From 0fa93b6d254788a2213708873d46225d6f5132e6 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:56:58 +0000 Subject: [PATCH 01/15] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index ee6f43e93..e45c32e4f 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 75 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-7acaeb315af90255109ae17afc71e32a8e5851bb8a956a2a284cb4d344dfab51.yml -openapi_spec_hash: 3044e94b48d60311b6048e8df88e7552 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-7074b9156acbeefa63e9ca2173e9c22768268e894a48f511ec902fdcff043407.yml +openapi_spec_hash: 400e8dc4ce4d49db45e2943f67fe255a config_hash: 593e89b291976a5e84e4c3c3f8324354 From 76252a98f28663e8c95777456d07e42171592c62 Mon Sep 17 00:00:00 2001 From: Cynthia Wang Date: Mon, 31 Aug 2026 11:08:39 -0400 Subject: [PATCH 02/15] feat(tracing): add opt-in commit SHA stamping for SGP spans (#505) Co-authored-by: Claude Opus 5 --- src/agentex/lib/adk/__init__.py | 4 + src/agentex/lib/core/tracing/code_revision.py | 105 +++++++++++++++++ .../processors/sgp_tracing_processor.py | 28 ++++- src/agentex/lib/environment_variables.py | 7 ++ .../processors/test_sgp_tracing_processor.py | 60 ++++++++++ tests/lib/core/tracing/test_code_revision.py | 109 ++++++++++++++++++ 6 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 src/agentex/lib/core/tracing/code_revision.py create mode 100644 tests/lib/core/tracing/test_code_revision.py diff --git a/src/agentex/lib/adk/__init__.py b/src/agentex/lib/adk/__init__.py index d5be0ac52..c05f8f3ea 100644 --- a/src/agentex/lib/adk/__init__.py +++ b/src/agentex/lib/adk/__init__.py @@ -31,6 +31,9 @@ # Data-source refs for lineage (SGP-6513); implementation lives in core.tracing from agentex.lib.core.tracing import lineage + +# Opt-in commit-SHA stamping (AGX1-969); implementation in core.tracing +from agentex.lib.core.tracing import code_revision from agentex.lib.core.tracing.lineage import DataSourceRef, data_sources # Unified harness surface (AGX1-375) @@ -73,6 +76,7 @@ "TurnSpan", # Lineage data-source refs (SGP-6513) "lineage", + "code_revision", "DataSourceRef", "data_sources", # Checkpointing / LangGraph diff --git a/src/agentex/lib/core/tracing/code_revision.py b/src/agentex/lib/core/tracing/code_revision.py new file mode 100644 index 000000000..7b08dd45f --- /dev/null +++ b/src/agentex/lib/core/tracing/code_revision.py @@ -0,0 +1,105 @@ +"""Opt-in stamping of the agent's source commit onto its spans. + +Nothing is stamped until the agent calls :func:`enable`, mirroring the +``lineage`` registry next door: a process-wide switch the agent sets once at +import, rather than automatic behaviour every agent inherits. When enabled the +resolved commit lands in span data under ``__commit_sha__`` and is searchable in +the SGP Traces UI as ``__commit_sha__:``. + +This is deliberately separate from ``__agent_version__``, which is automatic and +carries the deployed image tag verbatim ("image tag or git sha"). That tag is a +real commit on some build paths but an ``-`` composite (AWS +ECR), ``latest``, or a hand-passed tag on others -- so a field named for a commit +must not simply mirror it. Values that are not git object names are refused, and +a field named ``__commit_sha__`` therefore only ever holds one. +""" + +from __future__ import annotations + +import os +import re + +from agentex.lib.utils.logging import make_logger + +__all__ = ("COMMIT_SHA_KEY", "enable", "disable", "is_enabled", "commit_sha") + +logger = make_logger(__name__) + +COMMIT_SHA_KEY = "__commit_sha__" + +# A git object name: 40 hex for SHA-1, 64 for SHA-256, or an abbreviation down to +# git's own 7-character minimum. +_GIT_SHA_RE = re.compile(r"[0-9a-fA-F]{7,64}") + +_COMMIT_SHA_ENV = "AGENT_COMMIT_SHA" +# Fallback only: automatic, and only usable when it happens to be SHA-shaped. +_AGENT_VERSION_ENV = "AGENT_VERSION" + +# Resolved once at enable() rather than per span: the value is fixed for the +# life of the process, and resolving eagerly means a bad value is reported at +# startup instead of silently producing unstamped spans. +_commit_sha: str | None = None + + +def enable(commit_sha: str | None = None) -> None: + """Opt this process in to stamping ``__commit_sha__`` onto every span. + + Value precedence: the explicit ``commit_sha`` argument, else + ``AGENT_COMMIT_SHA``, else ``AGENT_VERSION`` when the deployment happened to + set it to a bare commit SHA. A value that is not a git object name is + refused with a warning and leaves stamping off -- better an absent field + than one named for a commit that holds an image tag. + """ + global _commit_sha + + for value, source in ( + (commit_sha, "the commit_sha argument"), + (os.environ.get(_COMMIT_SHA_ENV), _COMMIT_SHA_ENV), + (os.environ.get(_AGENT_VERSION_ENV), _AGENT_VERSION_ENV), + ): + candidate = (value or "").strip() + if not candidate: + continue + if _GIT_SHA_RE.fullmatch(candidate): + _commit_sha = candidate + logger.info("code revision stamping enabled from %s", source) + return + # An explicit argument or AGENT_COMMIT_SHA is a direct statement of + # intent, so a bad value there is worth surfacing. AGENT_VERSION is only + # a fallback and is expected to be a non-SHA tag much of the time, so + # falling through it quietly is correct, not a silent failure. + if source != _AGENT_VERSION_ENV: + logger.warning( + "%s=%r is not a git commit SHA; __commit_sha__ will not be stamped.", + source, + candidate, + ) + _commit_sha = None + return + + _commit_sha = None + logger.warning( + "code revision stamping was enabled but no commit SHA was found " + "(checked the commit_sha argument, %s, and %s); __commit_sha__ will not " + "be stamped. Set %s in the agent's environment -- e.g. bake it at build " + "time with a Dockerfile ARG/ENV.", + _COMMIT_SHA_ENV, + _AGENT_VERSION_ENV, + _COMMIT_SHA_ENV, + ) + + +def disable() -> None: + """Turn stamping back off (also used for test isolation).""" + global _commit_sha + _commit_sha = None + + +def is_enabled() -> bool: + """Whether a commit SHA resolved and will be stamped.""" + return _commit_sha is not None + + +def commit_sha() -> str | None: + """The resolved commit SHA, or ``None`` when stamping is not enabled.""" + return _commit_sha diff --git a/src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py b/src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py index a1c0edca2..9ee269231 100644 --- a/src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py +++ b/src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py @@ -3,7 +3,7 @@ import os import asyncio import weakref -from typing import cast, override +from typing import Any, cast, override import scale_gp_beta.lib.tracing as tracing from scale_gp_beta import SGPClient, AsyncSGPClient @@ -11,6 +11,7 @@ from scale_gp_beta.lib.tracing.span import Span as SGPSpan from agentex.types.span import Span +from agentex.lib.core.tracing import code_revision from agentex.lib.types.tracing import SGPTracingProcessorConfig from agentex.lib.utils.logging import make_logger from agentex.lib.core.observability import tracing_metrics_recording as _metrics @@ -69,6 +70,29 @@ def _add_source_to_span(span: Span, env_vars: EnvironmentVariables) -> None: span.data["__agent_version__"] = env_vars.AGENT_VERSION +def _sgp_metadata(span: Span) -> Any: + """Metadata for the SGP write: ``span.data`` plus the opt-in commit SHA. + + Returns a COPY rather than mutating ``span``. ``trace.py`` hands the same + Span instance to every registered processor, so anything written onto + ``span.data`` here would also be serialized by the Agentex processor and + show up in caller-visible span data. ``__commit_sha__`` is opt-in and + SGP-scoped, so it must not leak that way. + + (The ``__source__`` / ``__agent_*`` keys set by ``_add_source_to_span`` do + leak like that today. Left as-is: changing five long-shipped fields is not + this change's business.) + """ + commit_sha = code_revision.commit_sha() + if commit_sha is None: + return span.data + if isinstance(span.data, dict): + return {**span.data, code_revision.COMMIT_SHA_KEY: commit_sha} + # List-shaped data is an accepted `data` shape and has nowhere to put a + # metadata key; leave it untouched rather than dropping the caller's data. + return span.data + + def _build_sgp_span(span: Span, env_vars: EnvironmentVariables) -> SGPSpan: """Build an SGPSpan from an agentex Span. Idempotent on span_id at the SGP backend.""" _add_source_to_span(span, env_vars) @@ -82,7 +106,7 @@ def _build_sgp_span(span: Span, env_vars: EnvironmentVariables) -> SGPSpan: trace_id=span.trace_id, input=span.input, output=span.output, - metadata=span.data, + metadata=_sgp_metadata(span), ), ) sgp_span.start_time = span.start_time.isoformat() # type: ignore[union-attr] diff --git a/src/agentex/lib/environment_variables.py b/src/agentex/lib/environment_variables.py index 7d893e462..00dbbaada 100644 --- a/src/agentex/lib/environment_variables.py +++ b/src/agentex/lib/environment_variables.py @@ -25,6 +25,7 @@ class EnvVarKeys(str, Enum): AGENT_DESCRIPTION = "AGENT_DESCRIPTION" AGENT_ID = "AGENT_ID" AGENT_VERSION = "AGENT_VERSION" + AGENT_COMMIT_SHA = "AGENT_COMMIT_SHA" AGENT_API_KEY = "AGENT_API_KEY" # ACP Configuration ACP_URL = "ACP_URL" @@ -67,6 +68,12 @@ class EnvironmentVariables(BaseModel): AGENT_ID: str | None = None # Build/version discriminator (image tag or git sha), set by the deployment AGENT_VERSION: str | None = None + # The agent's source commit, baked into the image or set by the deployment. + # Unlike AGENT_VERSION this is expected to be a git SHA and nothing else, and + # it is OPT-IN: nothing is stamped unless the agent calls + # `adk.code_revision.enable()`, which also refuses a value that is not a git + # object name. See agentex.lib.core.tracing.code_revision. + AGENT_COMMIT_SHA: str | None = None AGENT_API_KEY: str | None = None ACP_TYPE: str | None = "async" AGENT_INPUT_TYPE: str | None = None diff --git a/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py b/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py index 4a233fb72..6cd324f01 100644 --- a/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py +++ b/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py @@ -54,6 +54,66 @@ def test_agent_identity_and_version_stamped_into_span_data(self): "__agent_version__": "sha-abc123", } + SHA = "b362b171a9c4e1f09d8e7a6b5c4d3e2f1a0b9c8d" + + def test_commit_sha_is_not_stamped_without_opt_in(self, monkeypatch): + """Upgrading the SDK must not start emitting __commit_sha__ on its own, + even when the environment carries a perfectly good SHA.""" + from agentex.lib.core.tracing import code_revision + from agentex.lib.core.tracing.processors.sgp_tracing_processor import _sgp_metadata + + monkeypatch.setenv("AGENT_COMMIT_SHA", self.SHA) + code_revision.disable() + + span = _make_span(); span.data = {} + assert "__commit_sha__" not in (_sgp_metadata(span) or {}) + + def test_commit_sha_is_stamped_after_opt_in(self, monkeypatch): + from agentex.lib.core.tracing import code_revision + from agentex.lib.core.tracing.processors.sgp_tracing_processor import _sgp_metadata + + monkeypatch.setenv("AGENT_COMMIT_SHA", self.SHA) + code_revision.enable() + try: + span = _make_span(); span.data = {"caller": "kept"} + metadata = _sgp_metadata(span) + assert metadata["__commit_sha__"] == self.SHA + assert metadata["caller"] == "kept" + finally: + code_revision.disable() + + def test_commit_sha_does_not_leak_onto_the_shared_span(self, monkeypatch): + """trace.py hands ONE Span to every processor. If the commit SHA were + written onto span.data, a co-registered Agentex processor would + serialize it too, and it would surface in caller-visible span data.""" + from agentex.lib.core.tracing import code_revision + from agentex.lib.core.tracing.processors.sgp_tracing_processor import _sgp_metadata + from agentex.lib.core.tracing.processors.agentex_tracing_processor import _create_kwargs + + monkeypatch.setenv("AGENT_COMMIT_SHA", self.SHA) + code_revision.enable() + try: + span = _make_span(); span.data = {} + assert _sgp_metadata(span)["__commit_sha__"] == self.SHA # SGP sees it + assert "__commit_sha__" not in span.data # the span does not + assert "__commit_sha__" not in (_create_kwargs(span)["data"] or {}) + finally: + code_revision.disable() + + def test_list_shaped_data_is_left_alone(self, monkeypatch): + """`data` may be a list of dicts; there is nowhere to put a metadata key, + and dropping the caller's data would be worse than omitting the field.""" + from agentex.lib.core.tracing import code_revision + from agentex.lib.core.tracing.processors.sgp_tracing_processor import _sgp_metadata + + monkeypatch.setenv("AGENT_COMMIT_SHA", self.SHA) + code_revision.enable() + try: + span = _make_span(); span.data = [{"a": 1}] + assert _sgp_metadata(span) == [{"a": 1}] + finally: + code_revision.disable() + def test_unset_identity_fields_are_omitted(self): from agentex.lib.core.tracing.processors.sgp_tracing_processor import _add_source_to_span diff --git a/tests/lib/core/tracing/test_code_revision.py b/tests/lib/core/tracing/test_code_revision.py new file mode 100644 index 000000000..0b89b88f2 --- /dev/null +++ b/tests/lib/core/tracing/test_code_revision.py @@ -0,0 +1,109 @@ +"""Opt-in commit-SHA stamping. + +The contract that matters: an agent that does not call ``enable()`` gets nothing, +so upgrading the SDK never starts emitting this field on its own. +""" + +from __future__ import annotations + +import pytest + +from agentex.lib.core.tracing import code_revision + +SHA = "b362b171a9c4e1f09d8e7a6b5c4d3e2f1a0b9c8d" + + +@pytest.fixture(autouse=True) +def _reset(): + """State is process-wide (like the lineage registry), so isolate each test.""" + code_revision.disable() + yield + code_revision.disable() + + +class TestOptIn: + def test_disabled_by_default(self, monkeypatch): + """Even with the env fully populated, nothing resolves until enable().""" + monkeypatch.setenv("AGENT_COMMIT_SHA", SHA) + monkeypatch.setenv("AGENT_VERSION", SHA) + assert code_revision.commit_sha() is None + assert code_revision.is_enabled() is False + + def test_enable_reads_agent_commit_sha(self, monkeypatch): + monkeypatch.setenv("AGENT_COMMIT_SHA", SHA) + code_revision.enable() + assert code_revision.commit_sha() == SHA + assert code_revision.is_enabled() is True + + def test_explicit_argument_wins(self, monkeypatch): + monkeypatch.setenv("AGENT_COMMIT_SHA", SHA) + code_revision.enable("7f3a91c2") + assert code_revision.commit_sha() == "7f3a91c2" + + def test_disable_turns_it_back_off(self, monkeypatch): + monkeypatch.setenv("AGENT_COMMIT_SHA", SHA) + code_revision.enable() + code_revision.disable() + assert code_revision.commit_sha() is None + + +class TestValueIsAlwaysACommit: + """A field named for a commit must never hold an image tag.""" + + @pytest.mark.parametrize( + "value", + [ + "latest", + "v1.2.3", + "0.2.4-v4", + "rocket_mock_agent-b362b171a9c4e1f09d8e7a6b5c4d3e2f1a0b9c8d", # AWS ECR composite + "abc", # shorter than git's 7-char minimum + "z" * 40, # right length, not hex + ], + ) + def test_non_sha_is_refused(self, monkeypatch, value): + monkeypatch.setenv("AGENT_COMMIT_SHA", value) + code_revision.enable() + assert code_revision.commit_sha() is None + + @pytest.mark.parametrize("value", [SHA, SHA.upper(), "b362b17", "a" * 64]) + def test_git_object_names_are_accepted(self, monkeypatch, value): + monkeypatch.setenv("AGENT_COMMIT_SHA", value) + code_revision.enable() + assert code_revision.commit_sha() == value + + def test_whitespace_only_is_refused(self, monkeypatch): + monkeypatch.setenv("AGENT_COMMIT_SHA", " ") + code_revision.enable() + assert code_revision.commit_sha() is None + + def test_enable_with_nothing_available_is_a_no_op(self, monkeypatch): + monkeypatch.delenv("AGENT_COMMIT_SHA", raising=False) + monkeypatch.delenv("AGENT_VERSION", raising=False) + code_revision.enable() + assert code_revision.commit_sha() is None + + +class TestAgentVersionFallback: + def test_falls_back_to_agent_version_when_sha_shaped(self, monkeypatch): + """A platform deploy already sets AGENT_VERSION; on GCP/Azure it is a + bare SHA, so an opting-in agent needs no extra plumbing.""" + monkeypatch.delenv("AGENT_COMMIT_SHA", raising=False) + monkeypatch.setenv("AGENT_VERSION", SHA) + code_revision.enable() + assert code_revision.commit_sha() == SHA + + def test_does_not_fall_back_to_a_non_sha_agent_version(self, monkeypatch): + """AGENT_VERSION is 'latest' or an AWS composite much of the time.""" + monkeypatch.delenv("AGENT_COMMIT_SHA", raising=False) + monkeypatch.setenv("AGENT_VERSION", "latest") + code_revision.enable() + assert code_revision.commit_sha() is None + + def test_bad_explicit_value_does_not_fall_through(self, monkeypatch): + """An explicit AGENT_COMMIT_SHA is a statement of intent: if it is wrong, + say so rather than silently substituting the image tag.""" + monkeypatch.setenv("AGENT_COMMIT_SHA", "not-a-sha") + monkeypatch.setenv("AGENT_VERSION", SHA) + code_revision.enable() + assert code_revision.commit_sha() is None From f394ce7f1dfd0088eb63e7cdf64ff37307990706 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:15:08 +0000 Subject: [PATCH 03/15] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index e45c32e4f..955f7e2ac 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 75 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-7074b9156acbeefa63e9ca2173e9c22768268e894a48f511ec902fdcff043407.yml -openapi_spec_hash: 400e8dc4ce4d49db45e2943f67fe255a +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-ee0c521f0612c31b874bd595b90cd9209545bab603983552a1a7a87f38ed931e.yml +openapi_spec_hash: 917a1ffe9e353bed2740524dec786ed2 config_hash: 593e89b291976a5e84e4c3c3f8324354 From f44750a7a15b45417e584111f9fb46c763ce4315 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 2 Sep 2026 17:41:53 +0000 Subject: [PATCH 04/15] Repository Setup --- .circleci/config.yml | 0 .github/workflows/bandit-ci.yml | 63 +++++++++ .github/workflows/opengrep-ci.yml | 18 +++ .github/workflows/opengrep-fp.yml | 18 +++ .github/workflows/output-template.json | 13 ++ .github/workflows/trufflehog-bypass.yml | 16 +++ .github/workflows/trufflehog-ci.yml | 13 ++ .github/workflows/trufflehog-weekly.yml | 26 ++++ .gitignore | 173 ++++++++++++++++++++++++ .trufflehog-exclude.txt | 13 ++ CODEOWNERS | 0 README.md | 16 +++ 12 files changed, 369 insertions(+) create mode 100644 .circleci/config.yml create mode 100644 .github/workflows/bandit-ci.yml create mode 100644 .github/workflows/opengrep-ci.yml create mode 100644 .github/workflows/opengrep-fp.yml create mode 100644 .github/workflows/output-template.json create mode 100644 .github/workflows/trufflehog-bypass.yml create mode 100644 .github/workflows/trufflehog-ci.yml create mode 100644 .github/workflows/trufflehog-weekly.yml create mode 100644 .gitignore create mode 100644 .trufflehog-exclude.txt create mode 100644 CODEOWNERS create mode 100644 README.md diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 000000000..e69de29bb diff --git a/.github/workflows/bandit-ci.yml b/.github/workflows/bandit-ci.yml new file mode 100644 index 000000000..0b93e2056 --- /dev/null +++ b/.github/workflows/bandit-ci.yml @@ -0,0 +1,63 @@ +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 + jq --argjson scanResults "$( 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/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/output-template.json b/.github/workflows/output-template.json new file mode 100644 index 000000000..e6303bcf9 --- /dev/null +++ b/.github/workflows/output-template.json @@ -0,0 +1,13 @@ +{ + "source": "github", + "organization": "\($organization)", + "timestamp": "\($time)", + "action": "\($action)", + "meta": { + "repository": "\($repository)", + "commit": "\($sha)", + "branch": "\($branch)", + "link": "\($link)" + }, + "results": [] +} 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/.gitignore b/.gitignore new file mode 100644 index 000000000..94993c3ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,173 @@ +# Logs +logs +*.log +npm-debug.log* +*.pth + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# IntelliJ +**/.idea +*.iml + +# VSCode +.vscode +*.code-workspace + +# filesystem files +.DS_Store + +# Local environment files +*.env +.env.* +*.envrc +frontend/.npmrc +local*.yaml + +# filesystem databases +dump.rdb +*.sqlite +*.db + +# Temp dirs +tmp + +### PYTHON + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints +_temp_extension +junit.xml +[uU]ntitled* +notebook/static/* +!notebook/static/favicons +notebook/labextension +notebook/schemas +docs/source/changelog.md +docs/source/contributing.md + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# pdm +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ diff --git a/.trufflehog-exclude.txt b/.trufflehog-exclude.txt new file mode 100644 index 000000000..bf4a81277 --- /dev/null +++ b/.trufflehog-exclude.txt @@ -0,0 +1,13 @@ +# Package manager lock files (contain integrity hashes that trigger false positives) +yarn\.lock +package-lock\.json +pnpm-lock\.yaml +Pipfile\.lock +uv\.lock + +# Other common false positive sources +go\.sum +Cargo\.lock +Gemfile\.lock +poetry\.lock +composer\.lock diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..e69de29bb diff --git a/README.md b/README.md new file mode 100644 index 000000000..7264db847 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# repository-template +A repository template for repository creation at Scale AI. + +## Usage +### Automatic +Request a new repository from the slackbot `Onyx` using `/onyx` and input the appropriate information such as desired language(s). + +Optionally scaffolds `CODEOWNERS` and `entity.datadog.yaml` when a GitHub owning team is selected. Workflow inputs `github_owner` and `description` are optional and skipped when unset. + +### Manual +Requires repository creation permissions and an appropriately-permissioned REPO_SETUP_TOKEN + +1. Create a new repository using this template +2. Add a secret `REPO_SETUP_TOKEN` to the new repository +3. Run the GitHub workflow `repository-setup`, inputting parameters as desired. +4. Allow the workflow to run and set up language-specific files and settings. From e5697db9196bdd4e0ca4a7548ad100c6d8c72d77 Mon Sep 17 00:00:00 2001 From: stlc-bot Date: Fri, 18 Sep 2026 12:59:19 -0700 Subject: [PATCH 05/15] ci: guard production-only workflows so they no-op on staging The promote model keeps staging main and production main SHA-identical, so every workflow file is shared. Four of them are production-app-specific and have none of their secrets on staging, where they would run and fail red on every codegen push -- and permanently red staging CI is what makes a genuinely red build invisible. Guarded on github.repository: agentex-tutorials-test (TUTORIAL_* keys), build-and-push-tutorial-agent (PACKAGE_TOKEN), harness-integration, and publish-pypi (matching the ts side, whose publish-npm is already guarded). Entry jobs only -- dependents skip via needs -- except test-summary, which is if: always() and so needed the condition ANDed. ci.yml is deliberately left unguarded: it references no secrets and running the SDK's own lint/test on staging is a useful signal that codegen is sound. Co-Authored-By: Claude Opus 5 --- .github/workflows/agentex-tutorials-test.yml | 6 +++++- .github/workflows/build-and-push-tutorial-agent.yml | 4 ++++ .github/workflows/harness-integration.yml | 8 ++++++++ .github/workflows/publish-pypi.yml | 4 ++++ 4 files changed, 21 insertions(+), 1 deletion(-) 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/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/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 From ebbf9006b535c227cb634f30edfbdca606d8cccd Mon Sep 17 00:00:00 2001 From: stlc-bot Date: Fri, 18 Sep 2026 13:43:43 -0700 Subject: [PATCH 06/15] ci(bandit): read scan results from file instead of passing them as argv The 'Format results appropriately from results.json' step passed the entire results file as a single shell argument: jq --argjson scanResults "$( --- .github/workflows/bandit-ci.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/bandit-ci.yml b/.github/workflows/bandit-ci.yml index 0b93e2056..d4690a71e 100644 --- a/.github/workflows/bandit-ci.yml +++ b/.github/workflows/bandit-ci.yml @@ -52,7 +52,13 @@ jobs: 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 - jq --argjson scanResults "$( output.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: | From 18bc39d3aa9187dcf224e1329a42fef6c11e71f5 Mon Sep 17 00:00:00 2001 From: Ari Nguyen Date: Mon, 21 Sep 2026 11:22:55 -0700 Subject: [PATCH 07/15] chore(stlc): reconcile staging main and back-sync production release #506 (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * feat(adk): allow all ClaudeAgentOptions in run_claude_agent_activity * release: 0.9.8 * Bump LiteLLM and urllib3 * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * fix(client): preserve hardcoded query params when merging with user params * codegen metadata * codegen metadata * codegen metadata * release: 0.9.9 * feat(adk): Revamp run_claude_agent_activity to use more streaming (#309) * codegen metadata * Fix cost bug (#313) * codegen metadata * release: 0.9.10 * Fix crash when .dockerignore file is missing during cloud build The build context preparation crashes with FileNotFoundError when a manifest specifies a dockerignore path but the file doesn't exist on disk. This adds an existence check and logs a warning instead of crashing, so builds proceed with no ignore patterns. Co-Authored-By: Claude Opus 4.6 * feat: add AgentCard for self-describing agent capabilities (#296) * Add AgentCard feature for self-describing agent capabilities via registration_metadata * Add tests for AgentCard feature, fix PEP 604 union unwrap in extract_literal_values * Fix ruff import sorting in __init__.py and test file * Fix pyright strict errors: use Enum isinstance checks, add override decorators in tests * Add AgentCard.from_states() classmethod for list[State] + initial_state usage * Minimize registration.py diff: only add agent_card param and merge logic * Add missing AGENTEX_DEPLOYMENT_ID to test mock env vars * fix(temporal): allowing-ACP-temporal-telemetry * fix: Temporal Union deserialization causing tool_response messages to be lost Temporal's payload converter deserializes Union types by trying each variant in order. ToolResponseContent was silently misdeserialized as TextContent (both share 'author' and 'content' fields), creating text messages instead of tool_response messages in the database. Fix: hooks now pass .model_dump() dicts to the activity, and the activity reconstructs the correct Pydantic model using the 'type' discriminator. Also fix test polling to handle the DONE/tool_response ordering race condition. * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * feat(api): api update * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * fix: ensure file data are only sent as 1 parameter * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * release: 0.10.0 * Add ShellTool support to TemporalStreamingModel openai-agents introduced a next-generation ShellTool (replacing LocalShellTool) that carries an environment config like {"type": "local", "skills": [...]}. The Temporal streaming model was dropping it with "Unknown tool type: ShellTool, skipping", so agents running through AgentEx/Temporal lost the tool entirely even though plain Runner.run(...) worked. Serialize ShellTool to the Responses API "shell" payload, defaulting environment to {"type": "local"} when unset. Import is guarded so users on older openai-agents versions (ShellTool not yet exported) continue to work. Co-Authored-By: Claude Opus 4.7 (1M context) * Upgrade openai-agents to 0.14.1 and temporalio to >=1.26.0 ShellTool (the next-gen replacement for LocalShellTool) is only exported in modern openai-agents versions. With the old 0.4.2 pin the ShellTool branch added in the prior commit was unreachable by default-install users. Bumps: - openai-agents 0.4.2 -> 0.14.1 - temporalio >=1.18.2 -> >=1.26.0 (matches the version that supports ShellTool serialization in temporalio.contrib.openai_agents) Co-Authored-By: Claude Opus 4.7 (1M context) * Narrow ComputerTool.computer union for Responses API serialization openai-agents 0.14 widened ComputerTool.computer to accept factory types (ComputerCreate/ComputerProvider) that don't expose environment or dimensions. Match the upstream pattern: narrow to Computer / AsyncComputer before reading those attributes, and validate that environment/dimensions are set. Co-Authored-By: Claude Opus 4.7 (1M context) * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * release: 0.10.1 * add support for Temporal PayloadCodec (#328) * codegen metadata * codegen metadata * perf(client): optimize file structure copying in multipart requests * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * feat(api): api update * fix(adk): fix to queue drain (#327) Co-authored-by: Declan Brady * codegen metadata * Add task_id to span creation (#329) * release: 0.10.2 * fix(tests): repair test_streaming_model so all 28 tests run and pass (#334) Four pre-existing bugs left this entire test file unrunnable on main (4 failures + 24 errors); fixing them here so the suite actually exercises TemporalStreamingModel and protects against regressions. Bug 1 (24 errors): `conftest.py` defines fixture `mock_adk_streaming` (no underscore) but every test in TestStreamingModelSettings and TestStreamingModelTools requested it as `_mock_adk_streaming`, so pytest failed to resolve the fixture before the body ever ran. The fixture is ``autouse=True`` and the param value was never used in any test body, so the parameter was vestigial — replaced with `_streaming_context_vars`, which provides the ContextVar setup these tests now actually need. Bug 2 (4 failures): `TemporalStreamingModel.get_response()` reads `task_id`, `trace_id`, and `parent_span_id` from ContextVars populated by `ContextInterceptor` from request headers in real Temporal flows. Tests had been passing `task_id=...` as a kwarg, which is silently swallowed by `**kwargs` and ignored, so all three ContextVars stayed at their defaults and the validation at the top of `get_response` raised before any work happened. New `_streaming_context_vars` fixture in conftest sets all three vars (and resets them on teardown), simulating what `ContextInterceptor` does in production. Bug 3 (test_computer_tool): A recent commit narrowed `ComputerTool` serialization to require an actual `Computer`/`AsyncComputer` instance, but `sample_computer_tool` still built a bare `MagicMock`. Switched to `MagicMock(spec=Computer)` so the production isinstance check passes. Bug 4 (3 streaming-context tests): The 3 tests in TestStreamingModelBasics that assert on `streaming_task_message_context` calls built event sequences with raw `MagicMock(type="...")`. Production dispatches via `isinstance(event, ResponseOutputItemAddedEvent)` etc., which `MagicMock` without `spec` never satisfies, so dispatch was silently skipped and the assertions failed. Switched to `MagicMock(spec=...)` for each event type — passes isinstance without triggering pydantic validation on the event's required fields. Also fixed `test_task_id_threading` which had been asserting against a hardcoded `task_id="test_task_12345"` that was never actually threaded anywhere (the kwarg was ignored, just like in Bug 2); it now asserts against the value yielded by the fixture, which is the value production reads from the ContextVar. After all four fixes: 28/28 pass, ruff clean, pyright clean. * release: 0.10.3 (#330) * feat(api): api update * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * chore(internal): more robust bootstrap script * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * fix: use correct field name format for multipart file arrays * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * feat: support setting headers via env * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * fix: allow litellm security patch (#336) * fix(adk): Always inject headers on execute activity (#337) * perf(streaming): coalesce per-token publishes to Redis (50ms / 128-char window) (#333) * perf(streaming): coalesce per-token publishes to Redis (50ms / 128-char window) Per-token Redis publishes from TemporalStreamingModel were adding ~45s (56-62%) overhead to agent response latency, mostly from head-of-line blocking on the model's event loop: each `await streaming_context.stream_update(...)` inside the OpenAI stream `async for` paused token consumption until the publish round-trip completed. This change introduces a `CoalescingBuffer` driven by an `asyncio.Event`, so the producer never awaits on Redis. Deltas are merged consecutive-only (preserving character order in every (type, index) channel) and flushed on a 50ms timer, on a 128-char size threshold, or immediately for the first delta to keep perceived responsiveness high. The buffer's `close()` drains remaining deltas before the DONE event, so consumers see the full sequence in order. A new `StreamingMode = Literal["off", "per_token", "coalesced"]` lives in `streaming.py` as the single source of truth and is plumbed through the adk streaming module, `StreamingService.streaming_task_message_context`, and `StreamingTaskMessageContext`. Default is `"coalesced"` everywhere, so all 13+ existing context callers (claude_agents, langgraph, litellm provider, openai sync provider, etc.) benefit automatically. * chore(streaming): fix import ordering (ruff I001) * fix(streaming): address greptile review findings - _run: when CancelledError is raised mid-flush in the for-loop, re-enqueue the in-flight item plus any remaining items in the local `drained` list back into self._buf so close()'s final drain can recover them. Previously the local `drained` list was unreachable after CancelledError exited the for-loop, causing the last coalesced batch to be silently dropped on close-during-flush races. Trade-off: the in-flight item may be duplicated on the consumer side (Redis pub may have completed before cancel was delivered), which is preferable to silent loss for streaming UX. - _merge_pair: replace `return b` fallback with AssertionError. All six current TaskMessageDelta variants have explicit isinstance branches, so the fallback is unreachable today. But _can_merge returns True for any same-type pair, so adding a 7th delta variant without updating _merge_pair would silently drop `a`'s accumulated content. Asserting turns a future silent data-loss into an immediate, diagnosable crash. * test(streaming): add coalescing-layer tests; loosen one model assertion After merging the test-suite repair from main (#334) into this branch, one model test (test_responses_api_streaming) regressed because its assert_called_with strict-matched all kwargs of streaming_task_message_context and didn't tolerate the new `streaming_mode='coalesced'` kwarg this PR adds. Switched to assert_called() + targeted kwarg checks so the test verifies what it cares about (task_id threading) without locking in implementation details. Replaced the ad-hoc smoke scripts that lived in conversation with a real pytest module at tests/lib/core/services/adk/test_streaming.py covering: - _delta_char_len, _can_merge, _merge_pair: per-channel correctness + None-handling - _merge_consecutive: pure-text collapse, cross-channel order preservation, per-channel reconstruction matches per-token semantics - CoalescingBuffer: first-delta-immediate flush within ~20ms, size-threshold flush before timer fires, multi-delta coalescing within one window, idle close, add-after-close no-op - CoalescingBuffer cancel-during-flush regression test for the P1 fix: five queued chunks must all surface across publishes when close() cancels mid-flush (asserts substring presence rather than exact ordering, since the documented trade-off allows duplicates of the in-flight item) - StreamingTaskMessageContext mode dispatch: "off" suppresses publishes but persists full content, "per_token" publishes each delta synchronously, "coalesced" batches and persists full content * chore(streaming): route TemporalStreamingModel logger through make_logger The model file used raw ``logging.getLogger("agentex.temporal.streaming")``, which returns a logger with no handler attached and no level configured — so the existing ``[TemporalStreamingModel] Initialized ... streaming_mode=...`` INFO log was silently dropped, making it impossible to verify at runtime that a coalesced (or any) streaming mode was actually wired. Switch to the SDK's ``make_logger`` helper (level=INFO, RichHandler in local mode, StreamHandler otherwise) used everywhere else in the SDK. The explicit logger name ``agentex.temporal.streaming`` is preserved so any external logging configuration targeting that name keeps working. * codegen metadata * feat(api): api update * release: 0.10.3 --------- Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Brandon Allen Co-authored-by: Declan Brady Co-authored-by: Stas Moreinis * release: 0.10.4 (#338) Co-authored-by: alvinkam2001 Co-authored-by: Declan Brady Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> * feat(openai_agents): expose real `usage`, `response_id`, plumb `previous_response_id`, opt-in `prompt_cache_key` for stateful responses and prompt caching (#335) Co-authored-by: Stas Moreinis * build(deps) bump scale-gp-beta to 0.2.0 (#344) * release: 0.10.5 (#343) Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Alvin Kam * Fix Redis stream leak: MAXLEN on xadd + sliding TTL on stream keys (#339) * ci: add conventional commit and PR base checks (#346) * release: 0.11.0 (#345) Co-authored-by: Daniel Miller Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> * fix: render .env.example template in agentex init (#351) Co-authored-by: Claude Opus 4.7 (1M context) * release: 0.11.1 (#350) Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Daniel Miller Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Stas Moreinis Co-authored-by: Devon Peticolas * release: 0.11.2 (#357) Co-authored-by: Declan Brady Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> * release: 0.11.3 (#358) Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Declan Brady Co-authored-by: Michael Chou * release: 0.11.4 (#364) Co-authored-by: Stas Moreinis Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Max Parke Co-authored-by: Claude Opus 4.7 (1M context) * release: 0.11.5 (#369) Co-authored-by: Max Parke Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Stas Moreinis Co-authored-by: Declan Brady Co-authored-by: Michael Chou Co-authored-by: Daniel Miller * release: 0.11.6 (#376) Co-authored-by: Max Parke Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Stas Moreinis Co-authored-by: Declan Brady Co-authored-by: Michael Chou Co-authored-by: Daniel Miller Co-authored-by: Matteo Librizzi * release: 0.11.7 (#382) Co-authored-by: Max Parke Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Stas Moreinis Co-authored-by: Declan Brady Co-authored-by: Michael Chou Co-authored-by: Daniel Miller Co-authored-by: Matteo Librizzi Co-authored-by: Stas Moreinis * release: 0.11.8 (#386) Co-authored-by: Max Parke Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Stas Moreinis Co-authored-by: Declan Brady Co-authored-by: Michael Chou Co-authored-by: Daniel Miller Co-authored-by: Matteo Librizzi Co-authored-by: Stas Moreinis Co-authored-by: James Cardenas * release: 0.11.9 (#389) Co-authored-by: Max Parke Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Stas Moreinis Co-authored-by: Declan Brady Co-authored-by: Michael Chou Co-authored-by: Daniel Miller Co-authored-by: Matteo Librizzi Co-authored-by: Stas Moreinis Co-authored-by: James Cardenas * release: 0.12.0 (#390) Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Max Parke Co-authored-by: Claude Opus 4.8 (1M context) * chore: release main (#393) Co-authored-by: Jerome Romualdez Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Max Parke Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Declan Brady Co-authored-by: Nitesh Dhanpal * chore: release main (#404) Co-authored-by: Max Parke Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> * chore: release main (#411) Co-authored-by: Max Parke Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Stas Moreinis Co-authored-by: Declan Brady Co-authored-by: Michael Chou Co-authored-by: Daniel Miller Co-authored-by: Matteo Librizzi Co-authored-by: Stas Moreinis Co-authored-by: James Cardenas Co-authored-by: Nitesh Dhanpal * chore: release main (#424) Co-authored-by: Declan Brady Co-authored-by: Vijay Kalmath <158184866+vkalmathscale@users.noreply.github.com> Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Nitesh Dhanpal Co-authored-by: Claude Opus 4.8 Co-authored-by: Daniel Miller * chore: release main (#443) Co-authored-by: Declan Brady Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: OpenAI Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> * chore: release main (#448) Co-authored-by: Endre Berki Co-authored-by: Claude Opus 4.8 Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> * chore: release main (#452) Co-authored-by: Jerome Romualdez Co-authored-by: Cursor Co-authored-by: Max Parke Co-authored-by: Claude Opus 4.8 Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> * chore: release main (#456) Co-authored-by: Daniel Miller Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Max Parke Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> * chore: release main (#457) Co-authored-by: Declan Brady Co-authored-by: Vijay Kalmath <158184866+vkalmathscale@users.noreply.github.com> Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Nitesh Dhanpal Co-authored-by: Claude Opus 4.8 Co-authored-by: Daniel Miller * chore: release main (#461) Co-authored-by: Nitesh Dhanpal Co-authored-by: Claude Opus 4.8 Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Levi Lentz * chore: release main (#463) Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Declan Brady * chore: release main (#464) Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Nitesh Dhanpal Co-authored-by: Claude Opus 4.8 Co-authored-by: Declan Brady * chore: release main (#475) Co-authored-by: Max Parke Co-authored-by: Claude Opus 4.8 Co-authored-by: Nitesh Dhanpal Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Deepthi Rao * chore: release main (#479) Co-authored-by: Deepthi Rao Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> * chore: release main (#483) Co-authored-by: Deepthi Rao Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> * chore: release main (#487) Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Nitesh Dhanpal Co-authored-by: Claude Opus 4.8 Co-authored-by: Javed Shaik Co-authored-by: Cursor Co-authored-by: Alvin Kam * chore: release main (#492) Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Nitesh Dhanpal Co-authored-by: Claude Opus 4.8 * chore: release main (#499) Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Max Parke Co-authored-by: Claude Opus 4.8 Co-authored-by: Nitesh Dhanpal Co-authored-by: Alvin Kam * codegen metadata * feat(tracing): add opt-in commit SHA stamping for SGP spans (#505) Co-authored-by: Claude Opus 5 * codegen metadata * chore: release main (#506) Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Cynthia Wang Co-authored-by: Claude Opus 5 Co-authored-by: Rishav Chakravarti Co-authored-by: Declan Brady * ci: guard production-only workflows so they no-op on staging The promote model keeps staging main and production main SHA-identical, so every workflow file is shared. Four of them are production-app-specific and have none of their secrets on staging, where they would run and fail red on every codegen push -- and permanently red staging CI is what makes a genuinely red build invisible. Guarded on github.repository: agentex-tutorials-test (TUTORIAL_* keys), build-and-push-tutorial-agent (PACKAGE_TOKEN), harness-integration, and publish-pypi (matching the ts side, whose publish-npm is already guarded). Entry jobs only -- dependents skip via needs -- except test-summary, which is if: always() and so needed the condition ANDed. ci.yml is deliberately left unguarded: it references no secrets and running the SDK's own lint/test on staging is a useful signal that codegen is sound. Co-Authored-By: Claude Opus 5 * ci(bandit): read scan results from file instead of passing them as argv The 'Format results appropriately from results.json' step passed the entire results file as a single shell argument: jq --argjson scanResults "$( --------- Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Declan Brady Co-authored-by: Raj Krishnan Co-authored-by: Daniel Miller Co-authored-by: Claude Opus 4.6 Co-authored-by: Prassanna Ravishankar Co-authored-by: Bruce Pannaman Co-authored-by: Bruce Pannaman Co-authored-by: Endre Berki Co-authored-by: Levi Lentz Co-authored-by: Stas Moreinis Co-authored-by: Brandon Allen Co-authored-by: alvinkam2001 Co-authored-by: Devon Peticolas Co-authored-by: Jean Lucas Co-authored-by: Michael Chou Co-authored-by: Max Parke Co-authored-by: Matteo Librizzi Co-authored-by: Stas Moreinis Co-authored-by: James Cardenas Co-authored-by: Jerome Romualdez Co-authored-by: Nitesh Dhanpal Co-authored-by: Vijay Kalmath <158184866+vkalmathscale@users.noreply.github.com> Co-authored-by: OpenAI Co-authored-by: Cursor Co-authored-by: Levi Lentz Co-authored-by: Deepthi Rao Co-authored-by: Javed Shaik Co-authored-by: Cynthia Wang Co-authored-by: Rishav Chakravarti Co-authored-by: stlc-bot --- .claude/settings.json | 15 + .cursor/rules/00_repo_tooling.mdc | 24 + .cursor/rules/05_permissions_and_tools.mdc | 18 + .cursor/rules/10_architecture.mdc | 30 + .cursor/rules/20_codegen_boundaries.mdc | 11 + .cursor/rules/30_cli_and_commands.mdc | 18 + .cursor/rules/40_temporal_and_agents.mdc | 17 + .cursor/rules/50_tests_and_mocking.mdc | 16 + .cursor/rules/60_style_lint_typecheck.mdc | 16 + .cursor/rules/70_examples_and_docs.mdc | 11 + .devcontainer/Dockerfile | 8 + .devcontainer/devcontainer.json | 43 + .github/scripts/sync_agents.py | 0 .github/workflows/agentex-tutorials-test.yml | 367 + .github/workflows/bandit-ci.yml | 8 +- .../build-and-push-tutorial-agent.yml | 377 + .github/workflows/ci.yml | 104 + .github/workflows/harness-integration.yml | 69 + .github/workflows/lint-pr.yaml | 144 + .github/workflows/publish-pypi.yml | 51 + .github/workflows/release-doctor.yml | 21 + .gitignore | 200 +- .python-version | 1 + .release-please-manifest.json | 4 + .stats.yml | 4 + .vscode/launch.json | 39 + .vscode/settings.json | 3 + Brewfile | 2 + CHANGELOG.md | 1349 ++++ CLAUDE.md | 133 + CONTRIBUTING.md | 159 + LICENSE | 201 + README.md | 420 +- SECURITY.md | 27 + adk/CHANGELOG.md | 165 + adk/README.md | 34 + adk/docs/harness.md | 206 + adk/docs/migration-0.16.0.md | 272 + adk/hatch_build.py | 41 + adk/pyproject.toml | 109 + api.md | 277 + bin/check-release-environment | 33 + bin/publish-pypi | 54 + examples/.keep | 4 + .../demos/procurement_agent/.dockerignore | 43 + examples/demos/procurement_agent/.gitignore | 62 + examples/demos/procurement_agent/Dockerfile | 48 + examples/demos/procurement_agent/README.md | 412 + examples/demos/procurement_agent/dev.ipynb | 126 + .../demos/procurement_agent/environments.yaml | 64 + .../demos/procurement_agent/evals/README.md | 63 + .../demos/procurement_agent/evals/__init__.py | 0 .../demos/procurement_agent/evals/conftest.py | 227 + .../evals/fixtures/__init__.py | 0 .../evals/fixtures/events.py | 108 + .../evals/graders/__init__.py | 0 .../evals/graders/database.py | 187 + .../evals/graders/tool_calls.py | 80 + .../demos/procurement_agent/evals/pytest.ini | 8 + .../demos/procurement_agent/evals/report.html | 1094 +++ .../procurement_agent/evals/tasks/__init__.py | 0 .../evals/tasks/test_inspection_failed.py | 162 + .../evals/tasks/test_inspection_passed.py | 116 + .../evals/tasks/test_shipment_arrived.py | 119 + .../evals/tasks/test_shipment_departed.py | 202 + .../evals/tasks/test_submittal_approved.py | 87 + .../demos/procurement_agent/manifest.yaml | 145 + .../procurement_agent/project/__init__.py | 0 .../demos/procurement_agent/project/acp.py | 59 + .../project/activities/__init__.py | 1 + .../project/activities/activities.py | 570 ++ .../project/agents/__init__.py | 1 + .../project/agents/extract_learnings_agent.py | 53 + .../project/agents/procurement_agent.py | 515 ++ .../project/agents/summarization_agent.py | 53 + .../project/data/__init__.py | 1 + .../project/data/database.py | 686 ++ .../project/models/__init__.py | 1 + .../project/models/events.py | 46 + .../procurement_agent/project/run_worker.py | 96 + .../project/scripts/__init__.py | 1 + .../project/scripts/happy_path.py | 184 + .../project/scripts/human_in_the_loop.py | 157 + .../project/scripts/out_of_order.py | 180 + .../project/scripts/send_test_events.py | 295 + .../project/scripts/send_test_events_lite.py | 192 + .../project/utils/__init__.py | 5 + .../project/utils/learning_extraction.py | 69 + .../project/utils/summarization.py | 205 + .../procurement_agent/project/workflow.py | 454 ++ .../demos/procurement_agent/pyproject.toml | 37 + examples/launch-tutorials.sh | 341 + .../00_sync/000_hello_acp/.dockerignore | 43 + .../00_sync/000_hello_acp/Dockerfile | 51 + .../tutorials/00_sync/000_hello_acp/README.md | 44 + .../tutorials/00_sync/000_hello_acp/dev.ipynb | 158 + .../00_sync/000_hello_acp/manifest.yaml | 120 + .../00_sync/000_hello_acp/project/__init__.py | 0 .../00_sync/000_hello_acp/project/acp.py | 35 + .../00_sync/000_hello_acp/pyproject.toml | 36 + .../00_sync/000_hello_acp/tests/test_agent.py | 129 + .../00_sync/010_multiturn/.dockerignore | 43 + .../.ipynb_checkpoints/dev-checkpoint.ipynb | 166 + .../00_sync/010_multiturn/Dockerfile | 51 + .../tutorials/00_sync/010_multiturn/README.md | 54 + .../tutorials/00_sync/010_multiturn/dev.ipynb | 166 + .../00_sync/010_multiturn/manifest.yaml | 118 + .../00_sync/010_multiturn/project/__init__.py | 0 .../00_sync/010_multiturn/project/acp.py | 110 + .../00_sync/010_multiturn/pyproject.toml | 35 + .../00_sync/010_multiturn/tests/test_agent.py | 172 + .../00_sync/020_streaming/.dockerignore | 43 + .../00_sync/020_streaming/Dockerfile | 50 + .../tutorials/00_sync/020_streaming/README.md | 45 + .../tutorials/00_sync/020_streaming/dev.ipynb | 158 + .../00_sync/020_streaming/manifest.yaml | 119 + .../00_sync/020_streaming/project/__init__.py | 0 .../00_sync/020_streaming/project/acp.py | 105 + .../00_sync/020_streaming/pyproject.toml | 35 + .../00_sync/020_streaming/tests/test_agent.py | 175 + .../00_sync/030_langgraph/.dockerignore | 43 + .../00_sync/030_langgraph/Dockerfile | 50 + .../tutorials/00_sync/030_langgraph/README.md | 55 + .../00_sync/030_langgraph/manifest.yaml | 58 + .../00_sync/030_langgraph/project/__init__.py | 0 .../00_sync/030_langgraph/project/acp.py | 107 + .../00_sync/030_langgraph/project/graph.py | 67 + .../00_sync/030_langgraph/project/tools.py | 24 + .../00_sync/030_langgraph/pyproject.toml | 37 + .../00_sync/030_langgraph/tests/test_agent.py | 144 + .../00_sync/040_pydantic_ai/.dockerignore | 43 + .../00_sync/040_pydantic_ai/Dockerfile | 50 + .../00_sync/040_pydantic_ai/README.md | 52 + .../00_sync/040_pydantic_ai/manifest.yaml | 58 + .../040_pydantic_ai/project/__init__.py | 0 .../00_sync/040_pydantic_ai/project/acp.py | 92 + .../00_sync/040_pydantic_ai/project/agent.py | 39 + .../00_sync/040_pydantic_ai/project/tools.py | 20 + .../00_sync/040_pydantic_ai/pyproject.toml | 36 + .../040_pydantic_ai/tests/test_agent.py | 137 + .../00_sync/050_openai_agents/.dockerignore | 43 + .../00_sync/050_openai_agents/Dockerfile | 50 + .../00_sync/050_openai_agents/README.md | 35 + .../00_sync/050_openai_agents/manifest.yaml | 58 + .../050_openai_agents/project/__init__.py | 0 .../00_sync/050_openai_agents/project/acp.py | 87 + .../050_openai_agents/project/agent.py | 47 + .../050_openai_agents/project/tools.py | 19 + .../00_sync/050_openai_agents/pyproject.toml | 36 + .../050_openai_agents/tests/test_agent.py | 48 + .../00_sync/060_claude_code/.dockerignore | 43 + .../00_sync/060_claude_code/Dockerfile | 46 + .../00_sync/060_claude_code/README.md | 76 + .../00_sync/060_claude_code/manifest.yaml | 55 + .../060_claude_code/project/__init__.py | 0 .../00_sync/060_claude_code/project/acp.py | 137 + .../00_sync/060_claude_code/pyproject.toml | 25 + .../060_claude_code/tests/test_agent.py | 162 + .../tests/test_agent_offline.py | 210 + .../tutorials/00_sync/070_codex/.dockerignore | 43 + .../tutorials/00_sync/070_codex/Dockerfile | 56 + .../tutorials/00_sync/070_codex/README.md | 40 + .../tutorials/00_sync/070_codex/conftest.py | 12 + .../tutorials/00_sync/070_codex/manifest.yaml | 58 + .../00_sync/070_codex/project/__init__.py | 0 .../00_sync/070_codex/project/acp.py | 175 + .../00_sync/070_codex/pyproject.toml | 38 + .../00_sync/070_codex/tests/test_agent.py | 176 + .../00_base/000_hello_acp/.dockerignore | 43 + .../10_async/00_base/000_hello_acp/Dockerfile | 51 + .../10_async/00_base/000_hello_acp/README.md | 49 + .../10_async/00_base/000_hello_acp/dev.ipynb | 126 + .../00_base/000_hello_acp/manifest.yaml | 122 + .../00_base/000_hello_acp/project/__init__.py | 0 .../00_base/000_hello_acp/project/acp.py | 75 + .../00_base/000_hello_acp/pyproject.toml | 33 + .../00_base/000_hello_acp/tests/test_agent.py | 191 + .../00_base/010_multiturn/.dockerignore | 43 + .../10_async/00_base/010_multiturn/Dockerfile | 51 + .../10_async/00_base/010_multiturn/README.md | 61 + .../10_async/00_base/010_multiturn/dev.ipynb | 126 + .../00_base/010_multiturn/manifest.yaml | 122 + .../00_base/010_multiturn/project/__init__.py | 0 .../00_base/010_multiturn/project/acp.py | 167 + .../00_base/010_multiturn/pyproject.toml | 33 + .../00_base/010_multiturn/tests/test_agent.py | 221 + .../00_base/020_streaming/.dockerignore | 43 + .../10_async/00_base/020_streaming/Dockerfile | 50 + .../10_async/00_base/020_streaming/README.md | 47 + .../10_async/00_base/020_streaming/dev.ipynb | 126 + .../00_base/020_streaming/manifest.yaml | 119 + .../00_base/020_streaming/project/__init__.py | 0 .../00_base/020_streaming/project/acp.py | 144 + .../00_base/020_streaming/pyproject.toml | 33 + .../00_base/020_streaming/tests/test_agent.py | 219 + .../00_base/030_tracing/.dockerignore | 43 + .../10_async/00_base/030_tracing/Dockerfile | 50 + .../10_async/00_base/030_tracing/README.md | 86 + .../10_async/00_base/030_tracing/dev.ipynb | 126 + .../00_base/030_tracing/manifest.yaml | 119 + .../00_base/030_tracing/project/__init__.py | 0 .../00_base/030_tracing/project/acp.py | 167 + .../00_base/030_tracing/pyproject.toml | 33 + .../00_base/030_tracing/tests/test_agent.py | 124 + .../00_base/040_other_sdks/.dockerignore | 43 + .../00_base/040_other_sdks/Dockerfile | 50 + .../10_async/00_base/040_other_sdks/README.md | 45 + .../10_async/00_base/040_other_sdks/dev.ipynb | 126 + .../00_base/040_other_sdks/manifest.yaml | 119 + .../040_other_sdks/project/__init__.py | 0 .../00_base/040_other_sdks/project/acp.py | 375 + .../00_base/040_other_sdks/pyproject.toml | 33 + .../040_other_sdks/tests/test_agent.py | 426 ++ .../00_base/080_batch_events/.dockerignore | 43 + .../00_base/080_batch_events/Dockerfile | 51 + .../00_base/080_batch_events/README.md | 46 + .../00_base/080_batch_events/dev.ipynb | 155 + .../00_base/080_batch_events/manifest.yaml | 117 + .../080_batch_events/project/__init__.py | 0 .../00_base/080_batch_events/project/acp.py | 235 + .../00_base/080_batch_events/pyproject.toml | 33 + .../080_batch_events/test_batch_events.py | 112 + .../080_batch_events/tests/test_agent.py | 233 + .../090_multi_agent_non_temporal/Dockerfile | 57 + .../090_multi_agent_non_temporal/README.md | 210 + .../090_multi_agent_non_temporal/creator.yaml | 42 + .../090_multi_agent_non_temporal/critic.yaml | 42 + .../formatter.yaml | 42 + .../orchestrator.yaml | 42 + .../project/__init__.py | 1 + .../project/creator.py | 294 + .../project/critic.py | 312 + .../project/formatter.py | 327 + .../project/models.py | 80 + .../project/orchestrator.py | 419 + .../project/state_machines/__init__.py | 1 + .../state_machines/content_workflow.py | 307 + .../pyproject.toml | 35 + .../start-agents.sh | 327 + .../tests/test_agent.py | 250 + .../00_base/100_langgraph/.dockerignore | 43 + .../10_async/00_base/100_langgraph/Dockerfile | 50 + .../10_async/00_base/100_langgraph/README.md | 57 + .../00_base/100_langgraph/manifest.yaml | 58 + .../00_base/100_langgraph/project/__init__.py | 0 .../00_base/100_langgraph/project/acp.py | 109 + .../00_base/100_langgraph/project/graph.py | 67 + .../00_base/100_langgraph/project/tools.py | 24 + .../00_base/100_langgraph/pyproject.toml | 37 + .../00_base/100_langgraph/tests/test_agent.py | 100 + .../00_base/110_pydantic_ai/.dockerignore | 43 + .../00_base/110_pydantic_ai/Dockerfile | 50 + .../00_base/110_pydantic_ai/README.md | 52 + .../00_base/110_pydantic_ai/manifest.yaml | 58 + .../110_pydantic_ai/project/__init__.py | 0 .../00_base/110_pydantic_ai/project/acp.py | 159 + .../00_base/110_pydantic_ai/project/agent.py | 39 + .../00_base/110_pydantic_ai/project/tools.py | 20 + .../00_base/110_pydantic_ai/pyproject.toml | 36 + .../110_pydantic_ai/tests/test_agent.py | 117 + .../00_base/120_openai_agents/.dockerignore | 43 + .../00_base/120_openai_agents/Dockerfile | 50 + .../00_base/120_openai_agents/README.md | 33 + .../00_base/120_openai_agents/manifest.yaml | 58 + .../120_openai_agents/project/__init__.py | 0 .../00_base/120_openai_agents/project/acp.py | 98 + .../120_openai_agents/project/agent.py | 43 + .../120_openai_agents/project/tools.py | 15 + .../00_base/120_openai_agents/pyproject.toml | 36 + .../120_openai_agents/tests/test_agent.py | 77 + .../00_base/130_claude_code/.dockerignore | 43 + .../00_base/130_claude_code/Dockerfile | 43 + .../00_base/130_claude_code/README.md | 76 + .../00_base/130_claude_code/manifest.yaml | 58 + .../130_claude_code/project/__init__.py | 0 .../00_base/130_claude_code/project/acp.py | 149 + .../00_base/130_claude_code/pyproject.toml | 25 + .../130_claude_code/tests/test_agent.py | 250 + .../tests/test_agent_offline.py | 243 + .../10_async/00_base/140_codex/.dockerignore | 43 + .../10_async/00_base/140_codex/Dockerfile | 45 + .../10_async/00_base/140_codex/README.md | 40 + .../10_async/00_base/140_codex/conftest.py | 12 + .../10_async/00_base/140_codex/manifest.yaml | 58 + .../00_base/140_codex/project/__init__.py | 0 .../10_async/00_base/140_codex/project/acp.py | 230 + .../10_async/00_base/140_codex/pyproject.toml | 38 + .../00_base/140_codex/tests/test_agent.py | 188 + .../10_temporal/000_hello_acp/.dockerignore | 43 + .../10_temporal/000_hello_acp/Dockerfile | 59 + .../10_temporal/000_hello_acp/README.md | 55 + .../10_temporal/000_hello_acp/dev.ipynb | 126 + .../10_temporal/000_hello_acp/manifest.yaml | 139 + .../000_hello_acp/project/__init__.py | 0 .../10_temporal/000_hello_acp/project/acp.py | 30 + .../000_hello_acp/project/run_worker.py | 34 + .../000_hello_acp/project/workflow.py | 79 + .../10_temporal/000_hello_acp/pyproject.toml | 34 + .../000_hello_acp/tests/test_agent.py | 189 + .../10_temporal/010_agent_chat/.dockerignore | 43 + .../10_temporal/010_agent_chat/Dockerfile | 59 + .../10_temporal/010_agent_chat/README.md | 47 + .../10_temporal/010_agent_chat/dev.ipynb | 1562 ++++ .../10_temporal/010_agent_chat/manifest.yaml | 139 + .../010_agent_chat/project/__init__.py | 0 .../10_temporal/010_agent_chat/project/acp.py | 30 + .../010_agent_chat/project/run_worker.py | 34 + .../010_agent_chat/project/workflow.py | 276 + .../10_temporal/010_agent_chat/pyproject.toml | 35 + .../010_agent_chat/tests/test_agent.py | 277 + .../020_state_machine/.dockerignore | 43 + .../10_temporal/020_state_machine/Dockerfile | 60 + .../10_temporal/020_state_machine/README.md | 70 + .../10_temporal/020_state_machine/dev.ipynb | 167 + .../020_state_machine/manifest.yaml | 138 + .../020_state_machine/project/__init__.py | 0 .../020_state_machine/project/acp.py | 30 + .../020_state_machine/project/run_worker.py | 34 + .../project/state_machines/deep_research.py | 41 + .../020_state_machine/project/workflow.py | 154 + .../deep_research/clarify_user_query.py | 89 + .../deep_research/performing_deep_research.py | 162 + .../deep_research/waiting_for_user_input.py | 21 + .../020_state_machine/pyproject.toml | 34 + .../020_state_machine/tests/test_agent.py | 193 + .../030_custom_activities/.dockerignore | 43 + .../030_custom_activities/Dockerfile | 59 + .../030_custom_activities/README.md | 106 + .../030_custom_activities/dev.ipynb | 228 + .../030_custom_activities/manifest.yaml | 138 + .../030_custom_activities/project/__init__.py | 0 .../030_custom_activities/project/acp.py | 60 + .../project/custom_activites.py | 111 + .../project/run_worker.py | 44 + .../project/shared_models.py | 14 + .../030_custom_activities/project/workflow.py | 216 + .../project/workflow_utils.py | 204 + .../030_custom_activities/pyproject.toml | 42 + .../030_custom_activities/tests/test_agent.py | 136 + .../050_agent_chat_guardrails/.dockerignore | 43 + .../050_agent_chat_guardrails/Dockerfile | 59 + .../050_agent_chat_guardrails/README.md | 70 + .../050_agent_chat_guardrails/dev.ipynb | 1196 +++ .../050_agent_chat_guardrails/manifest.yaml | 139 + .../project/__init__.py | 0 .../050_agent_chat_guardrails/project/acp.py | 30 + .../project/run_worker.py | 34 + .../project/workflow.py | 481 ++ .../050_agent_chat_guardrails/pyproject.toml | 34 + .../tests/test_agent.py | 136 + .../.dockerignore | 43 + .../Dockerfile | 62 + .../README.md | 105 + .../dev.ipynb | 124 + .../environments.yaml | 64 + .../manifest.yaml | 140 + .../project/__init__.py | 0 .../project/acp.py | 72 + .../project/run_worker.py | 69 + .../project/workflow.py | 313 + .../pyproject.toml | 37 + .../tests/test_agent.py | 132 + .../.dockerignore | 43 + .../070_open_ai_agents_sdk_tools/Dockerfile | 63 + .../070_open_ai_agents_sdk_tools/README.md | 180 + .../070_open_ai_agents_sdk_tools/dev.ipynb | 124 + .../environments.yaml | 64 + .../manifest.yaml | 139 + .../project/__init__.py | 0 .../project/acp.py | 72 + .../project/activities.py | 104 + .../project/run_worker.py | 71 + .../project/tools.py | 49 + .../project/workflow.py | 358 + .../pyproject.toml | 37 + .../tests/test_agent.py | 158 + .../.dockerignore | 43 + .../Dockerfile | 62 + .../README.md | 199 + .../dev.ipynb | 124 + .../environments.yaml | 64 + .../manifest.yaml | 140 + .../project/__init__.py | 0 .../project/acp.py | 95 + .../project/activities.py | 45 + .../project/child_workflow.py | 68 + .../project/run_worker.py | 73 + .../project/tools.py | 37 + .../project/workflow.py | 248 + .../pyproject.toml | 37 + .../tests/test_agent.py | 183 + .../090_claude_agents_sdk_mvp/.dockerignore | 43 + .../090_claude_agents_sdk_mvp/.gitignore | 5 + .../090_claude_agents_sdk_mvp/Dockerfile | 62 + .../090_claude_agents_sdk_mvp/README.md | 338 + .../090_claude_agents_sdk_mvp/manifest.yaml | 74 + .../090_claude_agents_sdk_mvp/project/acp.py | 75 + .../project/run_worker.py | 85 + .../project/workflow.py | 240 + .../090_claude_agents_sdk_mvp/pyproject.toml | 37 + .../tests/test_agent.py | 67 + .../workspace/.gitignore | 4 + .../100_gemini_litellm/.dockerignore | 43 + .../10_temporal/100_gemini_litellm/Dockerfile | 54 + .../10_temporal/100_gemini_litellm/README.md | 130 + .../100_gemini_litellm/project/__init__.py | 1 + .../100_gemini_litellm/project/acp.py | 60 + .../100_gemini_litellm/project/run_worker.py | 62 + .../100_gemini_litellm/project/workflow.py | 234 + .../100_gemini_litellm/pyproject.toml | 32 + .../10_temporal/110_pydantic_ai/.dockerignore | 43 + .../10_temporal/110_pydantic_ai/Dockerfile | 43 + .../10_temporal/110_pydantic_ai/README.md | 59 + .../10_temporal/110_pydantic_ai/manifest.yaml | 62 + .../110_pydantic_ai/project/__init__.py | 0 .../110_pydantic_ai/project/acp.py | 35 + .../110_pydantic_ai/project/agent.py | 111 + .../110_pydantic_ai/project/run_worker.py | 48 + .../110_pydantic_ai/project/tools.py | 24 + .../110_pydantic_ai/project/workflow.py | 137 + .../110_pydantic_ai/pyproject.toml | 38 + .../110_pydantic_ai/tests/test_agent.py | 113 + .../120_openai_agents/.dockerignore | 43 + .../10_temporal/120_openai_agents/Dockerfile | 43 + .../10_temporal/120_openai_agents/README.md | 41 + .../120_openai_agents/environments.yaml | 64 + .../120_openai_agents/manifest.yaml | 62 + .../120_openai_agents/project/__init__.py | 0 .../120_openai_agents/project/acp.py | 33 + .../120_openai_agents/project/activities.py | 80 + .../120_openai_agents/project/agent.py | 44 + .../120_openai_agents/project/run_worker.py | 44 + .../120_openai_agents/project/tools.py | 15 + .../120_openai_agents/project/workflow.py | 124 + .../120_openai_agents/pyproject.toml | 38 + .../120_openai_agents/tests/test_agent.py | 77 + .../10_temporal/130_langgraph/.dockerignore | 43 + .../10_temporal/130_langgraph/Dockerfile | 43 + .../10_temporal/130_langgraph/README.md | 49 + .../10_temporal/130_langgraph/manifest.yaml | 59 + .../130_langgraph/project/__init__.py | 0 .../10_temporal/130_langgraph/project/acp.py | 34 + .../130_langgraph/project/graph.py | 85 + .../130_langgraph/project/run_worker.py | 46 + .../130_langgraph/project/tools.py | 37 + .../130_langgraph/project/workflow.py | 80 + .../10_temporal/130_langgraph/pyproject.toml | 40 + .../130_langgraph/tests/test_agent.py | 106 + .../10_temporal/140_claude_code/.dockerignore | 43 + .../10_temporal/140_claude_code/Dockerfile | 46 + .../10_temporal/140_claude_code/README.md | 76 + .../10_temporal/140_claude_code/manifest.yaml | 62 + .../140_claude_code/project/__init__.py | 0 .../140_claude_code/project/acp.py | 31 + .../140_claude_code/project/activities.py | 139 + .../140_claude_code/project/run_worker.py | 41 + .../140_claude_code/project/workflow.py | 137 + .../140_claude_code/pyproject.toml | 27 + .../140_claude_code/tests/test_agent.py | 249 + .../tests/test_agent_offline.py | 230 + .../10_temporal/150_codex/.dockerignore | 43 + .../10_async/10_temporal/150_codex/Dockerfile | 48 + .../10_async/10_temporal/150_codex/README.md | 48 + .../10_temporal/150_codex/conftest.py | 17 + .../10_temporal/150_codex/manifest.yaml | 62 + .../10_temporal/150_codex/project/__init__.py | 0 .../10_temporal/150_codex/project/acp.py | 32 + .../150_codex/project/activities.py | 145 + .../150_codex/project/run_worker.py | 41 + .../10_temporal/150_codex/project/workflow.py | 145 + .../10_temporal/150_codex/pyproject.toml | 40 + .../10_temporal/150_codex/tests/test_agent.py | 275 + examples/tutorials/README.md | 155 + examples/tutorials/TEST_RUNNER_README.md | 142 + examples/tutorials/pytest.ini | 4 + examples/tutorials/run_agent_test.sh | 469 ++ examples/tutorials/test_utils/async_utils.py | 286 + examples/tutorials/test_utils/sync.py | 95 + pyproject.toml | 311 + release-please-config.json | 81 + requirements-dev.lock | 510 ++ scripts/bootstrap | 31 + scripts/check-slim-deps | 39 + scripts/check-wheel-install | 25 + scripts/format | 14 + scripts/lint | 19 + scripts/test | 32 + scripts/utils/ruffen-docs.py | 167 + scripts/utils/upload-artifact.sh | 27 + src/agentex/__init__.py | 104 + src/agentex/_base_client.py | 2131 ++++++ src/agentex/_client.py | 871 +++ src/agentex/_compat.py | 226 + src/agentex/_constants.py | 14 + src/agentex/_exceptions.py | 108 + src/agentex/_files.py | 173 + src/agentex/_models.py | 952 +++ src/agentex/_qs.py | 149 + src/agentex/_resource.py | 43 + src/agentex/_response.py | 833 ++ src/agentex/_streaming.py | 338 + src/agentex/_types.py | 273 + src/agentex/_utils/__init__.py | 64 + src/agentex/_utils/_compat.py | 45 + src/agentex/_utils/_datetime_parse.py | 136 + src/agentex/_utils/_json.py | 35 + src/agentex/_utils/_logs.py | 25 + src/agentex/_utils/_path.py | 127 + src/agentex/_utils/_proxy.py | 65 + src/agentex/_utils/_reflection.py | 42 + src/agentex/_utils/_resources_proxy.py | 24 + src/agentex/_utils/_streams.py | 12 + src/agentex/_utils/_sync.py | 58 + src/agentex/_utils/_transform.py | 457 ++ src/agentex/_utils/_typing.py | 158 + src/agentex/_utils/_utils.py | 433 ++ src/agentex/_version.py | 4 + src/agentex/config/__init__.py | 14 + src/agentex/config/_base.py | 7 + src/agentex/config/agent_config.py | 64 + src/agentex/config/agent_configs.py | 87 + src/agentex/config/agent_manifest.py | 24 + src/agentex/config/build_config.py | 37 + src/agentex/config/credentials.py | 34 + src/agentex/config/deployment_config.py | 117 + src/agentex/config/environment_config.py | 217 + .../config/local_development_config.py | 59 + src/agentex/lib/.keep | 4 + src/agentex/lib/__init__.py | 4 + src/agentex/lib/_version_guard.py | 28 + src/agentex/lib/adk/__init__.py | 118 + src/agentex/lib/adk/_modules/__init__.py | 0 .../lib/adk/_modules/_claude_code_sync.py | 417 + .../lib/adk/_modules/_claude_code_turn.py | 175 + src/agentex/lib/adk/_modules/_codex_sync.py | 679 ++ src/agentex/lib/adk/_modules/_codex_turn.py | 228 + .../lib/adk/_modules/_http_checkpointer.py | 380 + .../lib/adk/_modules/_langgraph_sync.py | 373 + .../lib/adk/_modules/_langgraph_turn.py | 200 + src/agentex/lib/adk/_modules/_openai_sync.py | 395 + src/agentex/lib/adk/_modules/_openai_turn.py | 134 + .../lib/adk/_modules/_pydantic_ai_sync.py | 350 + .../lib/adk/_modules/_pydantic_ai_turn.py | 173 + src/agentex/lib/adk/_modules/acp.py | 294 + .../lib/adk/_modules/agent_task_tracker.py | 180 + src/agentex/lib/adk/_modules/agents.py | 80 + src/agentex/lib/adk/_modules/checkpointer.py | 19 + src/agentex/lib/adk/_modules/events.py | 145 + src/agentex/lib/adk/_modules/messages.py | 301 + src/agentex/lib/adk/_modules/state.py | 295 + src/agentex/lib/adk/_modules/streaming.py | 89 + src/agentex/lib/adk/_modules/tasks.py | 478 ++ src/agentex/lib/adk/_modules/tracing.py | 427 ++ src/agentex/lib/adk/providers/__init__.py | 9 + .../lib/adk/providers/_modules/__init__.py | 0 .../lib/adk/providers/_modules/litellm.py | 240 + .../lib/adk/providers/_modules/openai.py | 514 ++ .../lib/adk/providers/_modules/openai_turn.py | 12 + src/agentex/lib/adk/providers/_modules/sgp.py | 87 + .../adk/providers/_modules/sync_provider.py | 394 + src/agentex/lib/adk/utils/__init__.py | 5 + .../lib/adk/utils/_modules/__init__.py | 0 src/agentex/lib/adk/utils/_modules/client.py | 32 + .../lib/adk/utils/_modules/templating.py | 96 + src/agentex/lib/cli/__init__.py | 0 src/agentex/lib/cli/commands/__init__.py | 0 src/agentex/lib/cli/commands/agents.py | 453 ++ src/agentex/lib/cli/commands/init.py | 448 ++ src/agentex/lib/cli/commands/main.py | 32 + src/agentex/lib/cli/commands/secrets.py | 171 + src/agentex/lib/cli/commands/tasks.py | 119 + src/agentex/lib/cli/commands/uv.py | 135 + src/agentex/lib/cli/debug/__init__.py | 15 + src/agentex/lib/cli/debug/debug_config.py | 115 + src/agentex/lib/cli/debug/debug_handlers.py | 179 + src/agentex/lib/cli/handlers/__init__.py | 0 .../lib/cli/handlers/agent_handlers.py | 284 + .../lib/cli/handlers/cleanup_handlers.py | 183 + .../lib/cli/handlers/deploy_handlers.py | 588 ++ src/agentex/lib/cli/handlers/run_handlers.py | 465 ++ .../lib/cli/handlers/secret_handlers.py | 672 ++ .../default-claude-code/.dockerignore.j2 | 43 + .../default-claude-code/.env.example.j2 | 13 + .../default-claude-code/Dockerfile-uv.j2 | 51 + .../default-claude-code/Dockerfile.j2 | 46 + .../default-claude-code/README.md.j2 | 64 + .../default-claude-code/dev.ipynb.j2 | 126 + .../default-claude-code/environments.yaml.j2 | 57 + .../default-claude-code/manifest.yaml.j2 | 123 + .../default-claude-code/project/acp.py.j2 | 167 + .../default-claude-code/pyproject.toml.j2 | 33 + .../default-claude-code/requirements.txt.j2 | 8 + .../templates/default-codex/.dockerignore.j2 | 43 + .../templates/default-codex/.env.example.j2 | 13 + .../templates/default-codex/Dockerfile-uv.j2 | 51 + .../cli/templates/default-codex/Dockerfile.j2 | 46 + .../cli/templates/default-codex/README.md.j2 | 72 + .../cli/templates/default-codex/dev.ipynb.j2 | 126 + .../default-codex/environments.yaml.j2 | 57 + .../templates/default-codex/manifest.yaml.j2 | 123 + .../templates/default-codex/project/acp.py.j2 | 271 + .../templates/default-codex/pyproject.toml.j2 | 33 + .../default-codex/requirements.txt.j2 | 8 + .../default-langgraph/.dockerignore.j2 | 43 + .../default-langgraph/.env.example.j2 | 13 + .../default-langgraph/Dockerfile-uv.j2 | 47 + .../templates/default-langgraph/Dockerfile.j2 | 42 + .../templates/default-langgraph/README.md.j2 | 85 + .../templates/default-langgraph/dev.ipynb.j2 | 126 + .../default-langgraph/environments.yaml.j2 | 57 + .../default-langgraph/manifest.yaml.j2 | 120 + .../default-langgraph/project/acp.py.j2 | 102 + .../default-langgraph/project/graph.py.j2 | 63 + .../default-langgraph/project/tools.py.j2 | 32 + .../default-langgraph/pyproject.toml.j2 | 35 + .../default-langgraph/requirements.txt.j2 | 10 + .../default-langgraph/test_agent.py.j2 | 147 + .../default-openai-agents/.dockerignore.j2 | 43 + .../default-openai-agents/.env.example.j2 | 13 + .../default-openai-agents/Dockerfile-uv.j2 | 47 + .../default-openai-agents/Dockerfile.j2 | 43 + .../default-openai-agents/README.md.j2 | 69 + .../default-openai-agents/dev.ipynb.j2 | 167 + .../environments.yaml.j2 | 53 + .../default-openai-agents/manifest.yaml.j2 | 115 + .../default-openai-agents/project/acp.py.j2 | 171 + .../default-openai-agents/pyproject.toml.j2 | 34 + .../default-openai-agents/requirements.txt.j2 | 11 + .../default-pydantic-ai/.dockerignore.j2 | 43 + .../default-pydantic-ai/.env.example.j2 | 12 + .../default-pydantic-ai/Dockerfile-uv.j2 | 47 + .../default-pydantic-ai/Dockerfile.j2 | 42 + .../default-pydantic-ai/README.md.j2 | 77 + .../default-pydantic-ai/dev.ipynb.j2 | 126 + .../default-pydantic-ai/environments.yaml.j2 | 57 + .../default-pydantic-ai/manifest.yaml.j2 | 120 + .../default-pydantic-ai/project/acp.py.j2 | 170 + .../default-pydantic-ai/project/agent.py.j2 | 43 + .../default-pydantic-ai/project/tools.py.j2 | 20 + .../default-pydantic-ai/pyproject.toml.j2 | 34 + .../default-pydantic-ai/requirements.txt.j2 | 9 + .../default-pydantic-ai/test_agent.py.j2 | 147 + .../cli/templates/default/.dockerignore.j2 | 43 + .../lib/cli/templates/default/.env.example.j2 | 13 + .../cli/templates/default/Dockerfile-uv.j2 | 47 + .../lib/cli/templates/default/Dockerfile.j2 | 42 + .../lib/cli/templates/default/README.md.j2 | 214 + .../lib/cli/templates/default/dev.ipynb.j2 | 126 + .../templates/default/environments.yaml.j2 | 57 + .../cli/templates/default/manifest.yaml.j2 | 119 + .../cli/templates/default/project/acp.py.j2 | 56 + .../cli/templates/default/pyproject.toml.j2 | 32 + .../cli/templates/default/requirements.txt.j2 | 5 + .../cli/templates/default/test_agent.py.j2 | 147 + .../sync-claude-code/.dockerignore.j2 | 43 + .../sync-claude-code/.env.example.j2 | 13 + .../sync-claude-code/Dockerfile-uv.j2 | 51 + .../templates/sync-claude-code/Dockerfile.j2 | 47 + .../templates/sync-claude-code/README.md.j2 | 64 + .../templates/sync-claude-code/dev.ipynb.j2 | 167 + .../sync-claude-code/environments.yaml.j2 | 53 + .../sync-claude-code/manifest.yaml.j2 | 120 + .../sync-claude-code/project/acp.py.j2 | 155 + .../sync-claude-code/pyproject.toml.j2 | 33 + .../sync-claude-code/requirements.txt.j2 | 8 + .../cli/templates/sync-codex/.dockerignore.j2 | 43 + .../cli/templates/sync-codex/.env.example.j2 | 13 + .../cli/templates/sync-codex/Dockerfile-uv.j2 | 51 + .../cli/templates/sync-codex/Dockerfile.j2 | 47 + .../lib/cli/templates/sync-codex/README.md.j2 | 67 + .../lib/cli/templates/sync-codex/dev.ipynb.j2 | 167 + .../templates/sync-codex/environments.yaml.j2 | 53 + .../cli/templates/sync-codex/manifest.yaml.j2 | 120 + .../templates/sync-codex/project/acp.py.j2 | 185 + .../templates/sync-codex/pyproject.toml.j2 | 33 + .../templates/sync-codex/requirements.txt.j2 | 8 + .../templates/sync-langgraph/.dockerignore.j2 | 43 + .../templates/sync-langgraph/.env.example.j2 | 13 + .../templates/sync-langgraph/Dockerfile-uv.j2 | 47 + .../templates/sync-langgraph/Dockerfile.j2 | 43 + .../cli/templates/sync-langgraph/README.md.j2 | 83 + .../cli/templates/sync-langgraph/dev.ipynb.j2 | 167 + .../sync-langgraph/environments.yaml.j2 | 53 + .../templates/sync-langgraph/manifest.yaml.j2 | 117 + .../sync-langgraph/project/acp.py.j2 | 103 + .../sync-langgraph/project/graph.py.j2 | 68 + .../sync-langgraph/project/tools.py.j2 | 32 + .../sync-langgraph/pyproject.toml.j2 | 35 + .../sync-langgraph/requirements.txt.j2 | 10 + .../templates/sync-langgraph/test_agent.py.j2 | 70 + .../.dockerignore.j2 | 43 + .../.env.example.j2 | 13 + .../Dockerfile-uv.j2 | 47 + .../Dockerfile.j2 | 43 + .../README.md.j2 | 327 + .../dev.ipynb.j2 | 167 + .../environments.yaml.j2 | 53 + .../manifest.yaml.j2 | 118 + .../project/acp.py.j2 | 84 + .../project/agent.py.j2 | 91 + .../project/tools.py.j2 | 29 + .../pyproject.toml.j2 | 36 + .../requirements.txt.j2 | 11 + .../test_agent.py.j2 | 135 + .../sync-openai-agents/.dockerignore.j2 | 43 + .../sync-openai-agents/.env.example.j2 | 13 + .../sync-openai-agents/Dockerfile-uv.j2 | 47 + .../sync-openai-agents/Dockerfile.j2 | 43 + .../templates/sync-openai-agents/README.md.j2 | 316 + .../templates/sync-openai-agents/dev.ipynb.j2 | 167 + .../sync-openai-agents/environments.yaml.j2 | 53 + .../sync-openai-agents/manifest.yaml.j2 | 115 + .../sync-openai-agents/project/acp.py.j2 | 156 + .../sync-openai-agents/pyproject.toml.j2 | 32 + .../sync-openai-agents/requirements.txt.j2 | 5 + .../sync-openai-agents/test_agent.py.j2 | 70 + .../sync-pydantic-ai/.dockerignore.j2 | 43 + .../sync-pydantic-ai/.env.example.j2 | 12 + .../sync-pydantic-ai/Dockerfile-uv.j2 | 47 + .../templates/sync-pydantic-ai/Dockerfile.j2 | 43 + .../templates/sync-pydantic-ai/README.md.j2 | 316 + .../templates/sync-pydantic-ai/dev.ipynb.j2 | 167 + .../sync-pydantic-ai/environments.yaml.j2 | 53 + .../sync-pydantic-ai/manifest.yaml.j2 | 115 + .../sync-pydantic-ai/project/acp.py.j2 | 98 + .../sync-pydantic-ai/project/agent.py.j2 | 42 + .../sync-pydantic-ai/project/tools.py.j2 | 20 + .../sync-pydantic-ai/pyproject.toml.j2 | 33 + .../sync-pydantic-ai/requirements.txt.j2 | 8 + .../sync-pydantic-ai/test_agent.py.j2 | 70 + .../lib/cli/templates/sync/.dockerignore.j2 | 43 + .../lib/cli/templates/sync/.env.example.j2 | 13 + .../lib/cli/templates/sync/Dockerfile-uv.j2 | 47 + .../lib/cli/templates/sync/Dockerfile.j2 | 43 + .../lib/cli/templates/sync/README.md.j2 | 316 + .../lib/cli/templates/sync/dev.ipynb.j2 | 167 + .../cli/templates/sync/environments.yaml.j2 | 53 + .../lib/cli/templates/sync/manifest.yaml.j2 | 115 + .../lib/cli/templates/sync/project/acp.py.j2 | 32 + .../lib/cli/templates/sync/pyproject.toml.j2 | 32 + .../cli/templates/sync/requirements.txt.j2 | 5 + .../lib/cli/templates/sync/test_agent.py.j2 | 70 + .../temporal-claude-code/.dockerignore.j2 | 43 + .../temporal-claude-code/.env.example.j2 | 13 + .../temporal-claude-code/Dockerfile-uv.j2 | 61 + .../temporal-claude-code/Dockerfile.j2 | 54 + .../temporal-claude-code/README.md.j2 | 73 + .../temporal-claude-code/dev.ipynb.j2 | 126 + .../temporal-claude-code/environments.yaml.j2 | 64 + .../temporal-claude-code/manifest.yaml.j2 | 142 + .../temporal-claude-code/project/acp.py.j2 | 31 + .../project/activities.py.j2 | 155 + .../project/run_worker.py.j2 | 41 + .../project/workflow.py.j2 | 148 + .../temporal-claude-code/pyproject.toml.j2 | 37 + .../temporal-claude-code/requirements.txt.j2 | 11 + .../templates/temporal-codex/.dockerignore.j2 | 43 + .../templates/temporal-codex/.env.example.j2 | 13 + .../templates/temporal-codex/Dockerfile-uv.j2 | 61 + .../templates/temporal-codex/Dockerfile.j2 | 54 + .../cli/templates/temporal-codex/README.md.j2 | 80 + .../cli/templates/temporal-codex/dev.ipynb.j2 | 126 + .../temporal-codex/environments.yaml.j2 | 64 + .../templates/temporal-codex/manifest.yaml.j2 | 142 + .../temporal-codex/project/acp.py.j2 | 32 + .../temporal-codex/project/activities.py.j2 | 151 + .../temporal-codex/project/run_worker.py.j2 | 41 + .../temporal-codex/project/workflow.py.j2 | 157 + .../temporal-codex/pyproject.toml.j2 | 37 + .../temporal-codex/requirements.txt.j2 | 11 + .../temporal-langgraph/.dockerignore.j2 | 43 + .../temporal-langgraph/.env.example.j2 | 13 + .../temporal-langgraph/Dockerfile-uv.j2 | 55 + .../temporal-langgraph/Dockerfile.j2 | 48 + .../templates/temporal-langgraph/README.md.j2 | 121 + .../templates/temporal-langgraph/dev.ipynb.j2 | 126 + .../temporal-langgraph/environments.yaml.j2 | 64 + .../temporal-langgraph/manifest.yaml.j2 | 140 + .../temporal-langgraph/project/acp.py.j2 | 42 + .../temporal-langgraph/project/graph.py.j2 | 165 + .../project/run_worker.py.j2 | 50 + .../temporal-langgraph/project/tools.py.j2 | 57 + .../temporal-langgraph/project/workflow.py.j2 | 263 + .../temporal-langgraph/pyproject.toml.j2 | 42 + .../temporal-langgraph/requirements.txt.j2 | 18 + .../temporal-langgraph/test_agent.py.j2 | 147 + .../temporal-openai-agents/.dockerignore.j2 | 43 + .../temporal-openai-agents/.env.example.j2 | 13 + .../temporal-openai-agents/Dockerfile-uv.j2 | 55 + .../temporal-openai-agents/Dockerfile.j2 | 48 + .../temporal-openai-agents/README.md.j2 | 224 + .../temporal-openai-agents/dev.ipynb.j2 | 126 + .../environments.yaml.j2 | 64 + .../temporal-openai-agents/manifest.yaml.j2 | 140 + .../temporal-openai-agents/project/acp.py.j2 | 86 + .../project/activities.py.j2 | 116 + .../project/run_worker.py.j2 | 56 + .../project/workflow.py.j2 | 181 + .../temporal-openai-agents/pyproject.toml.j2 | 35 + .../requirements.txt.j2 | 4 + .../temporal-openai-agents/test_agent.py.j2 | 147 + .../temporal-pydantic-ai/.dockerignore.j2 | 43 + .../temporal-pydantic-ai/.env.example.j2 | 12 + .../temporal-pydantic-ai/Dockerfile-uv.j2 | 55 + .../temporal-pydantic-ai/Dockerfile.j2 | 48 + .../temporal-pydantic-ai/README.md.j2 | 227 + .../temporal-pydantic-ai/dev.ipynb.j2 | 126 + .../temporal-pydantic-ai/environments.yaml.j2 | 64 + .../temporal-pydantic-ai/manifest.yaml.j2 | 140 + .../temporal-pydantic-ai/project/acp.py.j2 | 35 + .../temporal-pydantic-ai/project/agent.py.j2 | 115 + .../project/run_worker.py.j2 | 48 + .../temporal-pydantic-ai/project/tools.py.j2 | 20 + .../project/workflow.py.j2 | 153 + .../temporal-pydantic-ai/pyproject.toml.j2 | 35 + .../temporal-pydantic-ai/requirements.txt.j2 | 4 + .../temporal-pydantic-ai/test_agent.py.j2 | 147 + .../cli/templates/temporal/.dockerignore.j2 | 43 + .../cli/templates/temporal/.env.example.j2 | 13 + .../cli/templates/temporal/Dockerfile-uv.j2 | 55 + .../lib/cli/templates/temporal/Dockerfile.j2 | 48 + .../lib/cli/templates/temporal/README.md.j2 | 353 + .../lib/cli/templates/temporal/dev.ipynb.j2 | 126 + .../templates/temporal/environments.yaml.j2 | 64 + .../cli/templates/temporal/manifest.yaml.j2 | 140 + .../cli/templates/temporal/project/acp.py.j2 | 64 + .../temporal/project/activities.py.j2 | 77 + .../temporal/project/run_worker.py.j2 | 38 + .../templates/temporal/project/workflow.py.j2 | 66 + .../cli/templates/temporal/pyproject.toml.j2 | 34 + .../templates/temporal/requirements.txt.j2 | 5 + .../cli/templates/temporal/test_agent.py.j2 | 147 + src/agentex/lib/cli/utils/__init__.py | 0 src/agentex/lib/cli/utils/auth_utils.py | 61 + src/agentex/lib/cli/utils/cli_utils.py | 28 + src/agentex/lib/cli/utils/credential_utils.py | 103 + src/agentex/lib/cli/utils/exceptions.py | 6 + src/agentex/lib/cli/utils/kubectl_utils.py | 137 + .../lib/cli/utils/kubernetes_secrets_utils.py | 187 + src/agentex/lib/cli/utils/path_utils.py | 145 + src/agentex/lib/core/__init__.py | 0 src/agentex/lib/core/adapters/__init__.py | 0 src/agentex/lib/core/adapters/llm/__init__.py | 1 + .../lib/core/adapters/llm/adapter_litellm.py | 51 + .../lib/core/adapters/llm/adapter_sgp.py | 60 + src/agentex/lib/core/adapters/llm/port.py | 24 + .../core/adapters/streams/adapter_redis.py | 184 + src/agentex/lib/core/adapters/streams/port.py | 52 + src/agentex/lib/core/clients/__init__.py | 1 + .../lib/core/clients/temporal/__init__.py | 0 .../core/clients/temporal/temporal_client.py | 237 + .../lib/core/clients/temporal/types.py | 56 + .../lib/core/clients/temporal/utils.py | 160 + src/agentex/lib/core/compat/__init__.py | 1 + src/agentex/lib/core/compat/version_guard.py | 164 + src/agentex/lib/core/harness/__init__.py | 30 + src/agentex/lib/core/harness/auto_send.py | 156 + src/agentex/lib/core/harness/emitter.py | 80 + .../lib/core/harness/span_derivation.py | 173 + src/agentex/lib/core/harness/tracer.py | 119 + src/agentex/lib/core/harness/types.py | 96 + .../lib/core/harness/yield_delivery.py | 31 + .../lib/core/observability/__init__.py | 0 .../lib/core/observability/llm_metrics.py | 121 + .../core/observability/llm_metrics_hooks.py | 57 + .../lib/core/observability/tests/__init__.py | 0 .../observability/tests/test_llm_metrics.py | 83 + .../tests/test_llm_metrics_hooks.py | 215 + .../tests/test_tracing_metrics.py | 100 + .../tests/test_tracing_metrics_recording.py | 143 + .../lib/core/observability/tracing_metrics.py | 164 + .../tracing_metrics_recording.py | 153 + src/agentex/lib/core/services/__init__.py | 0 src/agentex/lib/core/services/adk/__init__.py | 0 .../lib/core/services/adk/acp/__init__.py | 0 src/agentex/lib/core/services/adk/acp/acp.py | 285 + .../core/services/adk/agent_task_tracker.py | 87 + src/agentex/lib/core/services/adk/agents.py | 43 + src/agentex/lib/core/services/adk/events.py | 63 + src/agentex/lib/core/services/adk/messages.py | 168 + .../core/services/adk/providers/__init__.py | 0 .../core/services/adk/providers/litellm.py | 277 + .../lib/core/services/adk/providers/openai.py | 934 +++ .../lib/core/services/adk/providers/sgp.py | 101 + src/agentex/lib/core/services/adk/state.py | 127 + .../lib/core/services/adk/streaming.py | 573 ++ src/agentex/lib/core/services/adk/tasks.py | 242 + src/agentex/lib/core/services/adk/tracing.py | 41 + .../lib/core/services/adk/utils/__init__.py | 0 .../lib/core/services/adk/utils/templating.py | 62 + src/agentex/lib/core/temporal/__init__.py | 0 .../lib/core/temporal/activities/__init__.py | 219 + .../temporal/activities/activity_helpers.py | 35 + .../core/temporal/activities/adk/__init__.py | 0 .../temporal/activities/adk/acp/__init__.py | 0 .../activities/adk/acp/acp_activities.py | 108 + .../adk/agent_task_tracker_activities.py | 78 + .../activities/adk/agents_activities.py | 37 + .../activities/adk/events_activities.py | 52 + .../activities/adk/messages_activities.py | 97 + .../activities/adk/providers/__init__.py | 0 .../adk/providers/litellm_activities.py | 76 + .../adk/providers/openai_activities.py | 689 ++ .../adk/providers/sgp_activities.py | 42 + .../activities/adk/state_activities.py | 87 + .../activities/adk/streaming_activities.py | 35 + .../activities/adk/tasks_activities.py | 149 + .../activities/adk/tracing_activities.py | 59 + .../temporal/activities/adk/utils/__init__.py | 0 .../adk/utils/templating_activities.py | 43 + .../lib/core/temporal/plugins/__init__.py | 55 + .../plugins/claude_agents/__init__.py | 85 + .../plugins/claude_agents/activities.py | 418 + .../plugins/claude_agents/hooks/__init__.py | 11 + .../plugins/claude_agents/hooks/hooks.py | 212 + .../plugins/claude_agents/message_handler.py | 178 + .../temporal/plugins/openai_agents/README.md | 750 ++ .../plugins/openai_agents/__init__.py | 86 + .../plugins/openai_agents/hooks/__init__.py | 17 + .../plugins/openai_agents/hooks/activities.py | 81 + .../plugins/openai_agents/hooks/hooks.py | 395 + .../openai_agents/interceptors/__init__.py | 19 + .../interceptors/context_interceptor.py | 153 + .../plugins/openai_agents/models/__init__.py | 15 + .../models/temporal_streaming_model.py | 1356 ++++ .../temporal/plugins/openai_agents/run.py | 161 + .../plugins/openai_agents/tests/__init__.py | 3 + .../plugins/openai_agents/tests/conftest.py | 331 + .../openai_agents/tests/test_convert_tools.py | 61 + .../openai_agents/tests/test_hosted_tools.py | 135 + .../tests/test_run_turn_and_hooks.py | 247 + .../tests/test_streaming_model.py | 1427 ++++ .../lib/core/temporal/services/__init__.py | 0 .../services/temporal_task_service.py | 154 + .../lib/core/temporal/types/__init__.py | 0 .../lib/core/temporal/types/workflow.py | 10 + .../lib/core/temporal/workers/__init__.py | 0 .../lib/core/temporal/workers/worker.py | 319 + .../lib/core/temporal/workflows/workflow.py | 191 + src/agentex/lib/core/tracing/__init__.py | 29 + src/agentex/lib/core/tracing/code_revision.py | 105 + src/agentex/lib/core/tracing/lineage.py | 174 + src/agentex/lib/core/tracing/obs_ids.py | 147 + src/agentex/lib/core/tracing/obs_span.py | 299 + .../processors/agentex_tracing_processor.py | 232 + .../processors/sgp_tracing_processor.py | 260 + .../processors/tracing_processor_interface.py | 83 + src/agentex/lib/core/tracing/span_error.py | 77 + src/agentex/lib/core/tracing/span_queue.py | 477 ++ src/agentex/lib/core/tracing/temporal.py | 73 + src/agentex/lib/core/tracing/trace.py | 567 ++ src/agentex/lib/core/tracing/tracer.py | 75 + .../core/tracing/tracing_processor_manager.py | 80 + src/agentex/lib/environment_variables.py | 134 + src/agentex/lib/py.typed | 0 src/agentex/lib/sdk/__init__.py | 0 src/agentex/lib/sdk/config/__init__.py | 0 src/agentex/lib/sdk/config/agent_config.py | 7 + src/agentex/lib/sdk/config/agent_manifest.py | 216 + src/agentex/lib/sdk/config/build_config.py | 10 + .../lib/sdk/config/deployment_config.py | 18 + .../lib/sdk/config/environment_config.py | 73 + .../sdk/config/local_development_config.py | 11 + src/agentex/lib/sdk/config/project_config.py | 105 + src/agentex/lib/sdk/config/validation.py | 256 + src/agentex/lib/sdk/fastacp/__init__.py | 3 + .../lib/sdk/fastacp/base/base_acp_server.py | 493 ++ src/agentex/lib/sdk/fastacp/base/constants.py | 36 + src/agentex/lib/sdk/fastacp/fastacp.py | 112 + .../lib/sdk/fastacp/impl/async_base_acp.py | 75 + src/agentex/lib/sdk/fastacp/impl/sync_acp.py | 111 + .../lib/sdk/fastacp/impl/temporal_acp.py | 153 + src/agentex/lib/sdk/fastacp/tests/README.md | 297 + src/agentex/lib/sdk/fastacp/tests/conftest.py | 311 + src/agentex/lib/sdk/fastacp/tests/pytest.ini | 10 + .../lib/sdk/fastacp/tests/run_tests.py | 227 + .../sdk/fastacp/tests/test_base_acp_server.py | 450 ++ .../sdk/fastacp/tests/test_fastacp_factory.py | 371 + .../lib/sdk/fastacp/tests/test_integration.py | 478 ++ src/agentex/lib/sdk/state_machine/__init__.py | 16 + .../lib/sdk/state_machine/noop_workflow.py | 25 + src/agentex/lib/sdk/state_machine/state.py | 10 + .../lib/sdk/state_machine/state_machine.py | 221 + .../lib/sdk/state_machine/state_workflow.py | 23 + src/agentex/lib/sdk/utils/__init__.py | 0 src/agentex/lib/sdk/utils/messages.py | 225 + src/agentex/lib/sdk/utils/webhooks.py | 389 + src/agentex/lib/types/__init__.py | 0 src/agentex/lib/types/acp.py | 16 + src/agentex/lib/types/agent_card.py | 158 + src/agentex/lib/types/agent_configs.py | 11 + src/agentex/lib/types/agent_results.py | 31 + src/agentex/lib/types/converters.py | 64 + src/agentex/lib/types/credentials.py | 7 + src/agentex/lib/types/fastacp.py | 113 + src/agentex/lib/types/files.py | 13 + src/agentex/lib/types/json_rpc.py | 11 + src/agentex/lib/types/llm_messages.py | 357 + src/agentex/lib/types/tracing.py | 37 + src/agentex/lib/utils/__init__.py | 0 src/agentex/lib/utils/build_provenance.py | 189 + src/agentex/lib/utils/completions.py | 148 + src/agentex/lib/utils/console.py | 16 + src/agentex/lib/utils/debug.py | 73 + src/agentex/lib/utils/dev_tools/__init__.py | 9 + .../lib/utils/dev_tools/async_messages.py | 423 ++ src/agentex/lib/utils/io.py | 31 + src/agentex/lib/utils/iterables.py | 16 + src/agentex/lib/utils/json_schema.py | 25 + src/agentex/lib/utils/logging.py | 98 + src/agentex/lib/utils/mcp.py | 20 + src/agentex/lib/utils/metadata_filters.py | 58 + src/agentex/lib/utils/model_utils.py | 71 + src/agentex/lib/utils/parsing.py | 15 + src/agentex/lib/utils/regex.py | 6 + src/agentex/lib/utils/registration.py | 113 + src/agentex/lib/utils/temporal.py | 28 + src/agentex/protocol/__init__.py | 16 + src/agentex/protocol/acp.py | 140 + src/agentex/protocol/json_rpc.py | 63 + src/agentex/py.typed | 0 src/agentex/resources/__init__.py | 145 + src/agentex/resources/agents/__init__.py | 47 + src/agentex/resources/agents/agents.py | 1545 ++++ src/agentex/resources/agents/deployments.py | 725 ++ src/agentex/resources/agents/schedules.py | 1845 +++++ src/agentex/resources/checkpoints.py | 589 ++ src/agentex/resources/deployment_history.py | 280 + src/agentex/resources/events.py | 294 + src/agentex/resources/messages/__init__.py | 33 + src/agentex/resources/messages/batch.py | 286 + src/agentex/resources/messages/messages.py | 2739 +++++++ src/agentex/resources/spans.py | 603 ++ src/agentex/resources/states.py | 558 ++ src/agentex/resources/tasks.py | 1530 ++++ src/agentex/resources/tracker.py | 416 + src/agentex/resources/webhooks.py | 242 + src/agentex/types/__init__.py | 94 + src/agentex/types/acp_type.py | 7 + src/agentex/types/agent.py | 48 + src/agentex/types/agent_list_params.py | 35 + src/agentex/types/agent_list_response.py | 10 + .../types/agent_register_build_params.py | 22 + src/agentex/types/agent_rpc_by_name_params.py | 107 + src/agentex/types/agent_rpc_params.py | 107 + src/agentex/types/agent_rpc_response.py | 56 + src/agentex/types/agent_rpc_result.py | 98 + src/agentex/types/agent_task_tracker.py | 34 + src/agentex/types/agents/__init__.py | 35 + .../types/agents/deployment_create_params.py | 25 + .../agents/deployment_create_response.py | 47 + .../types/agents/deployment_list_params.py | 22 + .../types/agents/deployment_list_response.py | 50 + .../agents/deployment_preview_rpc_params.py | 109 + .../agents/deployment_promote_response.py | 47 + .../agents/deployment_retrieve_response.py | 47 + .../types/agents/schedule_create_params.py | 63 + .../types/agents/schedule_create_response.py | 123 + .../types/agents/schedule_list_params.py | 14 + .../types/agents/schedule_list_response.py | 133 + .../agents/schedule_pause_by_name_params.py | 15 + .../agents/schedule_pause_by_name_response.py | 123 + .../types/agents/schedule_pause_params.py | 15 + .../types/agents/schedule_pause_response.py | 123 + .../agents/schedule_resume_by_name_params.py | 15 + .../schedule_resume_by_name_response.py | 123 + .../types/agents/schedule_resume_params.py | 15 + .../types/agents/schedule_resume_response.py | 123 + .../schedule_retrieve_by_name_response.py | 123 + .../agents/schedule_retrieve_response.py | 123 + .../types/agents/schedule_skip_params.py | 18 + .../types/agents/schedule_skip_response.py | 123 + .../schedule_trigger_by_name_response.py | 123 + .../types/agents/schedule_trigger_response.py | 123 + .../types/agents/schedule_unskip_params.py | 18 + .../types/agents/schedule_unskip_response.py | 123 + .../agents/schedule_update_by_name_params.py | 62 + .../schedule_update_by_name_response.py | 123 + .../types/agents/schedule_update_params.py | 62 + .../types/agents/schedule_update_response.py | 123 + .../types/checkpoint_delete_thread_params.py | 11 + .../types/checkpoint_get_tuple_params.py | 16 + .../types/checkpoint_get_tuple_response.py | 47 + src/agentex/types/checkpoint_list_params.py | 20 + src/agentex/types/checkpoint_list_response.py | 25 + src/agentex/types/checkpoint_put_params.py | 34 + src/agentex/types/checkpoint_put_response.py | 13 + .../types/checkpoint_put_writes_params.py | 34 + src/agentex/types/data_content.py | 30 + src/agentex/types/data_content_param.py | 31 + src/agentex/types/data_delta.py | 16 + src/agentex/types/deployment_history.py | 35 + .../types/deployment_history_list_params.py | 22 + .../types/deployment_history_list_response.py | 10 + src/agentex/types/event.py | 29 + src/agentex/types/event_list_params.py | 22 + src/agentex/types/event_list_response.py | 10 + src/agentex/types/message_author.py | 7 + src/agentex/types/message_create_params.py | 29 + .../types/message_list_paginated_params.py | 534 ++ .../types/message_list_paginated_response.py | 21 + src/agentex/types/message_list_params.py | 536 ++ src/agentex/types/message_list_response.py | 10 + src/agentex/types/message_style.py | 7 + src/agentex/types/message_update_params.py | 18 + src/agentex/types/messages/__init__.py | 8 + .../types/messages/batch_create_params.py | 26 + .../types/messages/batch_create_response.py | 10 + .../types/messages/batch_update_params.py | 16 + .../types/messages/batch_update_response.py | 10 + src/agentex/types/reasoning_content.py | 33 + src/agentex/types/reasoning_content_delta.py | 18 + src/agentex/types/reasoning_content_param.py | 35 + src/agentex/types/reasoning_summary_delta.py | 18 + src/agentex/types/shared/__init__.py | 3 + src/agentex/types/shared/delete_response.py | 11 + src/agentex/types/span.py | 39 + src/agentex/types/span_create_params.py | 43 + src/agentex/types/span_list_params.py | 22 + src/agentex/types/span_list_response.py | 10 + src/agentex/types/span_update_params.py | 40 + src/agentex/types/state.py | 35 + src/agentex/types/state_create_params.py | 16 + src/agentex/types/state_list_params.py | 28 + src/agentex/types/state_list_response.py | 10 + src/agentex/types/state_update_params.py | 12 + src/agentex/types/task.py | 31 + src/agentex/types/task_cancel_params.py | 12 + src/agentex/types/task_complete_params.py | 12 + src/agentex/types/task_fail_params.py | 12 + src/agentex/types/task_interrupt_params.py | 12 + src/agentex/types/task_list_params.py | 35 + src/agentex/types/task_list_response.py | 42 + src/agentex/types/task_message.py | 39 + src/agentex/types/task_message_content.py | 18 + .../types/task_message_content_param.py | 18 + src/agentex/types/task_message_delta.py | 19 + src/agentex/types/task_message_update.py | 91 + .../types/task_query_workflow_response.py | 8 + .../types/task_retrieve_by_name_params.py | 12 + .../types/task_retrieve_by_name_response.py | 36 + src/agentex/types/task_retrieve_params.py | 12 + src/agentex/types/task_retrieve_response.py | 36 + src/agentex/types/task_terminate_params.py | 12 + src/agentex/types/task_timeout_params.py | 12 + src/agentex/types/task_update_by_id_params.py | 14 + .../types/task_update_by_name_params.py | 14 + src/agentex/types/text_content.py | 56 + src/agentex/types/text_content_param.py | 57 + src/agentex/types/text_delta.py | 16 + src/agentex/types/text_format.py | 7 + src/agentex/types/tool_request_content.py | 36 + .../types/tool_request_content_param.py | 37 + src/agentex/types/tool_request_delta.py | 20 + src/agentex/types/tool_response_content.py | 42 + .../types/tool_response_content_param.py | 43 + src/agentex/types/tool_response_delta.py | 20 + src/agentex/types/tracker_list_params.py | 28 + src/agentex/types/tracker_list_response.py | 10 + src/agentex/types/tracker_update_params.py | 19 + .../webhook_create_webhook_trigger_params.py | 42 + ...webhook_create_webhook_trigger_response.py | 31 + tests/__init__.py | 1 + tests/api_resources/__init__.py | 1 + tests/api_resources/agents/__init__.py | 1 + .../api_resources/agents/test_deployments.py | 726 ++ tests/api_resources/agents/test_schedules.py | 1944 +++++ tests/api_resources/messages/__init__.py | 1 + tests/api_resources/messages/test_batch.py | 298 + tests/api_resources/test_agents.py | 808 ++ tests/api_resources/test_checkpoints.py | 563 ++ .../api_resources/test_deployment_history.py | 191 + tests/api_resources/test_events.py | 205 + tests/api_resources/test_messages.py | 630 ++ tests/api_resources/test_spans.py | 424 ++ tests/api_resources/test_states.py | 447 ++ tests/api_resources/test_tasks.py | 1580 ++++ tests/api_resources/test_tracker.py | 297 + tests/api_resources/test_webhooks.py | 131 + tests/compat/__init__.py | 0 tests/compat/refresh_specs.py | 34 + tests/compat/server_specs/current.yaml | 6741 ++++++++++++++++ tests/compat/server_specs/manifest.json | 19 + tests/compat/server_specs/min-supported.yaml | 6761 +++++++++++++++++ tests/compat/test_request_compat.py | 129 + tests/conftest.py | 84 + tests/lib/__init__.py | 0 tests/lib/adk/__init__.py | 0 tests/lib/adk/conftest.py | 33 + tests/lib/adk/providers/__init__.py | 0 tests/lib/adk/providers/test_litellm_usage.py | 219 + .../adk/providers/test_openai_activities.py | 843 ++ tests/lib/adk/providers/test_openai_turn.py | 248 + tests/lib/adk/test_claude_code_sync.py | 715 ++ tests/lib/adk/test_claude_code_turn.py | 351 + tests/lib/adk/test_codex_sync.py | 828 ++ tests/lib/adk/test_codex_turn.py | 341 + tests/lib/adk/test_langgraph_async.py | 282 + tests/lib/adk/test_langgraph_sync.py | 393 + tests/lib/adk/test_langgraph_turn.py | 265 + tests/lib/adk/test_messages_module.py | 120 + tests/lib/adk/test_messages_service.py | 98 + tests/lib/adk/test_openai_sync.py | 189 + tests/lib/adk/test_pydantic_ai_async.py | 776 ++ tests/lib/adk/test_pydantic_ai_sync.py | 639 ++ tests/lib/adk/test_pydantic_ai_turn.py | 276 + tests/lib/adk/test_state_service.py | 69 + tests/lib/adk/test_tasks_activities.py | 249 + tests/lib/adk/test_tasks_module.py | 254 + tests/lib/adk/test_tasks_service.py | 159 + tests/lib/adk/test_tracing_activities.py | 96 + tests/lib/adk/test_tracing_module.py | 437 ++ tests/lib/adk/test_tracing_service.py | 84 + tests/lib/cli/__init__.py | 0 tests/lib/cli/test_agent_handlers.py | 424 ++ tests/lib/cli/test_environment_config.py | 346 + tests/lib/cli/test_init_templates.py | 139 + tests/lib/cli/test_run_handlers_streaming.py | 180 + tests/lib/cli/test_validation.py | 76 + tests/lib/core/__init__.py | 0 tests/lib/core/harness/__init__.py | 0 tests/lib/core/harness/_fakes.py | 63 + .../lib/core/harness/conformance/__init__.py | 0 .../lib/core/harness/conformance/conftest.py | 21 + tests/lib/core/harness/conformance/runner.py | 507 ++ .../test_claude_code_conformance.py | 192 + .../conformance/test_codex_conformance.py | 215 + .../harness/conformance/test_conformance.py | 299 + .../conformance/test_langgraph_conformance.py | 218 + .../conformance/test_openai_conformance.py | 206 + .../test_pydantic_ai_conformance.py | 187 + tests/lib/core/harness/test_auto_send.py | 480 ++ tests/lib/core/harness/test_emitter.py | 142 + .../harness/test_harness_claude_code_async.py | 248 + .../harness/test_harness_claude_code_sync.py | 303 + .../test_harness_claude_code_temporal.py | 183 + .../core/harness/test_harness_codex_async.py | 228 + .../core/harness/test_harness_codex_sync.py | 276 + .../harness/test_harness_codex_temporal.py | 180 + .../harness/test_harness_langgraph_async.py | 276 + .../harness/test_harness_langgraph_sync.py | 205 + .../test_harness_langgraph_temporal.py | 232 + .../core/harness/test_harness_openai_async.py | 305 + .../core/harness/test_harness_openai_sync.py | 323 + .../harness/test_harness_openai_temporal.py | 195 + .../harness/test_harness_pydantic_ai_async.py | 330 + .../harness/test_harness_pydantic_ai_sync.py | 357 + .../test_harness_pydantic_ai_temporal.py | 370 + .../lib/core/harness/test_span_derivation.py | 365 + tests/lib/core/harness/test_tracer.py | 98 + tests/lib/core/harness/test_tracer_lineage.py | 53 + tests/lib/core/harness/test_types.py | 53 + tests/lib/core/harness/test_yield_delivery.py | 78 + tests/lib/core/services/__init__.py | 0 tests/lib/core/services/adk/__init__.py | 0 tests/lib/core/services/adk/test_streaming.py | 599 ++ .../services/test_temporal_task_service.py | 140 + tests/lib/core/temporal/__init__.py | 0 tests/lib/core/temporal/plugins/__init__.py | 0 .../plugins/openai_agents/__init__.py | 0 .../plugins/openai_agents/test_model_usage.py | 182 + .../test_base_workflow_continue_as_new.py | 75 + tests/lib/core/temporal/workers/__init__.py | 0 .../workers/test_worker_version_guard.py | 70 + tests/lib/core/tracing/__init__.py | 0 tests/lib/core/tracing/processors/__init__.py | 0 .../test_agentex_tracing_processor.py | 285 + .../processors/test_sgp_tracing_processor.py | 573 ++ .../test_tracing_processor_interface.py | 98 + tests/lib/core/tracing/test_code_revision.py | 109 + tests/lib/core/tracing/test_lineage.py | 147 + tests/lib/core/tracing/test_obs_ids.py | 126 + tests/lib/core/tracing/test_obs_span.py | 516 ++ tests/lib/core/tracing/test_span_error.py | 216 + tests/lib/core/tracing/test_span_queue.py | 893 +++ .../lib/core/tracing/test_span_queue_load.py | 306 + .../core/tracing/test_temporal_interceptor.py | 40 + tests/lib/core/tracing/test_trace_task_id.py | 55 + tests/lib/test_agent_card.py | 454 ++ tests/lib/test_agentex_worker.py | 354 + tests/lib/test_auto_send_params_created_at.py | 101 + tests/lib/test_build_provenance.py | 257 + tests/lib/test_claude_agents_activities.py | 737 ++ tests/lib/test_claude_agents_hooks.py | 398 + tests/lib/test_metadata_filters.py | 112 + tests/lib/test_payload_codec.py | 385 + tests/lib/test_state_machine.py | 68 + tests/lib/test_temporal_utils.py | 30 + tests/lib/test_version_guard.py | 26 + tests/lib/test_webhooks.py | 267 + tests/lib/utils/__init__.py | 0 tests/lib/utils/test_completions.py | 52 + tests/lib/utils/test_logging_level.py | 66 + tests/sample_file.txt | 1 + tests/test_acp_interrupt.py | 178 + tests/test_adk_tracing_span_error.py | 120 + tests/test_client.py | 2023 +++++ tests/test_config_shims.py | 117 + tests/test_extract_files.py | 91 + tests/test_files.py | 148 + tests/test_function_tool.py | 256 + tests/test_header_forwarding.py | 541 ++ tests/test_model_utils.py | 226 + tests/test_models.py | 1017 +++ tests/test_obs_handle_registry.py | 126 + tests/test_obs_span_fallback.py | 116 + tests/test_protocol_shims.py | 98 + tests/test_qs.py | 78 + tests/test_required_args.py | 111 + tests/test_response.py | 277 + tests/test_streaming.py | 248 + tests/test_task_cancel.py | 41 + tests/test_temporal_obs_backend.py | 270 + tests/test_trace_context_extraction.py | 87 + tests/test_transform.py | 460 ++ tests/test_utils/test_datetime_parse.py | 110 + tests/test_utils/test_json.py | 126 + tests/test_utils/test_path.py | 89 + tests/test_utils/test_proxy.py | 34 + tests/test_utils/test_typing.py | 73 + tests/test_version_guard.py | 208 + tests/utils.py | 167 + uv.lock | 3692 +++++++++ 1323 files changed, 188012 insertions(+), 156 deletions(-) create mode 100644 .claude/settings.json create mode 100644 .cursor/rules/00_repo_tooling.mdc create mode 100644 .cursor/rules/05_permissions_and_tools.mdc create mode 100644 .cursor/rules/10_architecture.mdc create mode 100644 .cursor/rules/20_codegen_boundaries.mdc create mode 100644 .cursor/rules/30_cli_and_commands.mdc create mode 100644 .cursor/rules/40_temporal_and_agents.mdc create mode 100644 .cursor/rules/50_tests_and_mocking.mdc create mode 100644 .cursor/rules/60_style_lint_typecheck.mdc create mode 100644 .cursor/rules/70_examples_and_docs.mdc create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .github/scripts/sync_agents.py create mode 100644 .github/workflows/agentex-tutorials-test.yml create mode 100644 .github/workflows/build-and-push-tutorial-agent.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/harness-integration.yml create mode 100644 .github/workflows/lint-pr.yaml create mode 100644 .github/workflows/publish-pypi.yml create mode 100644 .github/workflows/release-doctor.yml create mode 100644 .python-version create mode 100644 .release-please-manifest.json create mode 100644 .stats.yml create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 Brewfile create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 SECURITY.md create mode 100644 adk/CHANGELOG.md create mode 100644 adk/README.md create mode 100644 adk/docs/harness.md create mode 100644 adk/docs/migration-0.16.0.md create mode 100644 adk/hatch_build.py create mode 100644 adk/pyproject.toml create mode 100644 api.md create mode 100644 bin/check-release-environment create mode 100644 bin/publish-pypi create mode 100644 examples/.keep create mode 100644 examples/demos/procurement_agent/.dockerignore create mode 100644 examples/demos/procurement_agent/.gitignore create mode 100644 examples/demos/procurement_agent/Dockerfile create mode 100644 examples/demos/procurement_agent/README.md create mode 100644 examples/demos/procurement_agent/dev.ipynb create mode 100644 examples/demos/procurement_agent/environments.yaml create mode 100644 examples/demos/procurement_agent/evals/README.md create mode 100644 examples/demos/procurement_agent/evals/__init__.py create mode 100644 examples/demos/procurement_agent/evals/conftest.py create mode 100644 examples/demos/procurement_agent/evals/fixtures/__init__.py create mode 100644 examples/demos/procurement_agent/evals/fixtures/events.py create mode 100644 examples/demos/procurement_agent/evals/graders/__init__.py create mode 100644 examples/demos/procurement_agent/evals/graders/database.py create mode 100644 examples/demos/procurement_agent/evals/graders/tool_calls.py create mode 100644 examples/demos/procurement_agent/evals/pytest.ini create mode 100644 examples/demos/procurement_agent/evals/report.html create mode 100644 examples/demos/procurement_agent/evals/tasks/__init__.py create mode 100644 examples/demos/procurement_agent/evals/tasks/test_inspection_failed.py create mode 100644 examples/demos/procurement_agent/evals/tasks/test_inspection_passed.py create mode 100644 examples/demos/procurement_agent/evals/tasks/test_shipment_arrived.py create mode 100644 examples/demos/procurement_agent/evals/tasks/test_shipment_departed.py create mode 100644 examples/demos/procurement_agent/evals/tasks/test_submittal_approved.py create mode 100644 examples/demos/procurement_agent/manifest.yaml create mode 100644 examples/demos/procurement_agent/project/__init__.py create mode 100644 examples/demos/procurement_agent/project/acp.py create mode 100644 examples/demos/procurement_agent/project/activities/__init__.py create mode 100644 examples/demos/procurement_agent/project/activities/activities.py create mode 100644 examples/demos/procurement_agent/project/agents/__init__.py create mode 100644 examples/demos/procurement_agent/project/agents/extract_learnings_agent.py create mode 100644 examples/demos/procurement_agent/project/agents/procurement_agent.py create mode 100644 examples/demos/procurement_agent/project/agents/summarization_agent.py create mode 100644 examples/demos/procurement_agent/project/data/__init__.py create mode 100644 examples/demos/procurement_agent/project/data/database.py create mode 100644 examples/demos/procurement_agent/project/models/__init__.py create mode 100644 examples/demos/procurement_agent/project/models/events.py create mode 100644 examples/demos/procurement_agent/project/run_worker.py create mode 100644 examples/demos/procurement_agent/project/scripts/__init__.py create mode 100644 examples/demos/procurement_agent/project/scripts/happy_path.py create mode 100644 examples/demos/procurement_agent/project/scripts/human_in_the_loop.py create mode 100644 examples/demos/procurement_agent/project/scripts/out_of_order.py create mode 100644 examples/demos/procurement_agent/project/scripts/send_test_events.py create mode 100644 examples/demos/procurement_agent/project/scripts/send_test_events_lite.py create mode 100644 examples/demos/procurement_agent/project/utils/__init__.py create mode 100644 examples/demos/procurement_agent/project/utils/learning_extraction.py create mode 100644 examples/demos/procurement_agent/project/utils/summarization.py create mode 100644 examples/demos/procurement_agent/project/workflow.py create mode 100644 examples/demos/procurement_agent/pyproject.toml create mode 100755 examples/launch-tutorials.sh create mode 100644 examples/tutorials/00_sync/000_hello_acp/.dockerignore create mode 100644 examples/tutorials/00_sync/000_hello_acp/Dockerfile create mode 100644 examples/tutorials/00_sync/000_hello_acp/README.md create mode 100644 examples/tutorials/00_sync/000_hello_acp/dev.ipynb create mode 100644 examples/tutorials/00_sync/000_hello_acp/manifest.yaml create mode 100644 examples/tutorials/00_sync/000_hello_acp/project/__init__.py create mode 100644 examples/tutorials/00_sync/000_hello_acp/project/acp.py create mode 100644 examples/tutorials/00_sync/000_hello_acp/pyproject.toml create mode 100644 examples/tutorials/00_sync/000_hello_acp/tests/test_agent.py create mode 100644 examples/tutorials/00_sync/010_multiturn/.dockerignore create mode 100644 examples/tutorials/00_sync/010_multiturn/.ipynb_checkpoints/dev-checkpoint.ipynb create mode 100644 examples/tutorials/00_sync/010_multiturn/Dockerfile create mode 100644 examples/tutorials/00_sync/010_multiturn/README.md create mode 100644 examples/tutorials/00_sync/010_multiturn/dev.ipynb create mode 100644 examples/tutorials/00_sync/010_multiturn/manifest.yaml create mode 100644 examples/tutorials/00_sync/010_multiturn/project/__init__.py create mode 100644 examples/tutorials/00_sync/010_multiturn/project/acp.py create mode 100644 examples/tutorials/00_sync/010_multiturn/pyproject.toml create mode 100644 examples/tutorials/00_sync/010_multiturn/tests/test_agent.py create mode 100644 examples/tutorials/00_sync/020_streaming/.dockerignore create mode 100644 examples/tutorials/00_sync/020_streaming/Dockerfile create mode 100644 examples/tutorials/00_sync/020_streaming/README.md create mode 100644 examples/tutorials/00_sync/020_streaming/dev.ipynb create mode 100644 examples/tutorials/00_sync/020_streaming/manifest.yaml create mode 100644 examples/tutorials/00_sync/020_streaming/project/__init__.py create mode 100644 examples/tutorials/00_sync/020_streaming/project/acp.py create mode 100644 examples/tutorials/00_sync/020_streaming/pyproject.toml create mode 100644 examples/tutorials/00_sync/020_streaming/tests/test_agent.py create mode 100644 examples/tutorials/00_sync/030_langgraph/.dockerignore create mode 100644 examples/tutorials/00_sync/030_langgraph/Dockerfile create mode 100644 examples/tutorials/00_sync/030_langgraph/README.md create mode 100644 examples/tutorials/00_sync/030_langgraph/manifest.yaml create mode 100644 examples/tutorials/00_sync/030_langgraph/project/__init__.py create mode 100644 examples/tutorials/00_sync/030_langgraph/project/acp.py create mode 100644 examples/tutorials/00_sync/030_langgraph/project/graph.py create mode 100644 examples/tutorials/00_sync/030_langgraph/project/tools.py create mode 100644 examples/tutorials/00_sync/030_langgraph/pyproject.toml create mode 100644 examples/tutorials/00_sync/030_langgraph/tests/test_agent.py create mode 100644 examples/tutorials/00_sync/040_pydantic_ai/.dockerignore create mode 100644 examples/tutorials/00_sync/040_pydantic_ai/Dockerfile create mode 100644 examples/tutorials/00_sync/040_pydantic_ai/README.md create mode 100644 examples/tutorials/00_sync/040_pydantic_ai/manifest.yaml create mode 100644 examples/tutorials/00_sync/040_pydantic_ai/project/__init__.py create mode 100644 examples/tutorials/00_sync/040_pydantic_ai/project/acp.py create mode 100644 examples/tutorials/00_sync/040_pydantic_ai/project/agent.py create mode 100644 examples/tutorials/00_sync/040_pydantic_ai/project/tools.py create mode 100644 examples/tutorials/00_sync/040_pydantic_ai/pyproject.toml create mode 100644 examples/tutorials/00_sync/040_pydantic_ai/tests/test_agent.py create mode 100644 examples/tutorials/00_sync/050_openai_agents/.dockerignore create mode 100644 examples/tutorials/00_sync/050_openai_agents/Dockerfile create mode 100644 examples/tutorials/00_sync/050_openai_agents/README.md create mode 100644 examples/tutorials/00_sync/050_openai_agents/manifest.yaml create mode 100644 examples/tutorials/00_sync/050_openai_agents/project/__init__.py create mode 100644 examples/tutorials/00_sync/050_openai_agents/project/acp.py create mode 100644 examples/tutorials/00_sync/050_openai_agents/project/agent.py create mode 100644 examples/tutorials/00_sync/050_openai_agents/project/tools.py create mode 100644 examples/tutorials/00_sync/050_openai_agents/pyproject.toml create mode 100644 examples/tutorials/00_sync/050_openai_agents/tests/test_agent.py create mode 100644 examples/tutorials/00_sync/060_claude_code/.dockerignore create mode 100644 examples/tutorials/00_sync/060_claude_code/Dockerfile create mode 100644 examples/tutorials/00_sync/060_claude_code/README.md create mode 100644 examples/tutorials/00_sync/060_claude_code/manifest.yaml create mode 100644 examples/tutorials/00_sync/060_claude_code/project/__init__.py create mode 100644 examples/tutorials/00_sync/060_claude_code/project/acp.py create mode 100644 examples/tutorials/00_sync/060_claude_code/pyproject.toml create mode 100644 examples/tutorials/00_sync/060_claude_code/tests/test_agent.py create mode 100644 examples/tutorials/00_sync/060_claude_code/tests/test_agent_offline.py create mode 100644 examples/tutorials/00_sync/070_codex/.dockerignore create mode 100644 examples/tutorials/00_sync/070_codex/Dockerfile create mode 100644 examples/tutorials/00_sync/070_codex/README.md create mode 100644 examples/tutorials/00_sync/070_codex/conftest.py create mode 100644 examples/tutorials/00_sync/070_codex/manifest.yaml create mode 100644 examples/tutorials/00_sync/070_codex/project/__init__.py create mode 100644 examples/tutorials/00_sync/070_codex/project/acp.py create mode 100644 examples/tutorials/00_sync/070_codex/pyproject.toml create mode 100644 examples/tutorials/00_sync/070_codex/tests/test_agent.py create mode 100644 examples/tutorials/10_async/00_base/000_hello_acp/.dockerignore create mode 100644 examples/tutorials/10_async/00_base/000_hello_acp/Dockerfile create mode 100644 examples/tutorials/10_async/00_base/000_hello_acp/README.md create mode 100644 examples/tutorials/10_async/00_base/000_hello_acp/dev.ipynb create mode 100644 examples/tutorials/10_async/00_base/000_hello_acp/manifest.yaml create mode 100644 examples/tutorials/10_async/00_base/000_hello_acp/project/__init__.py create mode 100644 examples/tutorials/10_async/00_base/000_hello_acp/project/acp.py create mode 100644 examples/tutorials/10_async/00_base/000_hello_acp/pyproject.toml create mode 100644 examples/tutorials/10_async/00_base/000_hello_acp/tests/test_agent.py create mode 100644 examples/tutorials/10_async/00_base/010_multiturn/.dockerignore create mode 100644 examples/tutorials/10_async/00_base/010_multiturn/Dockerfile create mode 100644 examples/tutorials/10_async/00_base/010_multiturn/README.md create mode 100644 examples/tutorials/10_async/00_base/010_multiturn/dev.ipynb create mode 100644 examples/tutorials/10_async/00_base/010_multiturn/manifest.yaml create mode 100644 examples/tutorials/10_async/00_base/010_multiturn/project/__init__.py create mode 100644 examples/tutorials/10_async/00_base/010_multiturn/project/acp.py create mode 100644 examples/tutorials/10_async/00_base/010_multiturn/pyproject.toml create mode 100644 examples/tutorials/10_async/00_base/010_multiturn/tests/test_agent.py create mode 100644 examples/tutorials/10_async/00_base/020_streaming/.dockerignore create mode 100644 examples/tutorials/10_async/00_base/020_streaming/Dockerfile create mode 100644 examples/tutorials/10_async/00_base/020_streaming/README.md create mode 100644 examples/tutorials/10_async/00_base/020_streaming/dev.ipynb create mode 100644 examples/tutorials/10_async/00_base/020_streaming/manifest.yaml create mode 100644 examples/tutorials/10_async/00_base/020_streaming/project/__init__.py create mode 100644 examples/tutorials/10_async/00_base/020_streaming/project/acp.py create mode 100644 examples/tutorials/10_async/00_base/020_streaming/pyproject.toml create mode 100644 examples/tutorials/10_async/00_base/020_streaming/tests/test_agent.py create mode 100644 examples/tutorials/10_async/00_base/030_tracing/.dockerignore create mode 100644 examples/tutorials/10_async/00_base/030_tracing/Dockerfile create mode 100644 examples/tutorials/10_async/00_base/030_tracing/README.md create mode 100644 examples/tutorials/10_async/00_base/030_tracing/dev.ipynb create mode 100644 examples/tutorials/10_async/00_base/030_tracing/manifest.yaml create mode 100644 examples/tutorials/10_async/00_base/030_tracing/project/__init__.py create mode 100644 examples/tutorials/10_async/00_base/030_tracing/project/acp.py create mode 100644 examples/tutorials/10_async/00_base/030_tracing/pyproject.toml create mode 100644 examples/tutorials/10_async/00_base/030_tracing/tests/test_agent.py create mode 100644 examples/tutorials/10_async/00_base/040_other_sdks/.dockerignore create mode 100644 examples/tutorials/10_async/00_base/040_other_sdks/Dockerfile create mode 100644 examples/tutorials/10_async/00_base/040_other_sdks/README.md create mode 100644 examples/tutorials/10_async/00_base/040_other_sdks/dev.ipynb create mode 100644 examples/tutorials/10_async/00_base/040_other_sdks/manifest.yaml create mode 100644 examples/tutorials/10_async/00_base/040_other_sdks/project/__init__.py create mode 100644 examples/tutorials/10_async/00_base/040_other_sdks/project/acp.py create mode 100644 examples/tutorials/10_async/00_base/040_other_sdks/pyproject.toml create mode 100644 examples/tutorials/10_async/00_base/040_other_sdks/tests/test_agent.py create mode 100644 examples/tutorials/10_async/00_base/080_batch_events/.dockerignore create mode 100644 examples/tutorials/10_async/00_base/080_batch_events/Dockerfile create mode 100644 examples/tutorials/10_async/00_base/080_batch_events/README.md create mode 100644 examples/tutorials/10_async/00_base/080_batch_events/dev.ipynb create mode 100644 examples/tutorials/10_async/00_base/080_batch_events/manifest.yaml create mode 100644 examples/tutorials/10_async/00_base/080_batch_events/project/__init__.py create mode 100644 examples/tutorials/10_async/00_base/080_batch_events/project/acp.py create mode 100644 examples/tutorials/10_async/00_base/080_batch_events/pyproject.toml create mode 100644 examples/tutorials/10_async/00_base/080_batch_events/test_batch_events.py create mode 100644 examples/tutorials/10_async/00_base/080_batch_events/tests/test_agent.py create mode 100644 examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/Dockerfile create mode 100644 examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/README.md create mode 100644 examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/creator.yaml create mode 100644 examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/critic.yaml create mode 100644 examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/formatter.yaml create mode 100644 examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/orchestrator.yaml create mode 100644 examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/__init__.py create mode 100644 examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/creator.py create mode 100644 examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/critic.py create mode 100644 examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/formatter.py create mode 100644 examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/models.py create mode 100644 examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/orchestrator.py create mode 100644 examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/state_machines/__init__.py create mode 100644 examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/state_machines/content_workflow.py create mode 100644 examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/pyproject.toml create mode 100755 examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/start-agents.sh create mode 100644 examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/tests/test_agent.py create mode 100644 examples/tutorials/10_async/00_base/100_langgraph/.dockerignore create mode 100644 examples/tutorials/10_async/00_base/100_langgraph/Dockerfile create mode 100644 examples/tutorials/10_async/00_base/100_langgraph/README.md create mode 100644 examples/tutorials/10_async/00_base/100_langgraph/manifest.yaml create mode 100644 examples/tutorials/10_async/00_base/100_langgraph/project/__init__.py create mode 100644 examples/tutorials/10_async/00_base/100_langgraph/project/acp.py create mode 100644 examples/tutorials/10_async/00_base/100_langgraph/project/graph.py create mode 100644 examples/tutorials/10_async/00_base/100_langgraph/project/tools.py create mode 100644 examples/tutorials/10_async/00_base/100_langgraph/pyproject.toml create mode 100644 examples/tutorials/10_async/00_base/100_langgraph/tests/test_agent.py create mode 100644 examples/tutorials/10_async/00_base/110_pydantic_ai/.dockerignore create mode 100644 examples/tutorials/10_async/00_base/110_pydantic_ai/Dockerfile create mode 100644 examples/tutorials/10_async/00_base/110_pydantic_ai/README.md create mode 100644 examples/tutorials/10_async/00_base/110_pydantic_ai/manifest.yaml create mode 100644 examples/tutorials/10_async/00_base/110_pydantic_ai/project/__init__.py create mode 100644 examples/tutorials/10_async/00_base/110_pydantic_ai/project/acp.py create mode 100644 examples/tutorials/10_async/00_base/110_pydantic_ai/project/agent.py create mode 100644 examples/tutorials/10_async/00_base/110_pydantic_ai/project/tools.py create mode 100644 examples/tutorials/10_async/00_base/110_pydantic_ai/pyproject.toml create mode 100644 examples/tutorials/10_async/00_base/110_pydantic_ai/tests/test_agent.py create mode 100644 examples/tutorials/10_async/00_base/120_openai_agents/.dockerignore create mode 100644 examples/tutorials/10_async/00_base/120_openai_agents/Dockerfile create mode 100644 examples/tutorials/10_async/00_base/120_openai_agents/README.md create mode 100644 examples/tutorials/10_async/00_base/120_openai_agents/manifest.yaml create mode 100644 examples/tutorials/10_async/00_base/120_openai_agents/project/__init__.py create mode 100644 examples/tutorials/10_async/00_base/120_openai_agents/project/acp.py create mode 100644 examples/tutorials/10_async/00_base/120_openai_agents/project/agent.py create mode 100644 examples/tutorials/10_async/00_base/120_openai_agents/project/tools.py create mode 100644 examples/tutorials/10_async/00_base/120_openai_agents/pyproject.toml create mode 100644 examples/tutorials/10_async/00_base/120_openai_agents/tests/test_agent.py create mode 100644 examples/tutorials/10_async/00_base/130_claude_code/.dockerignore create mode 100644 examples/tutorials/10_async/00_base/130_claude_code/Dockerfile create mode 100644 examples/tutorials/10_async/00_base/130_claude_code/README.md create mode 100644 examples/tutorials/10_async/00_base/130_claude_code/manifest.yaml create mode 100644 examples/tutorials/10_async/00_base/130_claude_code/project/__init__.py create mode 100644 examples/tutorials/10_async/00_base/130_claude_code/project/acp.py create mode 100644 examples/tutorials/10_async/00_base/130_claude_code/pyproject.toml create mode 100644 examples/tutorials/10_async/00_base/130_claude_code/tests/test_agent.py create mode 100644 examples/tutorials/10_async/00_base/130_claude_code/tests/test_agent_offline.py create mode 100644 examples/tutorials/10_async/00_base/140_codex/.dockerignore create mode 100644 examples/tutorials/10_async/00_base/140_codex/Dockerfile create mode 100644 examples/tutorials/10_async/00_base/140_codex/README.md create mode 100644 examples/tutorials/10_async/00_base/140_codex/conftest.py create mode 100644 examples/tutorials/10_async/00_base/140_codex/manifest.yaml create mode 100644 examples/tutorials/10_async/00_base/140_codex/project/__init__.py create mode 100644 examples/tutorials/10_async/00_base/140_codex/project/acp.py create mode 100644 examples/tutorials/10_async/00_base/140_codex/pyproject.toml create mode 100644 examples/tutorials/10_async/00_base/140_codex/tests/test_agent.py create mode 100644 examples/tutorials/10_async/10_temporal/000_hello_acp/.dockerignore create mode 100644 examples/tutorials/10_async/10_temporal/000_hello_acp/Dockerfile create mode 100644 examples/tutorials/10_async/10_temporal/000_hello_acp/README.md create mode 100644 examples/tutorials/10_async/10_temporal/000_hello_acp/dev.ipynb create mode 100644 examples/tutorials/10_async/10_temporal/000_hello_acp/manifest.yaml create mode 100644 examples/tutorials/10_async/10_temporal/000_hello_acp/project/__init__.py create mode 100644 examples/tutorials/10_async/10_temporal/000_hello_acp/project/acp.py create mode 100644 examples/tutorials/10_async/10_temporal/000_hello_acp/project/run_worker.py create mode 100644 examples/tutorials/10_async/10_temporal/000_hello_acp/project/workflow.py create mode 100644 examples/tutorials/10_async/10_temporal/000_hello_acp/pyproject.toml create mode 100644 examples/tutorials/10_async/10_temporal/000_hello_acp/tests/test_agent.py create mode 100644 examples/tutorials/10_async/10_temporal/010_agent_chat/.dockerignore create mode 100644 examples/tutorials/10_async/10_temporal/010_agent_chat/Dockerfile create mode 100644 examples/tutorials/10_async/10_temporal/010_agent_chat/README.md create mode 100644 examples/tutorials/10_async/10_temporal/010_agent_chat/dev.ipynb create mode 100644 examples/tutorials/10_async/10_temporal/010_agent_chat/manifest.yaml create mode 100644 examples/tutorials/10_async/10_temporal/010_agent_chat/project/__init__.py create mode 100644 examples/tutorials/10_async/10_temporal/010_agent_chat/project/acp.py create mode 100644 examples/tutorials/10_async/10_temporal/010_agent_chat/project/run_worker.py create mode 100644 examples/tutorials/10_async/10_temporal/010_agent_chat/project/workflow.py create mode 100644 examples/tutorials/10_async/10_temporal/010_agent_chat/pyproject.toml create mode 100644 examples/tutorials/10_async/10_temporal/010_agent_chat/tests/test_agent.py create mode 100644 examples/tutorials/10_async/10_temporal/020_state_machine/.dockerignore create mode 100644 examples/tutorials/10_async/10_temporal/020_state_machine/Dockerfile create mode 100644 examples/tutorials/10_async/10_temporal/020_state_machine/README.md create mode 100644 examples/tutorials/10_async/10_temporal/020_state_machine/dev.ipynb create mode 100644 examples/tutorials/10_async/10_temporal/020_state_machine/manifest.yaml create mode 100644 examples/tutorials/10_async/10_temporal/020_state_machine/project/__init__.py create mode 100644 examples/tutorials/10_async/10_temporal/020_state_machine/project/acp.py create mode 100644 examples/tutorials/10_async/10_temporal/020_state_machine/project/run_worker.py create mode 100644 examples/tutorials/10_async/10_temporal/020_state_machine/project/state_machines/deep_research.py create mode 100644 examples/tutorials/10_async/10_temporal/020_state_machine/project/workflow.py create mode 100644 examples/tutorials/10_async/10_temporal/020_state_machine/project/workflows/deep_research/clarify_user_query.py create mode 100644 examples/tutorials/10_async/10_temporal/020_state_machine/project/workflows/deep_research/performing_deep_research.py create mode 100644 examples/tutorials/10_async/10_temporal/020_state_machine/project/workflows/deep_research/waiting_for_user_input.py create mode 100644 examples/tutorials/10_async/10_temporal/020_state_machine/pyproject.toml create mode 100644 examples/tutorials/10_async/10_temporal/020_state_machine/tests/test_agent.py create mode 100644 examples/tutorials/10_async/10_temporal/030_custom_activities/.dockerignore create mode 100644 examples/tutorials/10_async/10_temporal/030_custom_activities/Dockerfile create mode 100644 examples/tutorials/10_async/10_temporal/030_custom_activities/README.md create mode 100644 examples/tutorials/10_async/10_temporal/030_custom_activities/dev.ipynb create mode 100644 examples/tutorials/10_async/10_temporal/030_custom_activities/manifest.yaml create mode 100644 examples/tutorials/10_async/10_temporal/030_custom_activities/project/__init__.py create mode 100644 examples/tutorials/10_async/10_temporal/030_custom_activities/project/acp.py create mode 100644 examples/tutorials/10_async/10_temporal/030_custom_activities/project/custom_activites.py create mode 100644 examples/tutorials/10_async/10_temporal/030_custom_activities/project/run_worker.py create mode 100644 examples/tutorials/10_async/10_temporal/030_custom_activities/project/shared_models.py create mode 100644 examples/tutorials/10_async/10_temporal/030_custom_activities/project/workflow.py create mode 100644 examples/tutorials/10_async/10_temporal/030_custom_activities/project/workflow_utils.py create mode 100644 examples/tutorials/10_async/10_temporal/030_custom_activities/pyproject.toml create mode 100644 examples/tutorials/10_async/10_temporal/030_custom_activities/tests/test_agent.py create mode 100644 examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/.dockerignore create mode 100644 examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/Dockerfile create mode 100644 examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/README.md create mode 100644 examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/dev.ipynb create mode 100644 examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/manifest.yaml create mode 100644 examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/__init__.py create mode 100644 examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/acp.py create mode 100644 examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/run_worker.py create mode 100644 examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/workflow.py create mode 100644 examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/pyproject.toml create mode 100644 examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/tests/test_agent.py create mode 100644 examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/.dockerignore create mode 100644 examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/Dockerfile create mode 100644 examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/README.md create mode 100644 examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/dev.ipynb create mode 100644 examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/environments.yaml create mode 100644 examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/manifest.yaml create mode 100644 examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/project/__init__.py create mode 100644 examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/project/acp.py create mode 100644 examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/project/run_worker.py create mode 100644 examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/project/workflow.py create mode 100644 examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/pyproject.toml create mode 100644 examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/tests/test_agent.py create mode 100644 examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/.dockerignore create mode 100644 examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/Dockerfile create mode 100644 examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/README.md create mode 100644 examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/dev.ipynb create mode 100644 examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/environments.yaml create mode 100644 examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/manifest.yaml create mode 100644 examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/__init__.py create mode 100644 examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/acp.py create mode 100644 examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/activities.py create mode 100644 examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/run_worker.py create mode 100644 examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/tools.py create mode 100644 examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/workflow.py create mode 100644 examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/pyproject.toml create mode 100644 examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/tests/test_agent.py create mode 100644 examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/.dockerignore create mode 100644 examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/Dockerfile create mode 100644 examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/README.md create mode 100644 examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/dev.ipynb create mode 100644 examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/environments.yaml create mode 100644 examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/manifest.yaml create mode 100644 examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/__init__.py create mode 100644 examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/acp.py create mode 100644 examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/activities.py create mode 100644 examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/child_workflow.py create mode 100644 examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/run_worker.py create mode 100644 examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/tools.py create mode 100644 examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/workflow.py create mode 100644 examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/pyproject.toml create mode 100644 examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/tests/test_agent.py create mode 100644 examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/.dockerignore create mode 100644 examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/.gitignore create mode 100644 examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/Dockerfile create mode 100644 examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/README.md create mode 100644 examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/manifest.yaml create mode 100644 examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/project/acp.py create mode 100644 examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/project/run_worker.py create mode 100644 examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/project/workflow.py create mode 100644 examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/pyproject.toml create mode 100644 examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/tests/test_agent.py create mode 100644 examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/workspace/.gitignore create mode 100644 examples/tutorials/10_async/10_temporal/100_gemini_litellm/.dockerignore create mode 100644 examples/tutorials/10_async/10_temporal/100_gemini_litellm/Dockerfile create mode 100644 examples/tutorials/10_async/10_temporal/100_gemini_litellm/README.md create mode 100644 examples/tutorials/10_async/10_temporal/100_gemini_litellm/project/__init__.py create mode 100644 examples/tutorials/10_async/10_temporal/100_gemini_litellm/project/acp.py create mode 100644 examples/tutorials/10_async/10_temporal/100_gemini_litellm/project/run_worker.py create mode 100644 examples/tutorials/10_async/10_temporal/100_gemini_litellm/project/workflow.py create mode 100644 examples/tutorials/10_async/10_temporal/100_gemini_litellm/pyproject.toml create mode 100644 examples/tutorials/10_async/10_temporal/110_pydantic_ai/.dockerignore create mode 100644 examples/tutorials/10_async/10_temporal/110_pydantic_ai/Dockerfile create mode 100644 examples/tutorials/10_async/10_temporal/110_pydantic_ai/README.md create mode 100644 examples/tutorials/10_async/10_temporal/110_pydantic_ai/manifest.yaml create mode 100644 examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/__init__.py create mode 100644 examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/acp.py create mode 100644 examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/agent.py create mode 100644 examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/run_worker.py create mode 100644 examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/tools.py create mode 100644 examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/workflow.py create mode 100644 examples/tutorials/10_async/10_temporal/110_pydantic_ai/pyproject.toml create mode 100644 examples/tutorials/10_async/10_temporal/110_pydantic_ai/tests/test_agent.py create mode 100644 examples/tutorials/10_async/10_temporal/120_openai_agents/.dockerignore create mode 100644 examples/tutorials/10_async/10_temporal/120_openai_agents/Dockerfile create mode 100644 examples/tutorials/10_async/10_temporal/120_openai_agents/README.md create mode 100644 examples/tutorials/10_async/10_temporal/120_openai_agents/environments.yaml create mode 100644 examples/tutorials/10_async/10_temporal/120_openai_agents/manifest.yaml create mode 100644 examples/tutorials/10_async/10_temporal/120_openai_agents/project/__init__.py create mode 100644 examples/tutorials/10_async/10_temporal/120_openai_agents/project/acp.py create mode 100644 examples/tutorials/10_async/10_temporal/120_openai_agents/project/activities.py create mode 100644 examples/tutorials/10_async/10_temporal/120_openai_agents/project/agent.py create mode 100644 examples/tutorials/10_async/10_temporal/120_openai_agents/project/run_worker.py create mode 100644 examples/tutorials/10_async/10_temporal/120_openai_agents/project/tools.py create mode 100644 examples/tutorials/10_async/10_temporal/120_openai_agents/project/workflow.py create mode 100644 examples/tutorials/10_async/10_temporal/120_openai_agents/pyproject.toml create mode 100644 examples/tutorials/10_async/10_temporal/120_openai_agents/tests/test_agent.py create mode 100644 examples/tutorials/10_async/10_temporal/130_langgraph/.dockerignore create mode 100644 examples/tutorials/10_async/10_temporal/130_langgraph/Dockerfile create mode 100644 examples/tutorials/10_async/10_temporal/130_langgraph/README.md create mode 100644 examples/tutorials/10_async/10_temporal/130_langgraph/manifest.yaml create mode 100644 examples/tutorials/10_async/10_temporal/130_langgraph/project/__init__.py create mode 100644 examples/tutorials/10_async/10_temporal/130_langgraph/project/acp.py create mode 100644 examples/tutorials/10_async/10_temporal/130_langgraph/project/graph.py create mode 100644 examples/tutorials/10_async/10_temporal/130_langgraph/project/run_worker.py create mode 100644 examples/tutorials/10_async/10_temporal/130_langgraph/project/tools.py create mode 100644 examples/tutorials/10_async/10_temporal/130_langgraph/project/workflow.py create mode 100644 examples/tutorials/10_async/10_temporal/130_langgraph/pyproject.toml create mode 100644 examples/tutorials/10_async/10_temporal/130_langgraph/tests/test_agent.py create mode 100644 examples/tutorials/10_async/10_temporal/140_claude_code/.dockerignore create mode 100644 examples/tutorials/10_async/10_temporal/140_claude_code/Dockerfile create mode 100644 examples/tutorials/10_async/10_temporal/140_claude_code/README.md create mode 100644 examples/tutorials/10_async/10_temporal/140_claude_code/manifest.yaml create mode 100644 examples/tutorials/10_async/10_temporal/140_claude_code/project/__init__.py create mode 100644 examples/tutorials/10_async/10_temporal/140_claude_code/project/acp.py create mode 100644 examples/tutorials/10_async/10_temporal/140_claude_code/project/activities.py create mode 100644 examples/tutorials/10_async/10_temporal/140_claude_code/project/run_worker.py create mode 100644 examples/tutorials/10_async/10_temporal/140_claude_code/project/workflow.py create mode 100644 examples/tutorials/10_async/10_temporal/140_claude_code/pyproject.toml create mode 100644 examples/tutorials/10_async/10_temporal/140_claude_code/tests/test_agent.py create mode 100644 examples/tutorials/10_async/10_temporal/140_claude_code/tests/test_agent_offline.py create mode 100644 examples/tutorials/10_async/10_temporal/150_codex/.dockerignore create mode 100644 examples/tutorials/10_async/10_temporal/150_codex/Dockerfile create mode 100644 examples/tutorials/10_async/10_temporal/150_codex/README.md create mode 100644 examples/tutorials/10_async/10_temporal/150_codex/conftest.py create mode 100644 examples/tutorials/10_async/10_temporal/150_codex/manifest.yaml create mode 100644 examples/tutorials/10_async/10_temporal/150_codex/project/__init__.py create mode 100644 examples/tutorials/10_async/10_temporal/150_codex/project/acp.py create mode 100644 examples/tutorials/10_async/10_temporal/150_codex/project/activities.py create mode 100644 examples/tutorials/10_async/10_temporal/150_codex/project/run_worker.py create mode 100644 examples/tutorials/10_async/10_temporal/150_codex/project/workflow.py create mode 100644 examples/tutorials/10_async/10_temporal/150_codex/pyproject.toml create mode 100644 examples/tutorials/10_async/10_temporal/150_codex/tests/test_agent.py create mode 100644 examples/tutorials/README.md create mode 100644 examples/tutorials/TEST_RUNNER_README.md create mode 100644 examples/tutorials/pytest.ini create mode 100755 examples/tutorials/run_agent_test.sh create mode 100644 examples/tutorials/test_utils/async_utils.py create mode 100644 examples/tutorials/test_utils/sync.py create mode 100644 pyproject.toml create mode 100644 release-please-config.json create mode 100644 requirements-dev.lock create mode 100755 scripts/bootstrap create mode 100755 scripts/check-slim-deps create mode 100755 scripts/check-wheel-install create mode 100755 scripts/format create mode 100755 scripts/lint create mode 100755 scripts/test create mode 100644 scripts/utils/ruffen-docs.py create mode 100755 scripts/utils/upload-artifact.sh create mode 100644 src/agentex/__init__.py create mode 100644 src/agentex/_base_client.py create mode 100644 src/agentex/_client.py create mode 100644 src/agentex/_compat.py create mode 100644 src/agentex/_constants.py create mode 100644 src/agentex/_exceptions.py create mode 100644 src/agentex/_files.py create mode 100644 src/agentex/_models.py create mode 100644 src/agentex/_qs.py create mode 100644 src/agentex/_resource.py create mode 100644 src/agentex/_response.py create mode 100644 src/agentex/_streaming.py create mode 100644 src/agentex/_types.py create mode 100644 src/agentex/_utils/__init__.py create mode 100644 src/agentex/_utils/_compat.py create mode 100644 src/agentex/_utils/_datetime_parse.py create mode 100644 src/agentex/_utils/_json.py create mode 100644 src/agentex/_utils/_logs.py create mode 100644 src/agentex/_utils/_path.py create mode 100644 src/agentex/_utils/_proxy.py create mode 100644 src/agentex/_utils/_reflection.py create mode 100644 src/agentex/_utils/_resources_proxy.py create mode 100644 src/agentex/_utils/_streams.py create mode 100644 src/agentex/_utils/_sync.py create mode 100644 src/agentex/_utils/_transform.py create mode 100644 src/agentex/_utils/_typing.py create mode 100644 src/agentex/_utils/_utils.py create mode 100644 src/agentex/_version.py create mode 100644 src/agentex/config/__init__.py create mode 100644 src/agentex/config/_base.py create mode 100644 src/agentex/config/agent_config.py create mode 100644 src/agentex/config/agent_configs.py create mode 100644 src/agentex/config/agent_manifest.py create mode 100644 src/agentex/config/build_config.py create mode 100644 src/agentex/config/credentials.py create mode 100644 src/agentex/config/deployment_config.py create mode 100644 src/agentex/config/environment_config.py create mode 100644 src/agentex/config/local_development_config.py create mode 100644 src/agentex/lib/.keep create mode 100644 src/agentex/lib/__init__.py create mode 100644 src/agentex/lib/_version_guard.py create mode 100644 src/agentex/lib/adk/__init__.py create mode 100644 src/agentex/lib/adk/_modules/__init__.py create mode 100644 src/agentex/lib/adk/_modules/_claude_code_sync.py create mode 100644 src/agentex/lib/adk/_modules/_claude_code_turn.py create mode 100644 src/agentex/lib/adk/_modules/_codex_sync.py create mode 100644 src/agentex/lib/adk/_modules/_codex_turn.py create mode 100644 src/agentex/lib/adk/_modules/_http_checkpointer.py create mode 100644 src/agentex/lib/adk/_modules/_langgraph_sync.py create mode 100644 src/agentex/lib/adk/_modules/_langgraph_turn.py create mode 100644 src/agentex/lib/adk/_modules/_openai_sync.py create mode 100644 src/agentex/lib/adk/_modules/_openai_turn.py create mode 100644 src/agentex/lib/adk/_modules/_pydantic_ai_sync.py create mode 100644 src/agentex/lib/adk/_modules/_pydantic_ai_turn.py create mode 100644 src/agentex/lib/adk/_modules/acp.py create mode 100644 src/agentex/lib/adk/_modules/agent_task_tracker.py create mode 100644 src/agentex/lib/adk/_modules/agents.py create mode 100644 src/agentex/lib/adk/_modules/checkpointer.py create mode 100644 src/agentex/lib/adk/_modules/events.py create mode 100644 src/agentex/lib/adk/_modules/messages.py create mode 100644 src/agentex/lib/adk/_modules/state.py create mode 100644 src/agentex/lib/adk/_modules/streaming.py create mode 100644 src/agentex/lib/adk/_modules/tasks.py create mode 100644 src/agentex/lib/adk/_modules/tracing.py create mode 100644 src/agentex/lib/adk/providers/__init__.py create mode 100644 src/agentex/lib/adk/providers/_modules/__init__.py create mode 100644 src/agentex/lib/adk/providers/_modules/litellm.py create mode 100644 src/agentex/lib/adk/providers/_modules/openai.py create mode 100644 src/agentex/lib/adk/providers/_modules/openai_turn.py create mode 100644 src/agentex/lib/adk/providers/_modules/sgp.py create mode 100644 src/agentex/lib/adk/providers/_modules/sync_provider.py create mode 100644 src/agentex/lib/adk/utils/__init__.py create mode 100644 src/agentex/lib/adk/utils/_modules/__init__.py create mode 100644 src/agentex/lib/adk/utils/_modules/client.py create mode 100644 src/agentex/lib/adk/utils/_modules/templating.py create mode 100644 src/agentex/lib/cli/__init__.py create mode 100644 src/agentex/lib/cli/commands/__init__.py create mode 100644 src/agentex/lib/cli/commands/agents.py create mode 100644 src/agentex/lib/cli/commands/init.py create mode 100644 src/agentex/lib/cli/commands/main.py create mode 100644 src/agentex/lib/cli/commands/secrets.py create mode 100644 src/agentex/lib/cli/commands/tasks.py create mode 100644 src/agentex/lib/cli/commands/uv.py create mode 100644 src/agentex/lib/cli/debug/__init__.py create mode 100644 src/agentex/lib/cli/debug/debug_config.py create mode 100644 src/agentex/lib/cli/debug/debug_handlers.py create mode 100644 src/agentex/lib/cli/handlers/__init__.py create mode 100644 src/agentex/lib/cli/handlers/agent_handlers.py create mode 100644 src/agentex/lib/cli/handlers/cleanup_handlers.py create mode 100644 src/agentex/lib/cli/handlers/deploy_handlers.py create mode 100644 src/agentex/lib/cli/handlers/run_handlers.py create mode 100644 src/agentex/lib/cli/handlers/secret_handlers.py create mode 100644 src/agentex/lib/cli/templates/default-claude-code/.dockerignore.j2 create mode 100644 src/agentex/lib/cli/templates/default-claude-code/.env.example.j2 create mode 100644 src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 create mode 100644 src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 create mode 100644 src/agentex/lib/cli/templates/default-claude-code/README.md.j2 create mode 100644 src/agentex/lib/cli/templates/default-claude-code/dev.ipynb.j2 create mode 100644 src/agentex/lib/cli/templates/default-claude-code/environments.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/default-claude-code/manifest.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/default-claude-code/project/acp.py.j2 create mode 100644 src/agentex/lib/cli/templates/default-claude-code/pyproject.toml.j2 create mode 100644 src/agentex/lib/cli/templates/default-claude-code/requirements.txt.j2 create mode 100644 src/agentex/lib/cli/templates/default-codex/.dockerignore.j2 create mode 100644 src/agentex/lib/cli/templates/default-codex/.env.example.j2 create mode 100644 src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 create mode 100644 src/agentex/lib/cli/templates/default-codex/Dockerfile.j2 create mode 100644 src/agentex/lib/cli/templates/default-codex/README.md.j2 create mode 100644 src/agentex/lib/cli/templates/default-codex/dev.ipynb.j2 create mode 100644 src/agentex/lib/cli/templates/default-codex/environments.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/default-codex/manifest.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/default-codex/project/acp.py.j2 create mode 100644 src/agentex/lib/cli/templates/default-codex/pyproject.toml.j2 create mode 100644 src/agentex/lib/cli/templates/default-codex/requirements.txt.j2 create mode 100644 src/agentex/lib/cli/templates/default-langgraph/.dockerignore.j2 create mode 100644 src/agentex/lib/cli/templates/default-langgraph/.env.example.j2 create mode 100644 src/agentex/lib/cli/templates/default-langgraph/Dockerfile-uv.j2 create mode 100644 src/agentex/lib/cli/templates/default-langgraph/Dockerfile.j2 create mode 100644 src/agentex/lib/cli/templates/default-langgraph/README.md.j2 create mode 100644 src/agentex/lib/cli/templates/default-langgraph/dev.ipynb.j2 create mode 100644 src/agentex/lib/cli/templates/default-langgraph/environments.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/default-langgraph/manifest.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/default-langgraph/project/acp.py.j2 create mode 100644 src/agentex/lib/cli/templates/default-langgraph/project/graph.py.j2 create mode 100644 src/agentex/lib/cli/templates/default-langgraph/project/tools.py.j2 create mode 100644 src/agentex/lib/cli/templates/default-langgraph/pyproject.toml.j2 create mode 100644 src/agentex/lib/cli/templates/default-langgraph/requirements.txt.j2 create mode 100644 src/agentex/lib/cli/templates/default-langgraph/test_agent.py.j2 create mode 100644 src/agentex/lib/cli/templates/default-openai-agents/.dockerignore.j2 create mode 100644 src/agentex/lib/cli/templates/default-openai-agents/.env.example.j2 create mode 100644 src/agentex/lib/cli/templates/default-openai-agents/Dockerfile-uv.j2 create mode 100644 src/agentex/lib/cli/templates/default-openai-agents/Dockerfile.j2 create mode 100644 src/agentex/lib/cli/templates/default-openai-agents/README.md.j2 create mode 100644 src/agentex/lib/cli/templates/default-openai-agents/dev.ipynb.j2 create mode 100644 src/agentex/lib/cli/templates/default-openai-agents/environments.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/default-openai-agents/manifest.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/default-openai-agents/project/acp.py.j2 create mode 100644 src/agentex/lib/cli/templates/default-openai-agents/pyproject.toml.j2 create mode 100644 src/agentex/lib/cli/templates/default-openai-agents/requirements.txt.j2 create mode 100644 src/agentex/lib/cli/templates/default-pydantic-ai/.dockerignore.j2 create mode 100644 src/agentex/lib/cli/templates/default-pydantic-ai/.env.example.j2 create mode 100644 src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile-uv.j2 create mode 100644 src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile.j2 create mode 100644 src/agentex/lib/cli/templates/default-pydantic-ai/README.md.j2 create mode 100644 src/agentex/lib/cli/templates/default-pydantic-ai/dev.ipynb.j2 create mode 100644 src/agentex/lib/cli/templates/default-pydantic-ai/environments.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/default-pydantic-ai/manifest.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/default-pydantic-ai/project/acp.py.j2 create mode 100644 src/agentex/lib/cli/templates/default-pydantic-ai/project/agent.py.j2 create mode 100644 src/agentex/lib/cli/templates/default-pydantic-ai/project/tools.py.j2 create mode 100644 src/agentex/lib/cli/templates/default-pydantic-ai/pyproject.toml.j2 create mode 100644 src/agentex/lib/cli/templates/default-pydantic-ai/requirements.txt.j2 create mode 100644 src/agentex/lib/cli/templates/default-pydantic-ai/test_agent.py.j2 create mode 100644 src/agentex/lib/cli/templates/default/.dockerignore.j2 create mode 100644 src/agentex/lib/cli/templates/default/.env.example.j2 create mode 100644 src/agentex/lib/cli/templates/default/Dockerfile-uv.j2 create mode 100644 src/agentex/lib/cli/templates/default/Dockerfile.j2 create mode 100644 src/agentex/lib/cli/templates/default/README.md.j2 create mode 100644 src/agentex/lib/cli/templates/default/dev.ipynb.j2 create mode 100644 src/agentex/lib/cli/templates/default/environments.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/default/manifest.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/default/project/acp.py.j2 create mode 100644 src/agentex/lib/cli/templates/default/pyproject.toml.j2 create mode 100644 src/agentex/lib/cli/templates/default/requirements.txt.j2 create mode 100644 src/agentex/lib/cli/templates/default/test_agent.py.j2 create mode 100644 src/agentex/lib/cli/templates/sync-claude-code/.dockerignore.j2 create mode 100644 src/agentex/lib/cli/templates/sync-claude-code/.env.example.j2 create mode 100644 src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 create mode 100644 src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.j2 create mode 100644 src/agentex/lib/cli/templates/sync-claude-code/README.md.j2 create mode 100644 src/agentex/lib/cli/templates/sync-claude-code/dev.ipynb.j2 create mode 100644 src/agentex/lib/cli/templates/sync-claude-code/environments.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/sync-claude-code/manifest.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/sync-claude-code/project/acp.py.j2 create mode 100644 src/agentex/lib/cli/templates/sync-claude-code/pyproject.toml.j2 create mode 100644 src/agentex/lib/cli/templates/sync-claude-code/requirements.txt.j2 create mode 100644 src/agentex/lib/cli/templates/sync-codex/.dockerignore.j2 create mode 100644 src/agentex/lib/cli/templates/sync-codex/.env.example.j2 create mode 100644 src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 create mode 100644 src/agentex/lib/cli/templates/sync-codex/Dockerfile.j2 create mode 100644 src/agentex/lib/cli/templates/sync-codex/README.md.j2 create mode 100644 src/agentex/lib/cli/templates/sync-codex/dev.ipynb.j2 create mode 100644 src/agentex/lib/cli/templates/sync-codex/environments.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/sync-codex/manifest.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/sync-codex/project/acp.py.j2 create mode 100644 src/agentex/lib/cli/templates/sync-codex/pyproject.toml.j2 create mode 100644 src/agentex/lib/cli/templates/sync-codex/requirements.txt.j2 create mode 100644 src/agentex/lib/cli/templates/sync-langgraph/.dockerignore.j2 create mode 100644 src/agentex/lib/cli/templates/sync-langgraph/.env.example.j2 create mode 100644 src/agentex/lib/cli/templates/sync-langgraph/Dockerfile-uv.j2 create mode 100644 src/agentex/lib/cli/templates/sync-langgraph/Dockerfile.j2 create mode 100644 src/agentex/lib/cli/templates/sync-langgraph/README.md.j2 create mode 100644 src/agentex/lib/cli/templates/sync-langgraph/dev.ipynb.j2 create mode 100644 src/agentex/lib/cli/templates/sync-langgraph/environments.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/sync-langgraph/manifest.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/sync-langgraph/project/acp.py.j2 create mode 100644 src/agentex/lib/cli/templates/sync-langgraph/project/graph.py.j2 create mode 100644 src/agentex/lib/cli/templates/sync-langgraph/project/tools.py.j2 create mode 100644 src/agentex/lib/cli/templates/sync-langgraph/pyproject.toml.j2 create mode 100644 src/agentex/lib/cli/templates/sync-langgraph/requirements.txt.j2 create mode 100644 src/agentex/lib/cli/templates/sync-langgraph/test_agent.py.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/.dockerignore.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/.env.example.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile-uv.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/README.md.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/dev.ipynb.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/environments.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/manifest.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/acp.py.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/agent.py.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/tools.py.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/pyproject.toml.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/requirements.txt.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/test_agent.py.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents/.dockerignore.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents/.env.example.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile-uv.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents/README.md.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents/dev.ipynb.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents/environments.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents/manifest.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents/project/acp.py.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents/pyproject.toml.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents/requirements.txt.j2 create mode 100644 src/agentex/lib/cli/templates/sync-openai-agents/test_agent.py.j2 create mode 100644 src/agentex/lib/cli/templates/sync-pydantic-ai/.dockerignore.j2 create mode 100644 src/agentex/lib/cli/templates/sync-pydantic-ai/.env.example.j2 create mode 100644 src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile-uv.j2 create mode 100644 src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile.j2 create mode 100644 src/agentex/lib/cli/templates/sync-pydantic-ai/README.md.j2 create mode 100644 src/agentex/lib/cli/templates/sync-pydantic-ai/dev.ipynb.j2 create mode 100644 src/agentex/lib/cli/templates/sync-pydantic-ai/environments.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/sync-pydantic-ai/manifest.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/sync-pydantic-ai/project/acp.py.j2 create mode 100644 src/agentex/lib/cli/templates/sync-pydantic-ai/project/agent.py.j2 create mode 100644 src/agentex/lib/cli/templates/sync-pydantic-ai/project/tools.py.j2 create mode 100644 src/agentex/lib/cli/templates/sync-pydantic-ai/pyproject.toml.j2 create mode 100644 src/agentex/lib/cli/templates/sync-pydantic-ai/requirements.txt.j2 create mode 100644 src/agentex/lib/cli/templates/sync-pydantic-ai/test_agent.py.j2 create mode 100644 src/agentex/lib/cli/templates/sync/.dockerignore.j2 create mode 100644 src/agentex/lib/cli/templates/sync/.env.example.j2 create mode 100644 src/agentex/lib/cli/templates/sync/Dockerfile-uv.j2 create mode 100644 src/agentex/lib/cli/templates/sync/Dockerfile.j2 create mode 100644 src/agentex/lib/cli/templates/sync/README.md.j2 create mode 100644 src/agentex/lib/cli/templates/sync/dev.ipynb.j2 create mode 100644 src/agentex/lib/cli/templates/sync/environments.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/sync/manifest.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/sync/project/acp.py.j2 create mode 100644 src/agentex/lib/cli/templates/sync/pyproject.toml.j2 create mode 100644 src/agentex/lib/cli/templates/sync/requirements.txt.j2 create mode 100644 src/agentex/lib/cli/templates/sync/test_agent.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-claude-code/.dockerignore.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-claude-code/.env.example.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-claude-code/README.md.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-claude-code/dev.ipynb.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-claude-code/environments.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-claude-code/manifest.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-claude-code/project/acp.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-claude-code/project/activities.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-claude-code/project/run_worker.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-claude-code/project/workflow.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-claude-code/pyproject.toml.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-claude-code/requirements.txt.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-codex/.dockerignore.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-codex/.env.example.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-codex/Dockerfile.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-codex/README.md.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-codex/dev.ipynb.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-codex/environments.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-codex/manifest.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-codex/project/acp.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-codex/project/activities.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-codex/project/run_worker.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-codex/project/workflow.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-codex/pyproject.toml.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-codex/requirements.txt.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-langgraph/.dockerignore.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-langgraph/.env.example.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-langgraph/README.md.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-langgraph/dev.ipynb.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-langgraph/environments.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-langgraph/manifest.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-langgraph/project/acp.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-langgraph/project/graph.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-langgraph/project/run_worker.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-langgraph/project/tools.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-langgraph/project/workflow.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-langgraph/pyproject.toml.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-langgraph/requirements.txt.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-langgraph/test_agent.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-openai-agents/.dockerignore.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-openai-agents/.env.example.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile-uv.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-openai-agents/README.md.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-openai-agents/dev.ipynb.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-openai-agents/environments.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-openai-agents/manifest.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-openai-agents/project/acp.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-openai-agents/project/activities.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-openai-agents/project/run_worker.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-openai-agents/project/workflow.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-openai-agents/pyproject.toml.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-openai-agents/requirements.txt.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-openai-agents/test_agent.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-pydantic-ai/.dockerignore.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-pydantic-ai/.env.example.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile-uv.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-pydantic-ai/README.md.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-pydantic-ai/dev.ipynb.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-pydantic-ai/environments.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-pydantic-ai/manifest.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-pydantic-ai/project/acp.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-pydantic-ai/project/agent.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-pydantic-ai/project/run_worker.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-pydantic-ai/project/tools.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-pydantic-ai/project/workflow.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-pydantic-ai/pyproject.toml.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-pydantic-ai/requirements.txt.j2 create mode 100644 src/agentex/lib/cli/templates/temporal-pydantic-ai/test_agent.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal/.dockerignore.j2 create mode 100644 src/agentex/lib/cli/templates/temporal/.env.example.j2 create mode 100644 src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 create mode 100644 src/agentex/lib/cli/templates/temporal/Dockerfile.j2 create mode 100644 src/agentex/lib/cli/templates/temporal/README.md.j2 create mode 100644 src/agentex/lib/cli/templates/temporal/dev.ipynb.j2 create mode 100644 src/agentex/lib/cli/templates/temporal/environments.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/temporal/manifest.yaml.j2 create mode 100644 src/agentex/lib/cli/templates/temporal/project/acp.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal/project/activities.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal/project/run_worker.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal/project/workflow.py.j2 create mode 100644 src/agentex/lib/cli/templates/temporal/pyproject.toml.j2 create mode 100644 src/agentex/lib/cli/templates/temporal/requirements.txt.j2 create mode 100644 src/agentex/lib/cli/templates/temporal/test_agent.py.j2 create mode 100644 src/agentex/lib/cli/utils/__init__.py create mode 100644 src/agentex/lib/cli/utils/auth_utils.py create mode 100644 src/agentex/lib/cli/utils/cli_utils.py create mode 100644 src/agentex/lib/cli/utils/credential_utils.py create mode 100644 src/agentex/lib/cli/utils/exceptions.py create mode 100644 src/agentex/lib/cli/utils/kubectl_utils.py create mode 100644 src/agentex/lib/cli/utils/kubernetes_secrets_utils.py create mode 100644 src/agentex/lib/cli/utils/path_utils.py create mode 100644 src/agentex/lib/core/__init__.py create mode 100644 src/agentex/lib/core/adapters/__init__.py create mode 100644 src/agentex/lib/core/adapters/llm/__init__.py create mode 100644 src/agentex/lib/core/adapters/llm/adapter_litellm.py create mode 100644 src/agentex/lib/core/adapters/llm/adapter_sgp.py create mode 100644 src/agentex/lib/core/adapters/llm/port.py create mode 100644 src/agentex/lib/core/adapters/streams/adapter_redis.py create mode 100644 src/agentex/lib/core/adapters/streams/port.py create mode 100644 src/agentex/lib/core/clients/__init__.py create mode 100644 src/agentex/lib/core/clients/temporal/__init__.py create mode 100644 src/agentex/lib/core/clients/temporal/temporal_client.py create mode 100644 src/agentex/lib/core/clients/temporal/types.py create mode 100644 src/agentex/lib/core/clients/temporal/utils.py create mode 100644 src/agentex/lib/core/compat/__init__.py create mode 100644 src/agentex/lib/core/compat/version_guard.py create mode 100644 src/agentex/lib/core/harness/__init__.py create mode 100644 src/agentex/lib/core/harness/auto_send.py create mode 100644 src/agentex/lib/core/harness/emitter.py create mode 100644 src/agentex/lib/core/harness/span_derivation.py create mode 100644 src/agentex/lib/core/harness/tracer.py create mode 100644 src/agentex/lib/core/harness/types.py create mode 100644 src/agentex/lib/core/harness/yield_delivery.py create mode 100644 src/agentex/lib/core/observability/__init__.py create mode 100644 src/agentex/lib/core/observability/llm_metrics.py create mode 100644 src/agentex/lib/core/observability/llm_metrics_hooks.py create mode 100644 src/agentex/lib/core/observability/tests/__init__.py create mode 100644 src/agentex/lib/core/observability/tests/test_llm_metrics.py create mode 100644 src/agentex/lib/core/observability/tests/test_llm_metrics_hooks.py create mode 100644 src/agentex/lib/core/observability/tests/test_tracing_metrics.py create mode 100644 src/agentex/lib/core/observability/tests/test_tracing_metrics_recording.py create mode 100644 src/agentex/lib/core/observability/tracing_metrics.py create mode 100644 src/agentex/lib/core/observability/tracing_metrics_recording.py create mode 100644 src/agentex/lib/core/services/__init__.py create mode 100644 src/agentex/lib/core/services/adk/__init__.py create mode 100644 src/agentex/lib/core/services/adk/acp/__init__.py create mode 100644 src/agentex/lib/core/services/adk/acp/acp.py create mode 100644 src/agentex/lib/core/services/adk/agent_task_tracker.py create mode 100644 src/agentex/lib/core/services/adk/agents.py create mode 100644 src/agentex/lib/core/services/adk/events.py create mode 100644 src/agentex/lib/core/services/adk/messages.py create mode 100644 src/agentex/lib/core/services/adk/providers/__init__.py create mode 100644 src/agentex/lib/core/services/adk/providers/litellm.py create mode 100644 src/agentex/lib/core/services/adk/providers/openai.py create mode 100644 src/agentex/lib/core/services/adk/providers/sgp.py create mode 100644 src/agentex/lib/core/services/adk/state.py create mode 100644 src/agentex/lib/core/services/adk/streaming.py create mode 100644 src/agentex/lib/core/services/adk/tasks.py create mode 100644 src/agentex/lib/core/services/adk/tracing.py create mode 100644 src/agentex/lib/core/services/adk/utils/__init__.py create mode 100644 src/agentex/lib/core/services/adk/utils/templating.py create mode 100644 src/agentex/lib/core/temporal/__init__.py create mode 100644 src/agentex/lib/core/temporal/activities/__init__.py create mode 100644 src/agentex/lib/core/temporal/activities/activity_helpers.py create mode 100644 src/agentex/lib/core/temporal/activities/adk/__init__.py create mode 100644 src/agentex/lib/core/temporal/activities/adk/acp/__init__.py create mode 100644 src/agentex/lib/core/temporal/activities/adk/acp/acp_activities.py create mode 100644 src/agentex/lib/core/temporal/activities/adk/agent_task_tracker_activities.py create mode 100644 src/agentex/lib/core/temporal/activities/adk/agents_activities.py create mode 100644 src/agentex/lib/core/temporal/activities/adk/events_activities.py create mode 100644 src/agentex/lib/core/temporal/activities/adk/messages_activities.py create mode 100644 src/agentex/lib/core/temporal/activities/adk/providers/__init__.py create mode 100644 src/agentex/lib/core/temporal/activities/adk/providers/litellm_activities.py create mode 100644 src/agentex/lib/core/temporal/activities/adk/providers/openai_activities.py create mode 100644 src/agentex/lib/core/temporal/activities/adk/providers/sgp_activities.py create mode 100644 src/agentex/lib/core/temporal/activities/adk/state_activities.py create mode 100644 src/agentex/lib/core/temporal/activities/adk/streaming_activities.py create mode 100644 src/agentex/lib/core/temporal/activities/adk/tasks_activities.py create mode 100644 src/agentex/lib/core/temporal/activities/adk/tracing_activities.py create mode 100644 src/agentex/lib/core/temporal/activities/adk/utils/__init__.py create mode 100644 src/agentex/lib/core/temporal/activities/adk/utils/templating_activities.py create mode 100644 src/agentex/lib/core/temporal/plugins/__init__.py create mode 100644 src/agentex/lib/core/temporal/plugins/claude_agents/__init__.py create mode 100644 src/agentex/lib/core/temporal/plugins/claude_agents/activities.py create mode 100644 src/agentex/lib/core/temporal/plugins/claude_agents/hooks/__init__.py create mode 100644 src/agentex/lib/core/temporal/plugins/claude_agents/hooks/hooks.py create mode 100644 src/agentex/lib/core/temporal/plugins/claude_agents/message_handler.py create mode 100644 src/agentex/lib/core/temporal/plugins/openai_agents/README.md create mode 100644 src/agentex/lib/core/temporal/plugins/openai_agents/__init__.py create mode 100644 src/agentex/lib/core/temporal/plugins/openai_agents/hooks/__init__.py create mode 100644 src/agentex/lib/core/temporal/plugins/openai_agents/hooks/activities.py create mode 100644 src/agentex/lib/core/temporal/plugins/openai_agents/hooks/hooks.py create mode 100644 src/agentex/lib/core/temporal/plugins/openai_agents/interceptors/__init__.py create mode 100644 src/agentex/lib/core/temporal/plugins/openai_agents/interceptors/context_interceptor.py create mode 100644 src/agentex/lib/core/temporal/plugins/openai_agents/models/__init__.py create mode 100644 src/agentex/lib/core/temporal/plugins/openai_agents/models/temporal_streaming_model.py create mode 100644 src/agentex/lib/core/temporal/plugins/openai_agents/run.py create mode 100644 src/agentex/lib/core/temporal/plugins/openai_agents/tests/__init__.py create mode 100644 src/agentex/lib/core/temporal/plugins/openai_agents/tests/conftest.py create mode 100644 src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_convert_tools.py create mode 100644 src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_hosted_tools.py create mode 100644 src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_run_turn_and_hooks.py create mode 100644 src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_streaming_model.py create mode 100644 src/agentex/lib/core/temporal/services/__init__.py create mode 100644 src/agentex/lib/core/temporal/services/temporal_task_service.py create mode 100644 src/agentex/lib/core/temporal/types/__init__.py create mode 100644 src/agentex/lib/core/temporal/types/workflow.py create mode 100644 src/agentex/lib/core/temporal/workers/__init__.py create mode 100644 src/agentex/lib/core/temporal/workers/worker.py create mode 100644 src/agentex/lib/core/temporal/workflows/workflow.py create mode 100644 src/agentex/lib/core/tracing/__init__.py create mode 100644 src/agentex/lib/core/tracing/code_revision.py create mode 100644 src/agentex/lib/core/tracing/lineage.py create mode 100644 src/agentex/lib/core/tracing/obs_ids.py create mode 100644 src/agentex/lib/core/tracing/obs_span.py create mode 100644 src/agentex/lib/core/tracing/processors/agentex_tracing_processor.py create mode 100644 src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py create mode 100644 src/agentex/lib/core/tracing/processors/tracing_processor_interface.py create mode 100644 src/agentex/lib/core/tracing/span_error.py create mode 100644 src/agentex/lib/core/tracing/span_queue.py create mode 100644 src/agentex/lib/core/tracing/temporal.py create mode 100644 src/agentex/lib/core/tracing/trace.py create mode 100644 src/agentex/lib/core/tracing/tracer.py create mode 100644 src/agentex/lib/core/tracing/tracing_processor_manager.py create mode 100644 src/agentex/lib/environment_variables.py create mode 100644 src/agentex/lib/py.typed create mode 100644 src/agentex/lib/sdk/__init__.py create mode 100644 src/agentex/lib/sdk/config/__init__.py create mode 100644 src/agentex/lib/sdk/config/agent_config.py create mode 100644 src/agentex/lib/sdk/config/agent_manifest.py create mode 100644 src/agentex/lib/sdk/config/build_config.py create mode 100644 src/agentex/lib/sdk/config/deployment_config.py create mode 100644 src/agentex/lib/sdk/config/environment_config.py create mode 100644 src/agentex/lib/sdk/config/local_development_config.py create mode 100644 src/agentex/lib/sdk/config/project_config.py create mode 100644 src/agentex/lib/sdk/config/validation.py create mode 100644 src/agentex/lib/sdk/fastacp/__init__.py create mode 100644 src/agentex/lib/sdk/fastacp/base/base_acp_server.py create mode 100644 src/agentex/lib/sdk/fastacp/base/constants.py create mode 100644 src/agentex/lib/sdk/fastacp/fastacp.py create mode 100644 src/agentex/lib/sdk/fastacp/impl/async_base_acp.py create mode 100644 src/agentex/lib/sdk/fastacp/impl/sync_acp.py create mode 100644 src/agentex/lib/sdk/fastacp/impl/temporal_acp.py create mode 100644 src/agentex/lib/sdk/fastacp/tests/README.md create mode 100644 src/agentex/lib/sdk/fastacp/tests/conftest.py create mode 100644 src/agentex/lib/sdk/fastacp/tests/pytest.ini create mode 100644 src/agentex/lib/sdk/fastacp/tests/run_tests.py create mode 100644 src/agentex/lib/sdk/fastacp/tests/test_base_acp_server.py create mode 100644 src/agentex/lib/sdk/fastacp/tests/test_fastacp_factory.py create mode 100644 src/agentex/lib/sdk/fastacp/tests/test_integration.py create mode 100644 src/agentex/lib/sdk/state_machine/__init__.py create mode 100644 src/agentex/lib/sdk/state_machine/noop_workflow.py create mode 100644 src/agentex/lib/sdk/state_machine/state.py create mode 100644 src/agentex/lib/sdk/state_machine/state_machine.py create mode 100644 src/agentex/lib/sdk/state_machine/state_workflow.py create mode 100644 src/agentex/lib/sdk/utils/__init__.py create mode 100644 src/agentex/lib/sdk/utils/messages.py create mode 100644 src/agentex/lib/sdk/utils/webhooks.py create mode 100644 src/agentex/lib/types/__init__.py create mode 100644 src/agentex/lib/types/acp.py create mode 100644 src/agentex/lib/types/agent_card.py create mode 100644 src/agentex/lib/types/agent_configs.py create mode 100644 src/agentex/lib/types/agent_results.py create mode 100644 src/agentex/lib/types/converters.py create mode 100644 src/agentex/lib/types/credentials.py create mode 100644 src/agentex/lib/types/fastacp.py create mode 100644 src/agentex/lib/types/files.py create mode 100644 src/agentex/lib/types/json_rpc.py create mode 100644 src/agentex/lib/types/llm_messages.py create mode 100644 src/agentex/lib/types/tracing.py create mode 100644 src/agentex/lib/utils/__init__.py create mode 100644 src/agentex/lib/utils/build_provenance.py create mode 100644 src/agentex/lib/utils/completions.py create mode 100644 src/agentex/lib/utils/console.py create mode 100644 src/agentex/lib/utils/debug.py create mode 100644 src/agentex/lib/utils/dev_tools/__init__.py create mode 100644 src/agentex/lib/utils/dev_tools/async_messages.py create mode 100644 src/agentex/lib/utils/io.py create mode 100644 src/agentex/lib/utils/iterables.py create mode 100644 src/agentex/lib/utils/json_schema.py create mode 100644 src/agentex/lib/utils/logging.py create mode 100644 src/agentex/lib/utils/mcp.py create mode 100644 src/agentex/lib/utils/metadata_filters.py create mode 100644 src/agentex/lib/utils/model_utils.py create mode 100644 src/agentex/lib/utils/parsing.py create mode 100644 src/agentex/lib/utils/regex.py create mode 100644 src/agentex/lib/utils/registration.py create mode 100644 src/agentex/lib/utils/temporal.py create mode 100644 src/agentex/protocol/__init__.py create mode 100644 src/agentex/protocol/acp.py create mode 100644 src/agentex/protocol/json_rpc.py create mode 100644 src/agentex/py.typed create mode 100644 src/agentex/resources/__init__.py create mode 100644 src/agentex/resources/agents/__init__.py create mode 100644 src/agentex/resources/agents/agents.py create mode 100644 src/agentex/resources/agents/deployments.py create mode 100644 src/agentex/resources/agents/schedules.py create mode 100644 src/agentex/resources/checkpoints.py create mode 100644 src/agentex/resources/deployment_history.py create mode 100644 src/agentex/resources/events.py create mode 100644 src/agentex/resources/messages/__init__.py create mode 100644 src/agentex/resources/messages/batch.py create mode 100644 src/agentex/resources/messages/messages.py create mode 100644 src/agentex/resources/spans.py create mode 100644 src/agentex/resources/states.py create mode 100644 src/agentex/resources/tasks.py create mode 100644 src/agentex/resources/tracker.py create mode 100644 src/agentex/resources/webhooks.py create mode 100644 src/agentex/types/__init__.py create mode 100644 src/agentex/types/acp_type.py create mode 100644 src/agentex/types/agent.py create mode 100644 src/agentex/types/agent_list_params.py create mode 100644 src/agentex/types/agent_list_response.py create mode 100644 src/agentex/types/agent_register_build_params.py create mode 100644 src/agentex/types/agent_rpc_by_name_params.py create mode 100644 src/agentex/types/agent_rpc_params.py create mode 100644 src/agentex/types/agent_rpc_response.py create mode 100644 src/agentex/types/agent_rpc_result.py create mode 100644 src/agentex/types/agent_task_tracker.py create mode 100644 src/agentex/types/agents/__init__.py create mode 100644 src/agentex/types/agents/deployment_create_params.py create mode 100644 src/agentex/types/agents/deployment_create_response.py create mode 100644 src/agentex/types/agents/deployment_list_params.py create mode 100644 src/agentex/types/agents/deployment_list_response.py create mode 100644 src/agentex/types/agents/deployment_preview_rpc_params.py create mode 100644 src/agentex/types/agents/deployment_promote_response.py create mode 100644 src/agentex/types/agents/deployment_retrieve_response.py create mode 100644 src/agentex/types/agents/schedule_create_params.py create mode 100644 src/agentex/types/agents/schedule_create_response.py create mode 100644 src/agentex/types/agents/schedule_list_params.py create mode 100644 src/agentex/types/agents/schedule_list_response.py create mode 100644 src/agentex/types/agents/schedule_pause_by_name_params.py create mode 100644 src/agentex/types/agents/schedule_pause_by_name_response.py create mode 100644 src/agentex/types/agents/schedule_pause_params.py create mode 100644 src/agentex/types/agents/schedule_pause_response.py create mode 100644 src/agentex/types/agents/schedule_resume_by_name_params.py create mode 100644 src/agentex/types/agents/schedule_resume_by_name_response.py create mode 100644 src/agentex/types/agents/schedule_resume_params.py create mode 100644 src/agentex/types/agents/schedule_resume_response.py create mode 100644 src/agentex/types/agents/schedule_retrieve_by_name_response.py create mode 100644 src/agentex/types/agents/schedule_retrieve_response.py create mode 100644 src/agentex/types/agents/schedule_skip_params.py create mode 100644 src/agentex/types/agents/schedule_skip_response.py create mode 100644 src/agentex/types/agents/schedule_trigger_by_name_response.py create mode 100644 src/agentex/types/agents/schedule_trigger_response.py create mode 100644 src/agentex/types/agents/schedule_unskip_params.py create mode 100644 src/agentex/types/agents/schedule_unskip_response.py create mode 100644 src/agentex/types/agents/schedule_update_by_name_params.py create mode 100644 src/agentex/types/agents/schedule_update_by_name_response.py create mode 100644 src/agentex/types/agents/schedule_update_params.py create mode 100644 src/agentex/types/agents/schedule_update_response.py create mode 100644 src/agentex/types/checkpoint_delete_thread_params.py create mode 100644 src/agentex/types/checkpoint_get_tuple_params.py create mode 100644 src/agentex/types/checkpoint_get_tuple_response.py create mode 100644 src/agentex/types/checkpoint_list_params.py create mode 100644 src/agentex/types/checkpoint_list_response.py create mode 100644 src/agentex/types/checkpoint_put_params.py create mode 100644 src/agentex/types/checkpoint_put_response.py create mode 100644 src/agentex/types/checkpoint_put_writes_params.py create mode 100644 src/agentex/types/data_content.py create mode 100644 src/agentex/types/data_content_param.py create mode 100644 src/agentex/types/data_delta.py create mode 100644 src/agentex/types/deployment_history.py create mode 100644 src/agentex/types/deployment_history_list_params.py create mode 100644 src/agentex/types/deployment_history_list_response.py create mode 100644 src/agentex/types/event.py create mode 100644 src/agentex/types/event_list_params.py create mode 100644 src/agentex/types/event_list_response.py create mode 100644 src/agentex/types/message_author.py create mode 100644 src/agentex/types/message_create_params.py create mode 100644 src/agentex/types/message_list_paginated_params.py create mode 100644 src/agentex/types/message_list_paginated_response.py create mode 100644 src/agentex/types/message_list_params.py create mode 100644 src/agentex/types/message_list_response.py create mode 100644 src/agentex/types/message_style.py create mode 100644 src/agentex/types/message_update_params.py create mode 100644 src/agentex/types/messages/__init__.py create mode 100644 src/agentex/types/messages/batch_create_params.py create mode 100644 src/agentex/types/messages/batch_create_response.py create mode 100644 src/agentex/types/messages/batch_update_params.py create mode 100644 src/agentex/types/messages/batch_update_response.py create mode 100644 src/agentex/types/reasoning_content.py create mode 100644 src/agentex/types/reasoning_content_delta.py create mode 100644 src/agentex/types/reasoning_content_param.py create mode 100644 src/agentex/types/reasoning_summary_delta.py create mode 100644 src/agentex/types/shared/__init__.py create mode 100644 src/agentex/types/shared/delete_response.py create mode 100644 src/agentex/types/span.py create mode 100644 src/agentex/types/span_create_params.py create mode 100644 src/agentex/types/span_list_params.py create mode 100644 src/agentex/types/span_list_response.py create mode 100644 src/agentex/types/span_update_params.py create mode 100644 src/agentex/types/state.py create mode 100644 src/agentex/types/state_create_params.py create mode 100644 src/agentex/types/state_list_params.py create mode 100644 src/agentex/types/state_list_response.py create mode 100644 src/agentex/types/state_update_params.py create mode 100644 src/agentex/types/task.py create mode 100644 src/agentex/types/task_cancel_params.py create mode 100644 src/agentex/types/task_complete_params.py create mode 100644 src/agentex/types/task_fail_params.py create mode 100644 src/agentex/types/task_interrupt_params.py create mode 100644 src/agentex/types/task_list_params.py create mode 100644 src/agentex/types/task_list_response.py create mode 100644 src/agentex/types/task_message.py create mode 100644 src/agentex/types/task_message_content.py create mode 100644 src/agentex/types/task_message_content_param.py create mode 100644 src/agentex/types/task_message_delta.py create mode 100644 src/agentex/types/task_message_update.py create mode 100644 src/agentex/types/task_query_workflow_response.py create mode 100644 src/agentex/types/task_retrieve_by_name_params.py create mode 100644 src/agentex/types/task_retrieve_by_name_response.py create mode 100644 src/agentex/types/task_retrieve_params.py create mode 100644 src/agentex/types/task_retrieve_response.py create mode 100644 src/agentex/types/task_terminate_params.py create mode 100644 src/agentex/types/task_timeout_params.py create mode 100644 src/agentex/types/task_update_by_id_params.py create mode 100644 src/agentex/types/task_update_by_name_params.py create mode 100644 src/agentex/types/text_content.py create mode 100644 src/agentex/types/text_content_param.py create mode 100644 src/agentex/types/text_delta.py create mode 100644 src/agentex/types/text_format.py create mode 100644 src/agentex/types/tool_request_content.py create mode 100644 src/agentex/types/tool_request_content_param.py create mode 100644 src/agentex/types/tool_request_delta.py create mode 100644 src/agentex/types/tool_response_content.py create mode 100644 src/agentex/types/tool_response_content_param.py create mode 100644 src/agentex/types/tool_response_delta.py create mode 100644 src/agentex/types/tracker_list_params.py create mode 100644 src/agentex/types/tracker_list_response.py create mode 100644 src/agentex/types/tracker_update_params.py create mode 100644 src/agentex/types/webhook_create_webhook_trigger_params.py create mode 100644 src/agentex/types/webhook_create_webhook_trigger_response.py create mode 100644 tests/__init__.py create mode 100644 tests/api_resources/__init__.py create mode 100644 tests/api_resources/agents/__init__.py create mode 100644 tests/api_resources/agents/test_deployments.py create mode 100644 tests/api_resources/agents/test_schedules.py create mode 100644 tests/api_resources/messages/__init__.py create mode 100644 tests/api_resources/messages/test_batch.py create mode 100644 tests/api_resources/test_agents.py create mode 100644 tests/api_resources/test_checkpoints.py create mode 100644 tests/api_resources/test_deployment_history.py create mode 100644 tests/api_resources/test_events.py create mode 100644 tests/api_resources/test_messages.py create mode 100644 tests/api_resources/test_spans.py create mode 100644 tests/api_resources/test_states.py create mode 100644 tests/api_resources/test_tasks.py create mode 100644 tests/api_resources/test_tracker.py create mode 100644 tests/api_resources/test_webhooks.py create mode 100644 tests/compat/__init__.py create mode 100644 tests/compat/refresh_specs.py create mode 100644 tests/compat/server_specs/current.yaml create mode 100644 tests/compat/server_specs/manifest.json create mode 100644 tests/compat/server_specs/min-supported.yaml create mode 100644 tests/compat/test_request_compat.py create mode 100644 tests/conftest.py create mode 100644 tests/lib/__init__.py create mode 100644 tests/lib/adk/__init__.py create mode 100644 tests/lib/adk/conftest.py create mode 100644 tests/lib/adk/providers/__init__.py create mode 100644 tests/lib/adk/providers/test_litellm_usage.py create mode 100644 tests/lib/adk/providers/test_openai_activities.py create mode 100644 tests/lib/adk/providers/test_openai_turn.py create mode 100644 tests/lib/adk/test_claude_code_sync.py create mode 100644 tests/lib/adk/test_claude_code_turn.py create mode 100644 tests/lib/adk/test_codex_sync.py create mode 100644 tests/lib/adk/test_codex_turn.py create mode 100644 tests/lib/adk/test_langgraph_async.py create mode 100644 tests/lib/adk/test_langgraph_sync.py create mode 100644 tests/lib/adk/test_langgraph_turn.py create mode 100644 tests/lib/adk/test_messages_module.py create mode 100644 tests/lib/adk/test_messages_service.py create mode 100644 tests/lib/adk/test_openai_sync.py create mode 100644 tests/lib/adk/test_pydantic_ai_async.py create mode 100644 tests/lib/adk/test_pydantic_ai_sync.py create mode 100644 tests/lib/adk/test_pydantic_ai_turn.py create mode 100644 tests/lib/adk/test_state_service.py create mode 100644 tests/lib/adk/test_tasks_activities.py create mode 100644 tests/lib/adk/test_tasks_module.py create mode 100644 tests/lib/adk/test_tasks_service.py create mode 100644 tests/lib/adk/test_tracing_activities.py create mode 100644 tests/lib/adk/test_tracing_module.py create mode 100644 tests/lib/adk/test_tracing_service.py create mode 100644 tests/lib/cli/__init__.py create mode 100644 tests/lib/cli/test_agent_handlers.py create mode 100644 tests/lib/cli/test_environment_config.py create mode 100644 tests/lib/cli/test_init_templates.py create mode 100644 tests/lib/cli/test_run_handlers_streaming.py create mode 100644 tests/lib/cli/test_validation.py create mode 100644 tests/lib/core/__init__.py create mode 100644 tests/lib/core/harness/__init__.py create mode 100644 tests/lib/core/harness/_fakes.py create mode 100644 tests/lib/core/harness/conformance/__init__.py create mode 100644 tests/lib/core/harness/conformance/conftest.py create mode 100644 tests/lib/core/harness/conformance/runner.py create mode 100644 tests/lib/core/harness/conformance/test_claude_code_conformance.py create mode 100644 tests/lib/core/harness/conformance/test_codex_conformance.py create mode 100644 tests/lib/core/harness/conformance/test_conformance.py create mode 100644 tests/lib/core/harness/conformance/test_langgraph_conformance.py create mode 100644 tests/lib/core/harness/conformance/test_openai_conformance.py create mode 100644 tests/lib/core/harness/conformance/test_pydantic_ai_conformance.py create mode 100644 tests/lib/core/harness/test_auto_send.py create mode 100644 tests/lib/core/harness/test_emitter.py create mode 100644 tests/lib/core/harness/test_harness_claude_code_async.py create mode 100644 tests/lib/core/harness/test_harness_claude_code_sync.py create mode 100644 tests/lib/core/harness/test_harness_claude_code_temporal.py create mode 100644 tests/lib/core/harness/test_harness_codex_async.py create mode 100644 tests/lib/core/harness/test_harness_codex_sync.py create mode 100644 tests/lib/core/harness/test_harness_codex_temporal.py create mode 100644 tests/lib/core/harness/test_harness_langgraph_async.py create mode 100644 tests/lib/core/harness/test_harness_langgraph_sync.py create mode 100644 tests/lib/core/harness/test_harness_langgraph_temporal.py create mode 100644 tests/lib/core/harness/test_harness_openai_async.py create mode 100644 tests/lib/core/harness/test_harness_openai_sync.py create mode 100644 tests/lib/core/harness/test_harness_openai_temporal.py create mode 100644 tests/lib/core/harness/test_harness_pydantic_ai_async.py create mode 100644 tests/lib/core/harness/test_harness_pydantic_ai_sync.py create mode 100644 tests/lib/core/harness/test_harness_pydantic_ai_temporal.py create mode 100644 tests/lib/core/harness/test_span_derivation.py create mode 100644 tests/lib/core/harness/test_tracer.py create mode 100644 tests/lib/core/harness/test_tracer_lineage.py create mode 100644 tests/lib/core/harness/test_types.py create mode 100644 tests/lib/core/harness/test_yield_delivery.py create mode 100644 tests/lib/core/services/__init__.py create mode 100644 tests/lib/core/services/adk/__init__.py create mode 100644 tests/lib/core/services/adk/test_streaming.py create mode 100644 tests/lib/core/services/test_temporal_task_service.py create mode 100644 tests/lib/core/temporal/__init__.py create mode 100644 tests/lib/core/temporal/plugins/__init__.py create mode 100644 tests/lib/core/temporal/plugins/openai_agents/__init__.py create mode 100644 tests/lib/core/temporal/plugins/openai_agents/test_model_usage.py create mode 100644 tests/lib/core/temporal/test_base_workflow_continue_as_new.py create mode 100644 tests/lib/core/temporal/workers/__init__.py create mode 100644 tests/lib/core/temporal/workers/test_worker_version_guard.py create mode 100644 tests/lib/core/tracing/__init__.py create mode 100644 tests/lib/core/tracing/processors/__init__.py create mode 100644 tests/lib/core/tracing/processors/test_agentex_tracing_processor.py create mode 100644 tests/lib/core/tracing/processors/test_sgp_tracing_processor.py create mode 100644 tests/lib/core/tracing/processors/test_tracing_processor_interface.py create mode 100644 tests/lib/core/tracing/test_code_revision.py create mode 100644 tests/lib/core/tracing/test_lineage.py create mode 100644 tests/lib/core/tracing/test_obs_ids.py create mode 100644 tests/lib/core/tracing/test_obs_span.py create mode 100644 tests/lib/core/tracing/test_span_error.py create mode 100644 tests/lib/core/tracing/test_span_queue.py create mode 100644 tests/lib/core/tracing/test_span_queue_load.py create mode 100644 tests/lib/core/tracing/test_temporal_interceptor.py create mode 100644 tests/lib/core/tracing/test_trace_task_id.py create mode 100644 tests/lib/test_agent_card.py create mode 100644 tests/lib/test_agentex_worker.py create mode 100644 tests/lib/test_auto_send_params_created_at.py create mode 100644 tests/lib/test_build_provenance.py create mode 100644 tests/lib/test_claude_agents_activities.py create mode 100644 tests/lib/test_claude_agents_hooks.py create mode 100644 tests/lib/test_metadata_filters.py create mode 100644 tests/lib/test_payload_codec.py create mode 100644 tests/lib/test_state_machine.py create mode 100644 tests/lib/test_temporal_utils.py create mode 100644 tests/lib/test_version_guard.py create mode 100644 tests/lib/test_webhooks.py create mode 100644 tests/lib/utils/__init__.py create mode 100644 tests/lib/utils/test_completions.py create mode 100644 tests/lib/utils/test_logging_level.py create mode 100644 tests/sample_file.txt create mode 100644 tests/test_acp_interrupt.py create mode 100644 tests/test_adk_tracing_span_error.py create mode 100644 tests/test_client.py create mode 100644 tests/test_config_shims.py create mode 100644 tests/test_extract_files.py create mode 100644 tests/test_files.py create mode 100644 tests/test_function_tool.py create mode 100644 tests/test_header_forwarding.py create mode 100644 tests/test_model_utils.py create mode 100644 tests/test_models.py create mode 100644 tests/test_obs_handle_registry.py create mode 100644 tests/test_obs_span_fallback.py create mode 100644 tests/test_protocol_shims.py create mode 100644 tests/test_qs.py create mode 100644 tests/test_required_args.py create mode 100644 tests/test_response.py create mode 100644 tests/test_streaming.py create mode 100644 tests/test_task_cancel.py create mode 100644 tests/test_temporal_obs_backend.py create mode 100644 tests/test_trace_context_extraction.py create mode 100644 tests/test_transform.py create mode 100644 tests/test_utils/test_datetime_parse.py create mode 100644 tests/test_utils/test_json.py create mode 100644 tests/test_utils/test_path.py create mode 100644 tests/test_utils/test_proxy.py create mode 100644 tests/test_utils/test_typing.py create mode 100644 tests/test_version_guard.py create mode 100644 tests/utils.py create mode 100644 uv.lock diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..378d0ebec --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "WebSearch", + "WebFetch(domain:docs.temporal.io)", + "Bash(rye run pytest:*)", + "Bash(rye run lint:*)", + "Bash(rye run typecheck:*)", + "Bash(rye run sync:*)", + "Bash(rye run build:*)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/.cursor/rules/00_repo_tooling.mdc b/.cursor/rules/00_repo_tooling.mdc new file mode 100644 index 000000000..ca3a44cbd --- /dev/null +++ b/.cursor/rules/00_repo_tooling.mdc @@ -0,0 +1,24 @@ +--- +description: Project-wide tooling, env, and command conventions +globs: "**/*" +alwaysApply: true +--- + +Use Rye for Python dependency management and workflows. Prefer these commands: + +- Setup env: `./scripts/bootstrap` or `rye sync --all-features` [[Use Rye in this repo]] +- Run tests: `rye run pytest` or `./scripts/test` +- Run a specific test: `rye run pytest path/to/test_file.py::TestClass::test_method -v` +- Format: `rye run format` or `./scripts/format` +- Lint: `rye run lint` or `./scripts/lint` +- Type check: `rye run typecheck` (runs pyright and mypy) +- Build: `rye build` + +Environment requirements: + +- Python 3.12+ is required +- A mock server auto-starts for tests on port 4010 + +Notes: + +- Only use `uv` inside of tutorial folders which have their own virtualenv (managed by a tutorial specific pyproject.toml inside the relevant tutorial folder). Otherwise use rye at the top level. diff --git a/.cursor/rules/05_permissions_and_tools.mdc b/.cursor/rules/05_permissions_and_tools.mdc new file mode 100644 index 000000000..72ff65979 --- /dev/null +++ b/.cursor/rules/05_permissions_and_tools.mdc @@ -0,0 +1,18 @@ +--- +description: Cursor agent permissions and allowed tools aligned with Claude settings +globs: "**/*" +alwaysApply: true +--- + +When invoking external tools or the terminal, follow these constraints: + +- Web search is allowed when needed for docs and references +- Prefer fetching docs from `docs.temporal.io` when researching Temporal topics +- Allowed bash commands should go through Rye workflows: + - `rye run pytest:*` + - `rye run lint:*` + - `rye run typecheck:*` + - `rye run sync:*` + - `rye run build:*` + +Default to Rye; only use other tools when explicitly required by the codebase. diff --git a/.cursor/rules/10_architecture.mdc b/.cursor/rules/10_architecture.mdc new file mode 100644 index 000000000..230f14569 --- /dev/null +++ b/.cursor/rules/10_architecture.mdc @@ -0,0 +1,30 @@ +--- +description: Repository architecture overview and code navigation hints +globs: "src/agentex/**, examples/**, tests/**, README.md" +alwaysApply: false +--- + +Code structure expectations: + +- `src/agentex/` contains the core SDK and generated API client code +- `src/agentex/lib/` contains manually maintained code that should not be overwritten by the code generator + - `cli/` Typer-based CLI implementation + - `core/` Core services, adapters, and Temporal workflows + - `sdk/` SDK utilities and FastACP implementation + - `types/` Custom type definitions + - `utils/` Utility functions +- `examples/` provides example implementations and tutorials +- `tests/` contains the test suites + +Key components quick reference: + +- Client Layer: HTTP client for AgentEx API in `_client.py` and `resources/` +- CLI Layer: Typer-based commands under `lib/cli/` +- Core Services: Temporal workflows and services under `lib/core/` +- FastACP: Protocol implementation in `lib/sdk/fastacp/` +- State Machine: Workflow state management in `lib/sdk/state_machine/` + +Generated vs manual code: + +- Treat `src/agentex/lib/**` as manual code; avoid edits in generated areas unless regenerating consistently +- Expect merge conflicts between generator outputs and manual patches; keep custom logic in `lib/` diff --git a/.cursor/rules/20_codegen_boundaries.mdc b/.cursor/rules/20_codegen_boundaries.mdc new file mode 100644 index 000000000..0bd03880d --- /dev/null +++ b/.cursor/rules/20_codegen_boundaries.mdc @@ -0,0 +1,11 @@ +--- +description: Keep manual code separate from generated SDK code +globs: "src/agentex/**" +alwaysApply: true +--- + +Guideline: + +- Avoid modifying auto-generated files in `src/agentex/` except where explicitly intended. Place custom logic, extensions, and higher-level abstractions in `src/agentex/lib/`. +- When adding features, prefer adding new modules under `src/agentex/lib/**` rather than changing generated files directly. +- If a change to generated code is required, document the reason and ensure the generator configuration or upstream schema is updated to make the change reproducible. diff --git a/.cursor/rules/30_cli_and_commands.mdc b/.cursor/rules/30_cli_and_commands.mdc new file mode 100644 index 000000000..f39442341 --- /dev/null +++ b/.cursor/rules/30_cli_and_commands.mdc @@ -0,0 +1,18 @@ +--- +description: Guidance for working with the agentex CLI and commands +globs: "src/agentex/lib/cli/**, src/agentex/lib/core/**" +alwaysApply: false +--- + +The `agentex` CLI exposes: + +- `agentex agents` for get/list/run/build/deploy agents +- `agentex tasks` for get/list/delete tasks +- `agentex secrets` for sync/get/list/delete secrets +- `agentex uv` as a UV wrapper with AgentEx-specific enhancements +- `agentex init` to initialize new agent projects + +Development tips: + +- For agent development, use `agentex agents run --manifest manifest.yaml` +- For debugging, append `--debug-worker` and optionally `--debug-port 5679` diff --git a/.cursor/rules/40_temporal_and_agents.mdc b/.cursor/rules/40_temporal_and_agents.mdc new file mode 100644 index 000000000..7f1053915 --- /dev/null +++ b/.cursor/rules/40_temporal_and_agents.mdc @@ -0,0 +1,17 @@ +--- +description: Temporal workflows, activities, and agent development guidance +globs: "src/agentex/lib/core/temporal/**, examples/**/10_temporal/**" +alwaysApply: false +--- + +Temporal integration: + +- Workflow definitions live in `lib/core/temporal/` +- Include activity definitions for different providers and worker implementations +- Keep workflow logic deterministic and side-effect free; move I/O into activities + +Agent framework: + +- Agents are manifest-driven and support multiple agent types (sync and Temporal-based) +- Use the examples under `examples/10_async/` and `examples/10_temporal/` for patterns +- For debugging agents, use the CLI flags `--debug-worker` and `--debug-port` diff --git a/.cursor/rules/50_tests_and_mocking.mdc b/.cursor/rules/50_tests_and_mocking.mdc new file mode 100644 index 000000000..420de4ac7 --- /dev/null +++ b/.cursor/rules/50_tests_and_mocking.mdc @@ -0,0 +1,16 @@ +--- +description: Testing workflow and mock server details +globs: "tests/**, scripts/test, scripts/mock" +alwaysApply: true +--- + +Testing: + +- Run tests with `rye run pytest` or `./scripts/test` +- To run a specific test: `rye run pytest path/to/test_file.py::TestClass::test_method -v` +- A mock server is automatically started for tests on port 4010 + +When writing tests: + +- Prefer deterministic unit tests that do not depend on external services +- Use the mock server and fixtures provided in the repository diff --git a/.cursor/rules/60_style_lint_typecheck.mdc b/.cursor/rules/60_style_lint_typecheck.mdc new file mode 100644 index 000000000..f36f02d7c --- /dev/null +++ b/.cursor/rules/60_style_lint_typecheck.mdc @@ -0,0 +1,16 @@ +--- +description: Formatting, linting, and type checking standards +globs: "src/**, tests/**" +alwaysApply: true +--- + +Standards: + +- Format code via `rye run format` or `./scripts/format` +- Lint via `rye run lint` or `./scripts/lint` +- Type check via `rye run typecheck` (pyright + mypy) + +Guidance: + +- Keep code readable and consistent; prefer small, focused functions +- Avoid introducing style or type violations; fix before committing diff --git a/.cursor/rules/70_examples_and_docs.mdc b/.cursor/rules/70_examples_and_docs.mdc new file mode 100644 index 000000000..7d16e9d01 --- /dev/null +++ b/.cursor/rules/70_examples_and_docs.mdc @@ -0,0 +1,11 @@ +--- +description: How to use examples and documentation for development +globs: "examples/**, README.md" +alwaysApply: false +--- + +Use the `examples/` directory as reference implementations and tutorials. When creating new features: + +- Mirror patterns from the closest matching example +- Keep examples runnable with the documented commands +- Prefer adding or updating examples alongside significant feature changes diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 000000000..a7eb0f23b --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,8 @@ +ARG VARIANT="3.12" +FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} + +USER vscode + +COPY --from=ghcr.io/astral-sh/uv:0.10.2 /uv /uvx /bin/ + +RUN echo "[[ -d .venv ]] && source .venv/bin/activate || export PATH=\$PATH" >> /home/vscode/.bashrc diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..e01283d8c --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,43 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/debian +{ + "name": "Debian", + "build": { + "dockerfile": "Dockerfile", + "context": ".." + }, + + "postStartCommand": "uv sync --all-extras", + + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python" + ], + "settings": { + "terminal.integrated.shell.linux": "/bin/bash", + "python.pythonPath": ".venv/bin/python", + "python.defaultInterpreterPath": ".venv/bin/python", + "python.typeChecking": "basic", + "terminal.integrated.env.linux": { + "PATH": "${env:PATH}" + } + } + } + }, + "features": { + "ghcr.io/devcontainers/features/node:1": {} + } + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.github/scripts/sync_agents.py b/.github/scripts/sync_agents.py new file mode 100644 index 000000000..e69de29bb diff --git a/.github/workflows/agentex-tutorials-test.yml b/.github/workflows/agentex-tutorials-test.yml new file mode 100644 index 000000000..51f8a2141 --- /dev/null +++ b/.github/workflows/agentex-tutorials-test.yml @@ -0,0 +1,367 @@ +name: Test Tutorial Agents + +on: + pull_request: + branches: [main, next] + push: + branches: [main] + workflow_dispatch: + +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 }} + steps: + - name: Checkout agentex-python repo + uses: actions/checkout@v4 + + - name: Find all tutorials + id: get-tutorials + run: | + cd examples/tutorials + # Find all tutorials with a manifest.yaml + all_tutorials=$(find . -name "manifest.yaml" -exec dirname {} \; | sort | sed 's|^\./||') + + # Convert to JSON array + tutorials=$(echo "$all_tutorials" | jq -R -s -c 'split("\n") | map(select(length > 0))') + + echo "tutorials=$tutorials" >> $GITHUB_OUTPUT + echo "All tutorials found: $(echo "$all_tutorials" | wc -l)" + echo "Final tutorial list: $tutorials" + + test-tutorial: + needs: find-tutorials + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + matrix: + tutorial: ${{ fromJson(needs.find-tutorials.outputs.tutorials) }} + fail-fast: false + name: test-${{ matrix.tutorial }} + + steps: + - name: Checkout agentex-python repo + uses: actions/checkout@v4 + + - name: Install UV + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.local/bin" >> $GITHUB_PATH + + # Subprocess-CLI harnesses: install the relevant CLI only for the + # claude-code / codex tutorials (no-op for every other tutorial). npm is + # preinstalled on ubuntu runners. Versions mirror the golden agent's + # sandbox image (teams/sgp/agents/golden_agent/sandbox/Dockerfile): claude-code + # is pinned to the same CLAUDE_CODE_VERSION; codex is left unpinned there, + # so it is left unpinned here too. Bump CLAUDE_CODE_VERSION in lockstep + # with the sandbox Dockerfile. + - name: Install harness CLI (claude-code / codex only) + if: ${{ contains(matrix.tutorial, 'claude_code') || contains(matrix.tutorial, 'codex') }} + env: + CLAUDE_CODE_VERSION: "2.1.142" + run: | + if [[ "${{ matrix.tutorial }}" == *claude_code* ]]; then + echo "📦 Installing Claude Code CLI (v${CLAUDE_CODE_VERSION})..." + npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" + claude --version || true + fi + if [[ "${{ matrix.tutorial }}" == *codex* ]]; then + echo "📦 Installing Codex CLI..." + npm install -g @openai/codex + codex --version || true + fi + + - name: Pull latest AgentEx image + run: | + echo "🐳 Pulling latest Scale AgentEx Docker image..." + max_attempts=3 + attempt=1 + while [ $attempt -le $max_attempts ]; do + echo "Attempt $attempt of $max_attempts..." + if docker pull ghcr.io/scaleapi/scale-agentex/agentex:latest; then + echo "✅ Successfully pulled AgentEx Docker image" + exit 0 + fi + echo "❌ Pull failed, waiting before retry..." + sleep $((attempt * 10)) + attempt=$((attempt + 1)) + done + echo "❌ Failed to pull image after $max_attempts attempts" + exit 1 + + - name: Checkout scale-agentex repo + uses: actions/checkout@v4 + with: + repository: scaleapi/scale-agentex + path: scale-agentex + + - name: Configure Docker Compose for pulled image and host networking + run: | + cd scale-agentex/agentex + echo "🔧 Configuring AgentEx container to use pulled image and host networking..." + + # Install yq for YAML manipulation + sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 + sudo chmod +x /usr/local/bin/yq + + # Override to use pulled image instead of building + yq eval '.services.agentex.image = "ghcr.io/scaleapi/scale-agentex/agentex:latest"' -i docker-compose.yml + yq eval 'del(.services.agentex.build)' -i docker-compose.yml + + # Add extra_hosts to agentex service to make host.docker.internal work + yq eval '.services.agentex.extra_hosts = ["host.docker.internal:host-gateway"]' -i docker-compose.yml + + echo "✅ Configured docker-compose to use pulled image with host access" + + - name: Start AgentEx Server + run: | + cd scale-agentex/agentex + echo "🚀 Starting AgentEx server and dependencies..." + + # Start all services + docker compose up -d + + echo "⏳ Waiting for dependencies to be healthy..." + + # Wait for services to be healthy + for i in {1..30}; do + if docker compose ps | grep -q "healthy"; then + echo "✅ Dependencies are healthy" + break + fi + echo " Attempt $i/30: Waiting for services..." + sleep 5 + done + + # Wait specifically for AgentEx server to be ready + echo "⏳ Waiting for AgentEx server to be ready..." + for i in {1..30}; do + if curl -s --max-time 5 http://localhost:5003/health >/dev/null 2>&1; then + echo "✅ AgentEx server is ready" + break + fi + echo " Attempt $i/30: Waiting for AgentEx server..." + sleep 5 + done + + - name: Build AgentEx SDK + run: | + echo "🔨 Building both SDK wheels (slim client + heavy ADK overlay)..." + # uv workspace builds both members into the root dist/. --wheel: the + # heavy's cross-dir force-include can't build via the sdist default. + uv build --all-packages --wheel + echo "✅ Both SDK wheels built successfully" + ls -la dist/ + + - name: Test Tutorial + id: run-test + working-directory: ./examples/tutorials + env: + OPENAI_API_KEY: ${{ secrets.TUTORIAL_OPENAI_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.TUTORIAL_ANTHROPIC_API_KEY }} + # Enable the gated live tests only for the matching subprocess-CLI + # harness tutorial (the CLI is installed for it in the step above). + CLAUDE_LIVE_TESTS: ${{ contains(matrix.tutorial, 'claude_code') && '1' || '' }} + CODEX_LIVE_TESTS: ${{ contains(matrix.tutorial, 'codex') && '1' || '' }} + HEALTH_CHECK_PORT: 8080 # Use non-privileged port for temporal worker health checks + run: | + echo "Testing tutorial: ${{ matrix.tutorial }}" + AGENTEX_API_BASE_URL="http://localhost:5003" \ + ./run_agent_test.sh --build-cli "${{ matrix.tutorial }}" + + - name: Print agent logs on failure + if: failure() + working-directory: ./examples/tutorials + run: | + echo "🚨 Test failed for tutorial: ${{ matrix.tutorial }}" + + # Print agent logs from /tmp (where run_agent_test.sh writes them) + tutorial_name=$(basename "${{ matrix.tutorial }}") + agent_log="/tmp/agentex-${tutorial_name}.log" + if [[ -f "$agent_log" ]]; then + echo "📋 Agent logs ($agent_log):" + echo "----------------------------------------" + tail -100 "$agent_log" + echo "----------------------------------------" + else + echo "⚠️ No agent log at $agent_log" + echo "Available /tmp/agentex-*.log files:" + ls -la /tmp/agentex-*.log 2>/dev/null || echo " (none)" + fi + + # Print Docker server logs + echo "" + echo "📋 AgentEx Server (Docker) logs:" + echo "----------------------------------------" + cd ../../scale-agentex/agentex && docker compose logs --tail=100 agentex 2>/dev/null || echo "Could not retrieve Docker logs" + echo "----------------------------------------" + + echo "" + echo "🔍 Running python processes:" + ps aux | grep python || echo "No python processes found" + + - name: Record test result + id: test-result + if: always() + run: | + # Create results directory + mkdir -p test-results + + # Determine result + if [ "${{ steps.run-test.outcome }}" == "success" ]; then + result="passed" + echo "result=passed" >> $GITHUB_OUTPUT + echo "tutorial=${{ matrix.tutorial }}" >> $GITHUB_OUTPUT + else + result="failed" + echo "result=failed" >> $GITHUB_OUTPUT + echo "tutorial=${{ matrix.tutorial }}" >> $GITHUB_OUTPUT + fi + + # Save result to file for artifact upload + # Create a safe filename from tutorial path + safe_name=$(echo "${{ matrix.tutorial }}" | tr '/' '_' | tr -d ' ') + echo "$result" > "test-results/result-${safe_name}.txt" + echo "${{ matrix.tutorial }}" > "test-results/tutorial-${safe_name}.txt" + echo "safe_name=${safe_name}" >> $GITHUB_OUTPUT + + - name: Upload test result + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-result-${{ steps.test-result.outputs.safe_name }} + path: test-results/ + retention-days: 1 + + test-summary: + if: always() && github.repository == 'scaleapi/scale-agentex-python' + needs: [find-tutorials, test-tutorial] + runs-on: ubuntu-latest + name: Test Summary + steps: + - name: Download all test results + uses: actions/download-artifact@v4 + with: + pattern: test-result-* + path: all-results/ + merge-multiple: true + continue-on-error: true + + - name: Generate Test Summary + run: | + echo "# 🧪 Tutorial Tests Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Initialize counters + passed_count=0 + failed_count=0 + skipped_count=0 + total_count=0 + + # Get all tutorials that were supposed to run + tutorials='${{ needs.find-tutorials.outputs.tutorials }}' + + if [ -d "all-results" ] && [ "$(ls -A all-results 2>/dev/null)" ]; then + echo "📊 Processing individual test results from artifacts..." + + echo "## Test Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Tutorial | Status | Result |" >> $GITHUB_STEP_SUMMARY + echo "|----------|--------|--------|" >> $GITHUB_STEP_SUMMARY + + # Process each result file + for result_file in all-results/result-*.txt; do + if [ -f "$result_file" ]; then + # Extract the safe name from filename + safe_name=$(basename "$result_file" .txt | sed 's/result-//') + + # Get corresponding tutorial name file + tutorial_file="all-results/tutorial-${safe_name}.txt" + + if [ -f "$tutorial_file" ]; then + tutorial_name=$(cat "$tutorial_file") + result=$(cat "$result_file") + + total_count=$((total_count + 1)) + + if [ "$result" = "passed" ]; then + echo "| \`$tutorial_name\` | ✅ | Passed |" >> $GITHUB_STEP_SUMMARY + passed_count=$((passed_count + 1)) + else + echo "| \`$tutorial_name\` | ❌ | Failed |" >> $GITHUB_STEP_SUMMARY + failed_count=$((failed_count + 1)) + fi + fi + fi + done + + # Check for any tutorials that didn't have results (skipped/cancelled) + echo "$tutorials" | jq -r '.[]' | while read expected_tutorial; do + safe_expected=$(echo "$expected_tutorial" | tr '/' '_' | tr -d ' ') + if [ ! -f "all-results/result-${safe_expected}.txt" ]; then + echo "| \`$expected_tutorial\` | ⏭️ | Skipped/Cancelled |" >> $GITHUB_STEP_SUMMARY + skipped_count=$((skipped_count + 1)) + total_count=$((total_count + 1)) + fi + done + + else + echo "⚠️ No individual test results found. This could mean:" + echo "- Test jobs were cancelled before completion" + echo "- Artifacts failed to upload" + echo "- No tutorials were found to test" + echo "" + + overall_result="${{ needs.test-tutorial.result }}" + echo "Overall job status: **$overall_result**" + + if [[ "$overall_result" == "success" ]]; then + echo "✅ All tests appear to have passed based on job status." + elif [[ "$overall_result" == "failure" ]]; then + echo "❌ Some tests appear to have failed based on job status." + echo "" + echo "💡 **Tip:** Check individual job logs for specific failure details." + elif [[ "$overall_result" == "cancelled" ]]; then + echo "⏭️ Tests were cancelled." + else + echo "❓ Test status is unclear: $overall_result" + fi + + # Don't show detailed breakdown when we don't have individual results + tutorial_count=$(echo "$tutorials" | jq -r '. | length') + echo "" + echo "Expected tutorial count: $tutorial_count" + fi + + # Only show detailed statistics if we have individual results + if [ -d "all-results" ] && [ "$(ls -A all-results 2>/dev/null)" ]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "## Summary Statistics" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Total Tests:** $total_count" >> $GITHUB_STEP_SUMMARY + echo "- **Passed:** $passed_count ✅" >> $GITHUB_STEP_SUMMARY + echo "- **Failed:** $failed_count ❌" >> $GITHUB_STEP_SUMMARY + echo "- **Skipped:** $skipped_count ⏭️" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [ $failed_count -eq 0 ] && [ $passed_count -gt 0 ]; then + echo "🎉 **All tests passed!**" >> $GITHUB_STEP_SUMMARY + elif [ $failed_count -gt 0 ]; then + echo "⚠️ **Some tests failed.** Check individual job logs for details." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "💡 **Tip:** Look for the 'Print agent logs on failure' step in failed jobs for debugging information." >> $GITHUB_STEP_SUMMARY + else + echo "ℹ️ **Tests were cancelled or skipped.**" >> $GITHUB_STEP_SUMMARY + fi + fi + + - name: Fail if tests failed + if: ${{ needs.test-tutorial.result != 'success' }} + run: | + echo "❌ Test jobs did not succeed. Result: ${{ needs.test-tutorial.result }}" + exit 1 diff --git a/.github/workflows/bandit-ci.yml b/.github/workflows/bandit-ci.yml index 0b93e2056..d4690a71e 100644 --- a/.github/workflows/bandit-ci.yml +++ b/.github/workflows/bandit-ci.yml @@ -52,7 +52,13 @@ jobs: 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 - jq --argjson scanResults "$( output.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: | diff --git a/.github/workflows/build-and-push-tutorial-agent.yml b/.github/workflows/build-and-push-tutorial-agent.yml new file mode 100644 index 000000000..33c691d8b --- /dev/null +++ b/.github/workflows/build-and-push-tutorial-agent.yml @@ -0,0 +1,377 @@ +name: Build and Push Tutorial Agent + +on: + workflow_dispatch: + inputs: + rebuild_all: + description: "Rebuild all tutorial agents regardless of changes, this is reserved for maintainers only." + required: false + type: boolean + default: false + + pull_request: + paths: + - "examples/tutorials/**" + + push: + branches: + - main + paths: + - "examples/tutorials/**" + +permissions: + contents: read + packages: write + +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 + run: | + if [ "${{ github.event_name }}" != "workflow_dispatch" ]; then + echo "Skipping permission check - not a workflow_dispatch event" + exit 0 + fi + echo "Checking maintainer permissions for workflow_dispatch" + + - name: Check if user is maintainer + if: ${{ github.event_name == 'workflow_dispatch' }} + uses: actions/github-script@v7 + with: + script: | + const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.actor + }); + + const allowedRoles = ['admin', 'maintain']; + if (!allowedRoles.includes(permission.permission)) { + throw new Error(`❌ User ${context.actor} does not have sufficient permissions. Required: ${allowedRoles.join(', ')}. Current: ${permission.permission}`); + } + + find-agents: + runs-on: ubuntu-latest + needs: [check-permissions] + outputs: + agents: ${{ steps.get-agents.outputs.agents }} + all_agents: ${{ steps.get-agents.outputs.all_agents }} + has_agents: ${{ steps.get-agents.outputs.has_agents }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch full history for git diff + + - name: Find tutorial agents to build + id: get-agents + env: + REBUILD_ALL: ${{ inputs.rebuild_all }} + run: | + # Find all tutorial directories with manifest.yaml + all_agents=$(find examples/tutorials -name "manifest.yaml" -exec dirname {} \; | sort) + agents_to_build=() + + # Output all agents for deprecation check + all_agents_json=$(printf '%s\n' $all_agents | jq -R -s -c 'split("\n") | map(select(length > 0))') + echo "all_agents=$all_agents_json" >> $GITHUB_OUTPUT + + if [ "$REBUILD_ALL" = "true" ]; then + echo "Rebuild all agents requested" + agents_to_build=($(echo "$all_agents")) + + echo "### 🔄 Rebuilding All Tutorial Agents" >> $GITHUB_STEP_SUMMARY + else + # Determine the base branch for comparison + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE_BRANCH="origin/${{ github.base_ref }}" + echo "Comparing against PR base branch: $BASE_BRANCH" + else + # For pushes to main, compare against the first parent (pre-merge state) + BASE_BRANCH="HEAD^1" + echo "Comparing against previous commit: $BASE_BRANCH" + fi + # Check each agent directory for changes + for agent_dir in $all_agents; do + echo "Checking $agent_dir for changes..." + + # Check if any files in this agent directory have changed + if git diff --name-only $BASE_BRANCH HEAD | grep -q "^$agent_dir/"; then + echo " ✅ Changes detected in $agent_dir" + agents_to_build+=("$agent_dir") + else + echo " ⏭️ No changes in $agent_dir - skipping build" + fi + done + + echo "### 🔄 Changed Tutorial Agents" >> $GITHUB_STEP_SUMMARY + fi + + # Convert array to JSON format and output summary + if [ ${#agents_to_build[@]} -eq 0 ]; then + echo "No agents to build" + echo "agents=[]" >> $GITHUB_OUTPUT + echo "has_agents=false" >> $GITHUB_OUTPUT + else + echo "Agents to build: ${#agents_to_build[@]}" + agents_json=$(printf '%s\n' "${agents_to_build[@]}" | jq -R -s -c 'split("\n") | map(select(length > 0))') + echo "agents=$agents_json" >> $GITHUB_OUTPUT + echo "has_agents=true" >> $GITHUB_OUTPUT + + echo "" >> $GITHUB_STEP_SUMMARY + for agent in "${agents_to_build[@]}"; do + echo "- \`$agent\`" >> $GITHUB_STEP_SUMMARY + done + echo "" >> $GITHUB_STEP_SUMMARY + fi + + build-agents: + needs: [find-agents] + if: ${{ needs.find-agents.outputs.has_agents == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + matrix: + agent_path: ${{ fromJson(needs.find-agents.outputs.agents) }} + fail-fast: false + + name: build-${{ matrix.agent_path }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.12" + + - name: Get latest agentex-sdk version from PyPI + id: get-version + run: | + LATEST_VERSION=$(curl -s https://pypi.org/pypi/agentex-sdk/json | jq -r '.info.version') + echo "Latest agentex-sdk version: $LATEST_VERSION" + echo "AGENTEX_SDK_VERSION=$LATEST_VERSION" >> $GITHUB_ENV + pip install agentex-sdk==$LATEST_VERSION + echo "Installed agentex-sdk version $LATEST_VERSION" + + - name: Generate Image name + id: image-name + run: | + # Remove examples/tutorials/ prefix and replace / with - + AGENT_NAME=$(echo "${{ matrix.agent_path }}" | sed 's|^examples/tutorials/||' | sed 's|/|-|g') + echo "AGENT_NAME=$AGENT_NAME" >> $GITHUB_ENV + echo "agent_name=$AGENT_NAME" >> $GITHUB_OUTPUT + echo "Agent name set to $AGENT_NAME" + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build Agent Image + env: + REGISTRY: ghcr.io + run: | + AGENT_NAME="${{ steps.image-name.outputs.agent_name }}" + REPOSITORY_NAME="${{ github.repository }}/tutorial-agents/${AGENT_NAME}" + + # Determine if we should publish based on event type. + # Publish path: push to an immutable candidate tag (the commit SHA) first, + # validate that exact pushed artifact, then promote :latest onto it. This + # keeps an unvalidated image off :latest — if validation fails, :latest is + # left pointing at the last known-good build, and only the SHA tag is dirty. + if [ "${{ github.event_name }}" = "push" ] || [ "${{ inputs.rebuild_all }}" = "true" ]; then + SHOULD_PUSH=true + PROMOTE_LATEST=true + VERSION_TAG="${{ github.sha }}" + echo "🚀 Building agent (push candidate ${VERSION_TAG}, promote :latest after validation): ${{ matrix.agent_path }}" + else + SHOULD_PUSH=false + PROMOTE_LATEST=false + VERSION_TAG="${{ github.sha }}" + echo "🔍 Building agent for validation: ${{ matrix.agent_path }}" + # Skip image validation for PRs since Buildx doesn't load multi-platform images locally + echo "SKIP_VALIDATION=true" >> $GITHUB_ENV + fi + + # Build the image. On the publish path, push straight from buildx to the + # candidate tag: a multi-platform build cannot be loaded into the local + # Docker store, so it must be pushed by the build itself rather than by a + # later `docker push` (which would have no fresh local image and would + # re-push a stale tag instead). + BUILD_ARGS="--manifest ${{ matrix.agent_path }}/manifest.yaml --registry ${REGISTRY} --tag ${VERSION_TAG} --platforms linux/amd64,linux/arm64 --repository-name ${REPOSITORY_NAME}" + if [ "$SHOULD_PUSH" = "true" ]; then + BUILD_ARGS="$BUILD_ARGS --push" + fi + + agentex agents build $BUILD_ARGS + echo "✅ Successfully built: ${REGISTRY}/${REPOSITORY_NAME}:${VERSION_TAG}" + + # Set environment variables for subsequent steps + echo "FULL_IMAGE=${REGISTRY}/${REPOSITORY_NAME}:${VERSION_TAG}" >> $GITHUB_ENV + echo "LATEST_IMAGE=${REGISTRY}/${REPOSITORY_NAME}:latest" >> $GITHUB_ENV + echo "SHOULD_PUSH=${SHOULD_PUSH}" >> $GITHUB_ENV + echo "PROMOTE_LATEST=${PROMOTE_LATEST}" >> $GITHUB_ENV + + - name: Validate agent image + if: env.SKIP_VALIDATION != 'true' + run: | + set -e + + FULL_IMAGE="${{ env.FULL_IMAGE }}" + AGENT_PATH="${{ matrix.agent_path }}" + AGENT_NAME="${{ env.AGENT_NAME }}" + + echo "🔍 Validating agent image: $FULL_IMAGE" + + # Determine ACP type from path + if [[ "$AGENT_PATH" == *"10_async"* ]]; then + ACP_TYPE="async" + else + ACP_TYPE="sync" + fi + + # Common environment variables for validation + ENV_VARS="-e ENVIRONMENT=development \ + -e AGENT_NAME=${AGENT_NAME} \ + -e ACP_URL=http://localhost:8000 \ + -e ACP_PORT=8000 \ + -e ACP_TYPE=${ACP_TYPE}" + + # 1. Validate ACP entry point exists and is importable + echo "📦 Checking ACP entry point..." + docker run --rm $ENV_VARS "$FULL_IMAGE" python -c "from project.acp import acp; print('✅ ACP entry point validated')" + + # 2. Check if tests/test_agent.py exists (required for integration tests) + # Tests are located at /app//tests/test_agent.py + echo "🧪 Checking for tests/test_agent.py..." + TEST_FILE=$(docker run --rm "$FULL_IMAGE" find /app -name "test_agent.py" -path "*/tests/*" 2>/dev/null | head -1) + + if [ -n "$TEST_FILE" ]; then + echo "✅ Found test file at: $TEST_FILE" + else + echo "❌ No tests/test_agent.py found in image - this is required for all tutorial agents" + echo " Please add a tests/test_agent.py file to your agent" + exit 1 + fi + + # 3. Validate container can start (may fail due to missing services, but should initialize) + echo "🏥 Validating container starts..." + CONTAINER_NAME="validate-agent-$$" + + # Start container in background with required env vars + docker run -d --name "$CONTAINER_NAME" \ + $ENV_VARS \ + -p 8000:8000 \ + "$FULL_IMAGE" + + # Give it a few seconds to attempt startup + sleep 5 + + # Check if container is still running (it may exit due to missing services, that's ok) + # We just want to see that it attempted to start properly + echo "📋 Container logs:" + docker logs "$CONTAINER_NAME" 2>&1 || true + + # Check for successful ACP initialization in logs + if docker logs "$CONTAINER_NAME" 2>&1 | grep -q "instance created "; then + echo "✅ Container initialized ACP successfully" + else + echo "⚠️ Could not verify ACP initialization from logs" + fi + + # Cleanup container + docker stop "$CONTAINER_NAME" > /dev/null 2>&1 || true + docker rm "$CONTAINER_NAME" > /dev/null 2>&1 || true + + echo "✅ All validations passed for: $FULL_IMAGE" + + - name: Promote validated image to :latest + if: env.PROMOTE_LATEST == 'true' + run: | + echo "🏷️ Promoting validated ${{ env.FULL_IMAGE }} -> ${{ env.LATEST_IMAGE }}" + # Registry-side manifest copy: no rebuild, preserves the multi-arch + # manifest list, and only runs after validation passed — so :latest never + # points at an unvalidated image. + docker buildx imagetools create --tag "${{ env.LATEST_IMAGE }}" "${{ env.FULL_IMAGE }}" + echo "✅ Promoted to ${{ env.LATEST_IMAGE }}" + + deprecate-agents: + name: "Deprecate Removed Agents" + runs-on: ubuntu-latest + needs: [find-agents] + steps: + - name: Find and delete deprecated agent packages + env: + GITHUB_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + run: | + set -e + + echo "🔍 Agents in repo (from find-agents):" + # Convert JSON array of paths to package names + # e.g., "examples/tutorials/00_sync/000_hello_acp" -> "00_sync-000_hello_acp" + REPO_AGENTS=$(echo '${{ needs.find-agents.outputs.all_agents }}' | jq -r '.[]' | \ + sed 's|examples/tutorials/||' | \ + sed 's|/|-|g') + echo "$REPO_AGENTS" + + echo "" + echo "🔍 Fetching packages from GitHub Container Registry..." + PACKAGES=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/orgs/scaleapi/packages?package_type=container&per_page=100") + + # Check for API errors + if echo "$PACKAGES" | jq -e '.message' > /dev/null 2>&1; then + echo "❌ GitHub API error:" + echo "$PACKAGES" | jq '.' + exit 1 + fi + + # Filter for tutorial-agents from this repo + TUTORIAL_PACKAGES=$(echo "$PACKAGES" | \ + jq -r '.[] | select(.repository != null and .repository.name == "scale-agentex-python" and (.name | contains("tutorial-agents"))) | .name') + + echo "Tutorial packages in registry:" + echo "$TUTORIAL_PACKAGES" + + echo "" + echo "🔍 Checking for deprecated packages..." + while IFS= read -r package_name; do + [ -z "$package_name" ] && continue + + # Extract agent name: scale-agentex-python/tutorial-agents/00_sync-000_hello_acp -> 00_sync-000_hello_acp + agent_name=$(echo "$package_name" | sed 's|.*/tutorial-agents/||') + + if ! echo "$REPO_AGENTS" | grep -q "^${agent_name}$"; then + echo "🗑️ $agent_name - NOT in repo, deleting..." + # URL encode the package name (replace / with %2F) + encoded_package=$(echo "$package_name" | sed 's|/|%2F|g') + response=$(curl -s -w "\n%{http_code}" -X DELETE \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/orgs/scaleapi/packages/container/${encoded_package}") + + http_code=$(echo "$response" | tail -n1) + body=$(echo "$response" | sed '$d') + + if [ "$http_code" = "204" ] || [ "$http_code" = "200" ]; then + echo " ✅ Deleted: $package_name" + else + echo " ⚠️ Failed to delete $package_name (HTTP $http_code): $body" + fi + fi + done <<< "$TUTORIAL_PACKAGES" + + echo "" + echo "✅ Deprecation check complete" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..29e05db55 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,104 @@ +name: CI +on: + push: + branches: + - '**' + - '!integrated/**' + - '!stl-preview-head/**' + - '!stl-preview-base/**' + - '!generated' + - '!codegen/**' + - 'codegen/stl/**' + pull_request: + branches-ignore: + - 'stl-preview-head/**' + - 'stl-preview-base/**' + +jobs: + lint: + timeout-minutes: 10 + name: lint + runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install uv + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 + with: + version: '0.10.2' + + - name: Install dependencies + run: uv sync --all-packages --all-extras + + - name: Run lints + run: ./scripts/lint + + - name: Check slim dependency set + run: ./scripts/check-slim-deps + + build: + if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') + timeout-minutes: 10 + name: build + permissions: + contents: read + id-token: write + runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install uv + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 + with: + version: '0.10.2' + + - name: Install dependencies + run: uv sync --all-packages --all-extras + + - name: Run build + # Both workspace members. --wheel is load-bearing: the heavy's cross-dir + # force-include can't build via the sdist-then-wheel default. + run: uv build --all-packages --wheel + + - name: Smoke-test wheel install + # Both wheels must install together into one working agentex.* namespace. + run: ./scripts/check-wheel-install + + - name: Get GitHub OIDC Token + if: |- + github.repository == 'stainless-sdks/agentex-sdk-python' && + !startsWith(github.ref, 'refs/heads/stl/') + id: github-oidc + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: core.setOutput('github_token', await core.getIDToken()); + + - name: Upload tarball + if: |- + github.repository == 'stainless-sdks/agentex-sdk-python' && + !startsWith(github.ref, 'refs/heads/stl/') + env: + URL: https://pkg.stainless.com/s + AUTH: ${{ steps.github-oidc.outputs.github_token }} + SHA: ${{ github.sha }} + run: ./scripts/utils/upload-artifact.sh + + test: + timeout-minutes: 10 + name: test + runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install uv + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 + with: + version: '0.10.2' + + - name: Bootstrap + run: ./scripts/bootstrap + + - name: Run tests + run: ./scripts/test diff --git a/.github/workflows/harness-integration.yml b/.github/workflows/harness-integration.yml new file mode 100644 index 000000000..819006a50 --- /dev/null +++ b/.github/workflows/harness-integration.yml @@ -0,0 +1,69 @@ +name: Harness Integration + +on: + push: + branches: [main] + pull_request: + paths: + - "src/agentex/lib/core/harness/**" + - "src/agentex/lib/adk/_modules/**" + - "tests/lib/core/harness/test_harness_*.py" + - ".github/workflows/harness-integration.yml" + +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 + + - name: Install uv + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 + with: + version: '0.10.2' + + - name: Bootstrap + run: ./scripts/bootstrap + + # Defer to scripts/test so the harness suite runs under the exact same + # invocation as the main CI test job: DEFER_PYDANTIC_BUILD=false and + # `uv run --isolated --all-packages --all-extras pytest`, across the + # min/max supported Python versions. Running `uv run pytest` directly + # would risk an all-extras-only dep passing locally but failing in CI. + - name: Conformance suite + run: ./scripts/test tests/lib/core/harness/ -v + + # Offline harness integration tests (sync / async / temporal channels) for each + # harness. These use fake streams / TestModel + fake streaming/tracing and + # require no live infrastructure. All five harnesses are now covered; the + # 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: + harness: [pydantic_ai, langgraph, openai, claude_code, codex] + channel: [sync, async, temporal] + fail-fast: false + name: ${{ matrix.harness }}-${{ matrix.channel }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install uv + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 + with: + version: '0.10.2' + + - name: Bootstrap + run: ./scripts/bootstrap + + - name: ${{ matrix.harness }} ${{ matrix.channel }} integration tests (offline) + run: | + ./scripts/test tests/lib/core/harness/test_harness_${{ matrix.harness }}_${{ matrix.channel }}.py -v diff --git a/.github/workflows/lint-pr.yaml b/.github/workflows/lint-pr.yaml new file mode 100644 index 000000000..dc165a271 --- /dev/null +++ b/.github/workflows/lint-pr.yaml @@ -0,0 +1,144 @@ +name: Lint PR + +on: + pull_request: + types: + - opened + - edited + - synchronize + - reopened + - labeled + - unlabeled + +jobs: + validate-pr-title: + name: Validate PR title (Conventional Commits) + runs-on: ubuntu-latest + steps: + - name: Check Conventional Commits format + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + run: | + # Exempt automated PRs (Stainless codegen, release-please, dependabot, etc.). + # 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. + case "$PR_AUTHOR" in + stainless-app|stainless-app\[bot\]|release-please\[bot\]|github-actions\[bot\]|dependabot\[bot\]) + echo "PR is from automation ($PR_AUTHOR); skipping title check." + exit 0 + ;; + esac + + # Conventional Commits: ()(!): + PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|build|perf|revert)(\([^)]+\))?!?: .+' + + if printf '%s' "$PR_TITLE" | grep -qE "$PATTERN"; then + echo "PR title is a valid Conventional Commit: $PR_TITLE" + exit 0 + fi + + # ::error must be on stdout for GitHub Actions to surface it as an annotation. + echo "::error title=Invalid PR title::PR title must follow Conventional Commits format. Got: $PR_TITLE" + { + echo " Got: $PR_TITLE" + echo " Expected: ()(!): " + echo " Types: feat, fix, docs, style, refactor, test, chore, ci, build, perf, revert" + echo "" + echo " Examples:" + echo " feat: add new endpoint" + echo " fix(client): handle empty response" + echo " chore!: drop python 3.11 support" + } >&2 + exit 1 + + validate-pr-base: + name: Validate PR base branch + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Validate base branch and manage PR comment + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_BASE: ${{ github.event.pull_request.base.ref }} + HAS_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'target-main') }} + run: | + MARKER='' + + # Look up an existing marker comment so we can update/delete it. + # --paginate handles PRs with >30 comments. If the lookup fails + # (transient API error, fork PR token without read scope), continue + # with no existing_id so we still emit the failure annotation. + existing_id=$(gh api --paginate "repos/$REPO/issues/$PR_NUMBER/comments" \ + --jq ".[] | select(.body | contains(\"$MARKER\")) | .id" 2>/dev/null \ + | head -n1) || existing_id="" + + delete_comment() { + if [ -n "$existing_id" ]; then + gh api -X DELETE "repos/$REPO/issues/comments/$existing_id" >/dev/null 2>&1 || true + fi + } + + # PR doesn't target main — nothing to enforce. + if [ "$PR_BASE" != "main" ]; then + delete_comment + echo "PR base is '$PR_BASE'; check passes." + exit 0 + fi + + # 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\]) + delete_comment + echo "PR is from automation ($PR_AUTHOR); allowing PR targeting main." + exit 0 + ;; + esac + + # Per-PR opt-out via label. + if [ "$HAS_LABEL" = "true" ]; then + delete_comment + echo "Found 'target-main' label; allowing PR targeting main." + exit 0 + fi + + # Failure path: try to post or update an explanatory comment. + # The write may fail on fork PRs (GITHUB_TOKEN has read-only scope + # upstream) or due to transient API errors. Guard each gh call so + # the ::error annotation and exit 1 still run regardless. + body_file=$(mktemp) + { + echo "$MARKER" + echo + echo "**This PR is targeting \`main\`, but PRs should target the \`next\` branch by default.**" + echo + echo "The \`main\` branch is reserved for release-please and Stainless automation. To resolve, pick one of:" + echo + echo "- **Re-target the PR to \`next\`** (recommended). On the PR page, click **Edit** next to the title and change the base branch to \`next\`." + echo "- **Add the \`target-main\` label** if this is an intentional exception (e.g. an urgent hotfix). The check will re-run and pass." + echo + echo "See \`CONTRIBUTING.md\` for the full branch model." + } > "$body_file" + + comment_status="ok" + if [ -n "$existing_id" ]; then + gh api -X PATCH "repos/$REPO/issues/comments/$existing_id" \ + -F body=@"$body_file" >/dev/null 2>&1 || comment_status="failed" + [ "$comment_status" = "ok" ] && echo "Updated existing PR comment ($existing_id)." + else + gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file "$body_file" >/dev/null 2>&1 || comment_status="failed" + [ "$comment_status" = "ok" ] && echo "Posted new PR comment." + fi + + if [ "$comment_status" = "failed" ]; then + echo "::warning title=Could not write PR comment::Likely a fork PR (no upstream write scope) or a transient API error. The check still fails — see the next annotation for resolution steps." + fi + + # ::error must be on stdout to surface as an annotation. + echo "::error title=PR should target 'next'::Re-target to 'next' or add the 'target-main' label. See the PR comment for full details." + exit 1 diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 000000000..23c5f58fd --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,51 @@ +# This workflow is triggered when a GitHub release is created. +# It can also be run manually to re-publish to PyPI in case it failed for some reason. +# You can run this workflow by navigating to https://www.github.com/scaleapi/scale-agentex-python/actions/workflows/publish-pypi.yml +name: Publish PyPI +on: + workflow_dispatch: + inputs: + package: + description: Package to publish + required: true + default: all + type: choice + options: + - all + - agentex-client + - agentex-sdk + + release: + types: [published] + +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 + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install uv + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 + with: + version: '0.10.2' + + - name: Publish to PyPI + run: | + bash ./bin/publish-pypi + env: + # Heavy `agentex-sdk` package token (existing PyPI name). + AGENTEX_PYPI_TOKEN: ${{ secrets.AGENTEX_PYPI_TOKEN }} + # Slim `agentex-client` package token (new PyPI name; needs + # to be added to repo secrets when the slim is registered). + AGENTEX_CLIENT_PYPI_TOKEN: ${{ secrets.AGENTEX_CLIENT_PYPI_TOKEN }} + # Back-compat fallback — used by bin/publish-pypi when the + # dedicated tokens above are unset. + PYPI_TOKEN: ${{ secrets.AGENTEX_PYPI_TOKEN || secrets.PYPI_TOKEN }} + # Manual dispatches can override tag-derived package selection. + PYPI_PACKAGE: ${{ inputs.package }} diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml new file mode 100644 index 000000000..a20022ce7 --- /dev/null +++ b/.github/workflows/release-doctor.yml @@ -0,0 +1,21 @@ +name: Release Doctor +on: + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + release_doctor: + name: release doctor + runs-on: ubuntu-latest + if: github.repository == 'scaleapi/scale-agentex-python' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check release environment + run: | + bash ./bin/check-release-environment + env: + PYPI_TOKEN: ${{ secrets.AGENTEX_PYPI_TOKEN || secrets.PYPI_TOKEN }} diff --git a/.gitignore b/.gitignore index 94993c3ab..c437a2077 100644 --- a/.gitignore +++ b/.gitignore @@ -1,173 +1,85 @@ -# Logs -logs -*.log -npm-debug.log* -*.pth +.prism.log +.stdy.log +_dev -# Runtime data -pids -*.pid -*.seed -*.pid.lock +__pycache__ +.mypy_cache -# IntelliJ -**/.idea -*.iml +dist -# VSCode -.vscode -*.code-workspace +.venv +.idea + +.env +.envrc +codegen.log +Brewfile.lock.json -# filesystem files .DS_Store -# Local environment files -*.env -.env.* -*.envrc -frontend/.npmrc -local*.yaml +# Claude workspace directories +.claude-workspace/ -# filesystem databases -dump.rdb -*.sqlite -*.db +# Claude Code local scheduled-task lock +.claude/scheduled_tasks.lock -# Temp dirs -tmp +# --------------------------------------------------------------------------- +# Local additions (not Stainless-generated) +# --------------------------------------------------------------------------- -### PYTHON +# Logs +*.log -# Byte-compiled / optimized / DLL files -__pycache__/ +# Python build & packaging artifacts *.py[cod] *$py.class - -# C extensions *.so - -# Distribution / packaging -.Python build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ sdist/ -var/ wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg *.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt +*.egg-info/ +.eggs/ -# Unit test / coverage reports -htmlcov/ -.tox/ +# Test, lint & type-check caches +.pytest_cache/ +.ruff_cache/ .nox/ +.tox/ +.hypothesis/ .coverage .coverage.* -.cache -nosetests.xml coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints -_temp_extension +htmlcov/ junit.xml -[uU]ntitled* -notebook/static/* -!notebook/static/favicons -notebook/labextension -notebook/schemas -docs/source/changelog.md -docs/source/contributing.md - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# pdm -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Environments -.env -.venv -env/ +.dmypy.json +dmypy.json + +# Virtual environments (uv/rye/venv) +.venv*/ venv/ +env/ ENV/ -env.bak/ -venv.bak/ -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json +# Jupyter (examples/ tutorials & demos) +.ipynb_checkpoints/ -# Pyre type checker -.pyre/ +# Local env files -- keep the CLI template examples tracked +.env.* +!.env.example +!.env.example.* +!.env.template -# pytype static type analyzer -.pytype/ +# Local databases +*.db +*.sqlite +*.sqlite3 + +# Editors +**/.idea +*.iml +*.code-workspace +*.sw[op] -# Cython debug symbols -cython_debug/ +# Claude Code local overrides +.claude/settings.local.json diff --git a/.python-version b/.python-version new file mode 100644 index 000000000..e4fba2183 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 000000000..95c44cfb4 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,4 @@ +{ + ".": "0.26.0", + "adk": "0.26.0" +} diff --git a/.stats.yml b/.stats.yml new file mode 100644 index 000000000..955f7e2ac --- /dev/null +++ b/.stats.yml @@ -0,0 +1,4 @@ +configured_endpoints: 75 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-ee0c521f0612c31b874bd595b90cd9209545bab603983552a1a7a87f38ed931e.yml +openapi_spec_hash: 917a1ffe9e353bed2740524dec786ed2 +config_hash: 593e89b291976a5e84e4c3c3f8324354 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..2d735cafe --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,39 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to AgentEx Worker", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5678 + }, + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "." + } + ], + "justMyCode": false, + "console": "integratedTerminal" + }, + { + "name": "Attach to AgentEx Worker (Port 5679)", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5679 + }, + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "." + } + ], + "justMyCode": false, + "console": "integratedTerminal" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..5b0103078 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.analysis.importFormat": "relative", +} diff --git a/Brewfile b/Brewfile new file mode 100644 index 000000000..c43041cef --- /dev/null +++ b/Brewfile @@ -0,0 +1,2 @@ +brew "uv" + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..f8ffe61e7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1349 @@ +# Changelog + +## Unreleased + +### ⚠ BREAKING CHANGES + +* **harness:** removed the deprecated bespoke LangGraph tracing handler `create_langgraph_tracing_handler` (and its `AgentexLangGraphTracingHandler` class) from the public `agentex.lib.adk` surface. Span tracing is now derived from the canonical `StreamTaskMessage*` stream by `UnifiedEmitter` — wrap your run in the harness `*Turn` and drive `UnifiedEmitter.yield_turn` / `auto_send_turn`. The `agentex init` templates were migrated accordingly. +* **harness:** removed the deprecated bespoke Pydantic-AI tracing handler `create_pydantic_ai_tracing_handler` (and its `AgentexPydanticAITracingHandler` class) from the public `agentex.lib.adk` surface. Span tracing is now derived from the canonical `StreamTaskMessage*` stream by `UnifiedEmitter` — wrap your run in `PydanticAITurn` and drive `UnifiedEmitter.yield_turn` / `auto_send_turn`. The `agentex init` templates were migrated accordingly. +* **harness:** each harness now exposes exactly `__sync.py` + `__turn.py` under `agentex.lib.adk._modules`. The OpenAI harness `OpenAITurn` and `convert_openai_to_agentex_events` moved to `agentex.lib.adk._modules._openai_turn` / `_openai_sync`; back-compat shims remain at `agentex.lib.adk.providers._modules.{openai_turn,sync_provider}` for one release. Public facade names (`stream_pydantic_ai_events`, `stream_langgraph_events`, `emit_langgraph_messages`, etc.) are unchanged. + +### Features + +* **tracing:** emit OTel metrics for async span queue depth, batch drain, and SGP export success/failure (HTTP status labels). Disable SDK-side recording with ``AGENTEX_TRACING_METRICS=0``. + +## 0.26.0 (2026-09-14) + +Full Changelog: [agentex-client-v0.25.0...agentex-client-v0.26.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.25.0...agentex-client-v0.26.0) + +### Features + +* **agent-card:** add metadata field and expose list filter ([#502](https://github.com/scaleapi/scale-agentex-python/issues/502)) ([55a0178](https://github.com/scaleapi/scale-agentex-python/commit/55a0178c2d210325770b56dbb58eac2f644cc14b)) +* **api:** add agent_card_metadata parameter to agents list method ([ebb632a](https://github.com/scaleapi/scale-agentex-python/commit/ebb632a4b22287e157bcf086dd98b27f26188fc5)) +* **tracing:** add opt-in commit SHA stamping for SGP spans ([#505](https://github.com/scaleapi/scale-agentex-python/issues/505)) ([76252a9](https://github.com/scaleapi/scale-agentex-python/commit/76252a98f28663e8c95777456d07e42171592c62)) + +### Bug Fixes + +* keep agent output streaming alive on an unreadable line, and honor LOG_LEVEL ([#509](https://github.com/scaleapi/scale-agentex-python/issues/509)) ([0db6037](https://github.com/scaleapi/scale-agentex-python/commit/0db6037e63ca1b24b80ae0d38883f7687ae5b9e5)) + +## 0.25.0 (2026-08-26) + +Full Changelog: [agentex-client-v0.24.0...agentex-client-v0.25.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.24.0...agentex-client-v0.25.0) + +### Features + +* **lineage:** capture tool data-source refs and agent build version in span data ([#469](https://github.com/scaleapi/scale-agentex-python/issues/469)) ([941bbc4](https://github.com/scaleapi/scale-agentex-python/commit/941bbc47f3a8fefb1b577ab34f1358be9b3569c4)) +* **worker:** add OTLP metrics exporter options (headers, HTTP transport, delta temporality) ([#501](https://github.com/scaleapi/scale-agentex-python/issues/501)) ([3dca81f](https://github.com/scaleapi/scale-agentex-python/commit/3dca81fa91cfd848bff4344bc4e20470b07cda9d)) + +## 0.24.0 (2026-08-11) + +Full Changelog: [agentex-client-v0.23.0...agentex-client-v0.24.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.23.0...agentex-client-v0.24.0) + +### Features + +* **tracing:** per-step obs wrappers inside business Temporal activities ([#491](https://github.com/scaleapi/scale-agentex-python/issues/491)) ([5c7ee10](https://github.com/scaleapi/scale-agentex-python/commit/5c7ee100cbfc87b4a253b577faca1dbd7af8e686)) + + +### Bug Fixes + +* **tracing:** continue inbound W3C trace context at the ACP boundary ([#490](https://github.com/scaleapi/scale-agentex-python/issues/490)) ([152c163](https://github.com/scaleapi/scale-agentex-python/commit/152c163aa3a01d9c33031d2cb1134b9787bcde89)) + +## 0.23.0 (2026-08-07) + +Full Changelog: [agentex-client-v0.22.2...agentex-client-v0.23.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.22.2...agentex-client-v0.23.0) + +### Features + +* propagate error categories to SGP spans ([#486](https://github.com/scaleapi/scale-agentex-python/issues/486)) ([f2b1808](https://github.com/scaleapi/scale-agentex-python/commit/f2b18087c5049f7e2a159f8e1ad3bf069b01eb23)) +* **tracing:** correlate business spans with obs via dedicated wrapper span ([#484](https://github.com/scaleapi/scale-agentex-python/issues/484)) ([72732b7](https://github.com/scaleapi/scale-agentex-python/commit/72732b7c07700df840a2308424154f11a30e39f2)) +* **tracing:** propagate OTel trace context across Temporal boundaries ([#485](https://github.com/scaleapi/scale-agentex-python/issues/485)) ([da7ea15](https://github.com/scaleapi/scale-agentex-python/commit/da7ea1558683da05a3f9ecb119b91bf873437be1)) + + +### Bug Fixes + +* **task-create:** handle WorkflowAlreadyStartedError gracefully ([#489](https://github.com/scaleapi/scale-agentex-python/issues/489)) ([462195d](https://github.com/scaleapi/scale-agentex-python/commit/462195d11c48713f1b15487adad45776043a09fd)) + +## 0.22.2 (2026-07-30) + +Full Changelog: [agentex-client-v0.22.1...agentex-client-v0.22.2](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.22.1...agentex-client-v0.22.2) + +### Bug Fixes + +* **tutorials:** add pytest-asyncio to async tutorial dev deps ([#481](https://github.com/scaleapi/scale-agentex-python/issues/481)) ([8ae3ee5](https://github.com/scaleapi/scale-agentex-python/commit/8ae3ee5b554f7b47e7c3b0dbd0e9c939009c91e1)) + +## 0.22.1 (2026-07-29) + +Full Changelog: [agentex-client-v0.22.0...agentex-client-v0.22.1](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.22.0...agentex-client-v0.22.1) + +### Bug Fixes + +* **ci:** push tutorial images from buildx instead of re-pushing a stale local tag ([#478](https://github.com/scaleapi/scale-agentex-python/issues/478)) ([a7c8f5a](https://github.com/scaleapi/scale-agentex-python/commit/a7c8f5af8e0acf6ba2eab82f3e9c700c40ec4265)) + +## 0.22.0 (2026-07-29) + +Full Changelog: [agentex-client-v0.21.0...agentex-client-v0.22.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.21.0...agentex-client-v0.22.0) + +### Features + +* **lib:** capture client-attested build provenance ([#454](https://github.com/scaleapi/scale-agentex-python/issues/454)) ([8964044](https://github.com/scaleapi/scale-agentex-python/commit/896404475a1e93955eabd562caa1670364335c29)) + + +### Bug Fixes + +* **lib:** default 'agentex agents build' to --no-cache so stale layers can't ship stale source ([#476](https://github.com/scaleapi/scale-agentex-python/issues/476)) ([632d82c](https://github.com/scaleapi/scale-agentex-python/commit/632d82c2e4eeb1f7113b575b8666ffd53a1ab2eb)) + + +### Refactors + +* **lib:** remove the dead build-info.json registration read-path ([#455](https://github.com/scaleapi/scale-agentex-python/issues/455)) ([2078f9f](https://github.com/scaleapi/scale-agentex-python/commit/2078f9fabb3099fa2d5d02f67ed5af50b8efbc09)) + +## 0.21.0 (2026-07-28) + +Full Changelog: [agentex-client-v0.20.0...agentex-client-v0.21.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.20.0...agentex-client-v0.21.0) + +### Features + +* **api:** add include_live parameter to schedules list method ([199fd6a](https://github.com/scaleapi/scale-agentex-python/commit/199fd6a633bc82626a4629ee1ac52a40e6f10271)) +* **codex:** republish todo_list revisions as they are ticked off ([#473](https://github.com/scaleapi/scale-agentex-python/issues/473)) ([0252fcc](https://github.com/scaleapi/scale-agentex-python/commit/0252fcc1d00c96066e4500d09b07a6b0da836437)) +* **stlc:** configurable CI runner and private-production-repo support in workflow templates ([23c1b6b](https://github.com/scaleapi/scale-agentex-python/commit/23c1b6b8dc60f16c5e19a1aab68d244d286caffc)) + + +### Bug Fixes + +* **api:** remove params field from task list response ([152bc75](https://github.com/scaleapi/scale-agentex-python/commit/152bc75b242c2ff4179e0cfac0f33edbc6bcae38)) + +## 0.20.0 (2026-07-16) + +Full Changelog: [agentex-client-v0.19.0...agentex-client-v0.20.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.19.0...agentex-client-v0.20.0) + +### Features + +* **api:** add interrupt task ([936a2b1](https://github.com/scaleapi/scale-agentex-python/commit/936a2b1d28a5409bd7548d59b0f18491495c805b)) +* **api:** add task/interrupt method and INTERRUPTED status to agents/tasks ([4f1c093](https://github.com/scaleapi/scale-agentex-python/commit/4f1c09348c2ddffeb8c78738fb9b7aa14ed4b752)) +* **interrupt:** task/interrupt hook + protocol + resume-safe session capture (AGX1-391) ([#462](https://github.com/scaleapi/scale-agentex-python/issues/462)) ([eaa3dd5](https://github.com/scaleapi/scale-agentex-python/commit/eaa3dd526b88c454fc604d0e617f7191b66952bb)) + +## 0.19.0 (2026-07-14) + +Full Changelog: [agentex-client-v0.18.0...agentex-client-v0.19.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.18.0...agentex-client-v0.19.0) + +### Features + +* **tracing:** emit token usage on spans for SGP billing ([#458](https://github.com/scaleapi/scale-agentex-python/issues/458)) ([7d19ada](https://github.com/scaleapi/scale-agentex-python/commit/7d19ada2db5d1eca5268a10fe04dfc85a367cf7f)) + + +### Bug Fixes + +* **internal:** resolve build failures ([9245b70](https://github.com/scaleapi/scale-agentex-python/commit/9245b700ceee95be9a0c478e518d9c06228d4b9f)) +* **tracing:** capture span body exceptions and export SGP status=ERROR ([#460](https://github.com/scaleapi/scale-agentex-python/issues/460)) ([6c23d76](https://github.com/scaleapi/scale-agentex-python/commit/6c23d7625ccf58ac9793dcf5219e4f7f4de38353)) + +## 0.18.0 (2026-07-10) + +Full Changelog: [agentex-client-v0.17.0...agentex-client-v0.18.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.17.0...agentex-client-v0.18.0) + +### Features + +* **api:** add schedule resume ([56f41aa](https://github.com/scaleapi/scale-agentex-python/commit/56f41aa78a79ba7ff75acb909371354f6732a299)) +* **api:** add skipped_action_times field to agents schedule responses ([de49d43](https://github.com/scaleapi/scale-agentex-python/commit/de49d43d6dd4fd12896c519ebd745c87f224f596)) +* **api:** add webhook endpoint ([f1c1252](https://github.com/scaleapi/scale-agentex-python/commit/f1c1252edea74f7cb84deb11d5e915a1e5506ea6)) +* **api:** manual updates ([e855070](https://github.com/scaleapi/scale-agentex-python/commit/e855070cc8dcb4f5ffae96f55ba8862ac890dafc)) +* **api:** manual updates ([e3c8baf](https://github.com/scaleapi/scale-agentex-python/commit/e3c8baf19509319e9d5b545d95574cf92f24e63c)) +* **api:** remove retrieve/delete/pause/trigger/unpause, update create/list in schedules ([8f084b6](https://github.com/scaleapi/scale-agentex-python/commit/8f084b6080cb2492ea8d18f4209547be0c057437)) +* **api:** update schedule configs ([c1e7db8](https://github.com/scaleapi/scale-agentex-python/commit/c1e7db875930c532e61a8ab72ec3b62473caae3a)) +* Use stable handles for run schedules ([9145865](https://github.com/scaleapi/scale-agentex-python/commit/91458652755536383693466c1b63a357bf610099)) + + +### Bug Fixes + +* cap openai <2.45 for openai-agents 0.14.x compatibility ([#459](https://github.com/scaleapi/scale-agentex-python/issues/459)) ([14c124d](https://github.com/scaleapi/scale-agentex-python/commit/14c124d363ed964ed8c08e10a95ca3939095ea92)) + + +### Chores + +* **internal:** version bump ([7aeb893](https://github.com/scaleapi/scale-agentex-python/commit/7aeb8937bb794586f7d5931bdc5964d007762b4c)) +* **internal:** version bump ([fcddeea](https://github.com/scaleapi/scale-agentex-python/commit/fcddeea8ef4bdff0a5f7735156c3003166464eac)) +* **internal:** version bump ([0793543](https://github.com/scaleapi/scale-agentex-python/commit/079354303393c28c5087ce3907d4b5b4a64ee1c0)) + +## 0.17.0 (2026-07-01) + +Full Changelog: [agentex-client-v0.16.2...agentex-client-v0.17.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.16.2...agentex-client-v0.17.0) + +### Features + +* **temporal:** opt-in continue-as-new for long-lived agent workflows ([#447](https://github.com/scaleapi/scale-agentex-python/issues/447)) ([98cf744](https://github.com/scaleapi/scale-agentex-python/commit/98cf7444002b5f9862f3a922665f016ae6c89af0)) + +## 0.16.2 (2026-06-29) + +Full Changelog: [agentex-client-v0.16.1...agentex-client-v0.16.2](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.16.1...agentex-client-v0.16.2) + +### Bug Fixes + +* **adk:** release streaming buffer repair in sdk ([#449](https://github.com/scaleapi/scale-agentex-python/issues/449)) ([20795cb](https://github.com/scaleapi/scale-agentex-python/commit/20795cb158244767207b6d3758929014bc015bb6)) + +## 0.16.1 (2026-06-26) + +Full Changelog: [agentex-client-v0.16.0...agentex-client-v0.16.1](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.16.0...agentex-client-v0.16.1) + +### Bug Fixes + +* **streaming:** StreamTaskMessageFull closes the coalescing buffer ([#426](https://github.com/scaleapi/scale-agentex-python/issues/426)) ([94ce668](https://github.com/scaleapi/scale-agentex-python/commit/94ce6687a86ecac8ee1a6ee1b3448f463e3b0e83)) + +## 0.16.0 (2026-06-24) + +Full Changelog: [agentex-client-v0.15.0...agentex-client-v0.16.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.15.0...agentex-client-v0.16.0) + +### ⚠ BREAKING CHANGES + +* **harness:** consolidate the Pydantic-AI harness + remove tracing handler ([#431](https://github.com/scaleapi/scale-agentex-python/issues/431)) +* **harness:** consolidate the LangGraph harness + remove tracing handler ([#430](https://github.com/scaleapi/scale-agentex-python/issues/430)) + +### Features + +* **cli:** add claude-code init templates (sync / async / temporal) ([#435](https://github.com/scaleapi/scale-agentex-python/issues/435)) ([fd9bc4a](https://github.com/scaleapi/scale-agentex-python/commit/fd9bc4a81417b9d75ad692b779293720f8435d37)) +* **cli:** add codex init templates (sync / async / temporal) ([#436](https://github.com/scaleapi/scale-agentex-python/issues/436)) ([0fadfd7](https://github.com/scaleapi/scale-agentex-python/commit/0fadfd7a113536d49a99894a3b80ed0915a0e0fb)) +* **cli:** add default-openai-agents init template (async base) ([#434](https://github.com/scaleapi/scale-agentex-python/issues/434)) ([624e9c8](https://github.com/scaleapi/scale-agentex-python/commit/624e9c8f3b4c4288a7037bc83651970cfb02e6b0)) +* **openai-agents:** single-emit + input-bearing tool spans + run_turn ([#445](https://github.com/scaleapi/scale-agentex-python/issues/445)) ([53ab8ef](https://github.com/scaleapi/scale-agentex-python/commit/53ab8efaaf65590e71abe07149582ea59814921b)) +* **openai-temporal:** render hosted/server-side tool calls in TemporalStreamingModel ([#442](https://github.com/scaleapi/scale-agentex-python/issues/442)) ([5dce9f0](https://github.com/scaleapi/scale-agentex-python/commit/5dce9f097723d3436a0e40277139e7cce68580ef)) + + +### Bug Fixes + +* **cli:** harden init templates per Greptile feedback (suite-wide) ([#444](https://github.com/scaleapi/scale-agentex-python/issues/444)) ([2d85eb0](https://github.com/scaleapi/scale-agentex-python/commit/2d85eb0952f2298e6c412ab44b9c59255431cb84)) +* **harness:** harden Claude Code + OpenAI taps and span tracing ([#446](https://github.com/scaleapi/scale-agentex-python/issues/446)) ([5b4359d](https://github.com/scaleapi/scale-agentex-python/commit/5b4359dcf28f390f780215ed954fa52e8cb4dd7c)) + + +### Refactors + +* **harness:** consolidate the LangGraph harness + remove tracing handler ([#430](https://github.com/scaleapi/scale-agentex-python/issues/430)) ([a3fb5ad](https://github.com/scaleapi/scale-agentex-python/commit/a3fb5ad51f6392a48cbb8324f15c9619f10244b6)) +* **harness:** consolidate the Pydantic-AI harness + remove tracing handler ([#431](https://github.com/scaleapi/scale-agentex-python/issues/431)) ([48c3da8](https://github.com/scaleapi/scale-agentex-python/commit/48c3da8777ae20a9ca6d544238dccd64d6c62c2b)) +* **harness:** move OpenAI harness into adk/_modules + facade export ([#432](https://github.com/scaleapi/scale-agentex-python/issues/432)) ([58bdb16](https://github.com/scaleapi/scale-agentex-python/commit/58bdb16b4b18db22188a29d5d1b31759f9d0dd4e)) + +## 0.15.0 (2026-06-23) + +Full Changelog: [agentex-client-v0.14.0...agentex-client-v0.15.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.14.0...agentex-client-v0.15.0) + +### Features + +* **api:** add webhook endpoint ([37c7d9d](https://github.com/scaleapi/scale-agentex-python/commit/37c7d9d465943184ab84922ba1079b939516d534)) +* **claude-code:** stream-json parser tap for the unified harness surface ([#420](https://github.com/scaleapi/scale-agentex-python/issues/420)) ([904339c](https://github.com/scaleapi/scale-agentex-python/commit/904339c21b8cd641a02d903c03d4a8730b4d7e84)) +* **codex:** event-stream parser tap for the unified harness surface ([#421](https://github.com/scaleapi/scale-agentex-python/issues/421)) ([9b2b031](https://github.com/scaleapi/scale-agentex-python/commit/9b2b03144cc67bb497e0a301686207aba2629758)) +* **harness:** public adk facade + docs for the unified harness surface (PR 9) ([#423](https://github.com/scaleapi/scale-agentex-python/issues/423)) ([fa60632](https://github.com/scaleapi/scale-agentex-python/commit/fa60632f9be84315a3fdc627745ae5b605994bd8)) +* **harness:** unified harness surface — foundation (span derivation, delivery adapters, emitter) ([#412](https://github.com/scaleapi/scale-agentex-python/issues/412)) ([a9cacf4](https://github.com/scaleapi/scale-agentex-python/commit/a9cacf4eb71697351ee658a570636f04bbf31ad5)) +* **langgraph:** migrate LangGraph harness onto unified surface ([#417](https://github.com/scaleapi/scale-agentex-python/issues/417)) ([d344228](https://github.com/scaleapi/scale-agentex-python/commit/d34422845de4b80ed69d2dccfdb0c680ef2fbca3)) +* **openai-agents:** migrate onto the unified harness surface ([#416](https://github.com/scaleapi/scale-agentex-python/issues/416)) ([d10e151](https://github.com/scaleapi/scale-agentex-python/commit/d10e1510bd5da44ad5acc5cac638750122083fce)) +* **pydantic-ai:** migrate onto unified harness surface (PR4) ([#415](https://github.com/scaleapi/scale-agentex-python/issues/415)) ([5ec62c2](https://github.com/scaleapi/scale-agentex-python/commit/5ec62c20781d24fc3e0b92734fcd444b1e791d70)) +* **sdk:** add webhook helper for forward-route handlers ([#419](https://github.com/scaleapi/scale-agentex-python/issues/419)) ([514075d](https://github.com/scaleapi/scale-agentex-python/commit/514075de2189f33be4ade0ac84368019e55ed7ea)) +* **streaming:** stream tool call argument deltas in TemporalStreamingModel ([#355](https://github.com/scaleapi/scale-agentex-python/issues/355)) ([c8de1d4](https://github.com/scaleapi/scale-agentex-python/commit/c8de1d4c9c3b5b3c16ad4aaf9644c1ba0d618757)) +* **tracing:** skip Agentex span-start write by default (end-only ingest) ([#438](https://github.com/scaleapi/scale-agentex-python/issues/438)) ([10d22a2](https://github.com/scaleapi/scale-agentex-python/commit/10d22a27091c9c410ae808dab9cfce5dab3816a8)) + + +### Bug Fixes + +* **harness:** assert cross-channel (yield vs auto-send) conformance equivalence [AGX1-373] ([#414](https://github.com/scaleapi/scale-agentex-python/issues/414)) ([694960f](https://github.com/scaleapi/scale-agentex-python/commit/694960f913b8ba521d9236e876e5e00f57a3a3ff)) +* **harness:** correct codex & openai reasoning stream envelopes ([#441](https://github.com/scaleapi/scale-agentex-python/issues/441)) ([1d86e8a](https://github.com/scaleapi/scale-agentex-python/commit/1d86e8a47a369814540b6e853cd20240c6098f27)) +* **tests:** use relative import for assert_matches_type in webhooks test ([#440](https://github.com/scaleapi/scale-agentex-python/issues/440)) ([5954a9f](https://github.com/scaleapi/scale-agentex-python/commit/5954a9fc8c7961ef5ceb41abf3ca32e6e78590c5)) +* **tracing:** fail open temporal span activities ([#437](https://github.com/scaleapi/scale-agentex-python/issues/437)) ([2d63eef](https://github.com/scaleapi/scale-agentex-python/commit/2d63eef53bdb919bb6568e04708e3b7abcb8075b)) + + +### Refactors + +* **cli:** migrate existing langgraph/pydantic-ai templates to unified surface ([#429](https://github.com/scaleapi/scale-agentex-python/issues/429)) ([ee41408](https://github.com/scaleapi/scale-agentex-python/commit/ee41408c420eba5c6b8fe8719c8ebd445dcd220c)) +* **tutorials:** migrate to the unified harness surface + renumber ([#428](https://github.com/scaleapi/scale-agentex-python/issues/428)) ([ebaf617](https://github.com/scaleapi/scale-agentex-python/commit/ebaf617256c7971dde12fd7e25f02b05f2f42fca)) + +## 0.14.0 (2026-06-22) + +Full Changelog: [agentex-client-v0.13.1...agentex-client-v0.14.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.13.1...agentex-client-v0.14.0) + +### Features + +* **api:** add is error to tools ([8ddd960](https://github.com/scaleapi/scale-agentex-python/commit/8ddd9604290d23ed59586a68bd6db46bf452104b)) +* **compat:** runtime SDK↔backend version guard at ACP startup ([#408](https://github.com/scaleapi/scale-agentex-python/issues/408)) ([433c999](https://github.com/scaleapi/scale-agentex-python/commit/433c999bbdb4817d2048c5454cb65b54812950af)) + + +### Bug Fixes + +* **types:** add missing Optional import to ToolResponseContent ([3439f6e](https://github.com/scaleapi/scale-agentex-python/commit/3439f6edec9ab89d685b5b1c99e567a67c911522)) + +## 0.13.1 (2026-06-17) + +Full Changelog: [agentex-client-v0.13.0...agentex-client-v0.13.1](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.13.0...agentex-client-v0.13.1) + +### Bug Fixes + +* **adk:** re-send task_id/agent_id in state updates for backend compatibility ([#405](https://github.com/scaleapi/scale-agentex-python/issues/405)) ([f59f26d](https://github.com/scaleapi/scale-agentex-python/commit/f59f26d4402f01318cf34d57820e121d97719986)) +* **packaging:** guard agentex-client surface, bump floor, smoke-test wheel install ([#406](https://github.com/scaleapi/scale-agentex-python/issues/406)) ([a5abbb9](https://github.com/scaleapi/scale-agentex-python/commit/a5abbb9669c6ab71c52e60db72676c95c20d840d)) + + +### Documentation + +* drop stale keep_files / dashboard-config comments ([#401](https://github.com/scaleapi/scale-agentex-python/issues/401)) ([23858df](https://github.com/scaleapi/scale-agentex-python/commit/23858df775d0a617c6418eed28f1b68c9bf9ed5c)) + +## 0.13.0 (2026-06-10) + +Full Changelog: [agentex-client-v0.12.0...agentex-client-v0.13.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.12.0...agentex-client-v0.13.0) + +### ⚠ BREAKING CHANGES + +* **packaging:** release tag scheme changes from v* to -v*. + +### Features + +* add AgentCard for self-describing agent capabilities ([#296](https://github.com/scaleapi/scale-agentex-python/issues/296)) ([6509be1](https://github.com/scaleapi/scale-agentex-python/commit/6509be1e5d9bc53e6058b22c45c760e04a4c4006)) +* add HTTP-proxy LangGraph checkpointer ([19fae2f](https://github.com/scaleapi/scale-agentex-python/commit/19fae2f6e3ce4302066a403cac4c6499410ec4ad)) +* add OCI Helm registry support for agent deployments ([#255](https://github.com/scaleapi/scale-agentex-python/issues/255)) ([5f054b5](https://github.com/scaleapi/scale-agentex-python/commit/5f054b514ff919479b0914883ed163279820c848)) +* **adk:** allow all ClaudeAgentOptions in run_claude_agent_activity ([25bbe24](https://github.com/scaleapi/scale-agentex-python/commit/25bbe24b57feaab2e557ca15279369bfb59e02db)) +* **adk:** Revamp run_claude_agent_activity to use more streaming ([#309](https://github.com/scaleapi/scale-agentex-python/issues/309)) ([0c16595](https://github.com/scaleapi/scale-agentex-python/commit/0c16595017164649bbea1bab8767010c9be7228d)) +* **api:** api update ([7b1b642](https://github.com/scaleapi/scale-agentex-python/commit/7b1b642404f34ff74d866e91a5ed2d6f0a4424c6)) +* **api:** api update ([710c63f](https://github.com/scaleapi/scale-agentex-python/commit/710c63f3a9b0494635c41e0d3498d69dc9145b81)) +* **api:** api update ([8abce2b](https://github.com/scaleapi/scale-agentex-python/commit/8abce2ba6131732688f04bacff33da506e47c77f)) +* **api:** Switch target to -client ([e741990](https://github.com/scaleapi/scale-agentex-python/commit/e74199029367ec7c626f5ea3057eb462e9f81b30)) +* **lib:** Add task updates to adk ([a58747f](https://github.com/scaleapi/scale-agentex-python/commit/a58747f0d85733f32f67b06eee222a1464eb87fe)) +* **openai_agents:** expose real `usage`, `response_id`, plumb `previous_response_id`, opt-in `prompt_cache_key` for stateful responses and prompt caching ([#335](https://github.com/scaleapi/scale-agentex-python/issues/335)) ([ba5d64b](https://github.com/scaleapi/scale-agentex-python/commit/ba5d64be1f959ff1a35b30e647a0a5ead21a8402)) +* **packaging:** introduce slim agentex-client + heavy agentex-sdk split ([bbfb22e](https://github.com/scaleapi/scale-agentex-python/commit/bbfb22eb113dd1f3d5ddf82b4d377895f5ae5466)) +* pass AGENTEX_DEPLOYMENT_ID in registration metadata ([#305](https://github.com/scaleapi/scale-agentex-python/issues/305)) ([31af8c6](https://github.com/scaleapi/scale-agentex-python/commit/31af8c6fc4aaafad57b70ded4883ced1254aeb1b)) +* **tracing:** Add background queue for async span processing ([#303](https://github.com/scaleapi/scale-agentex-python/issues/303)) ([3a60add](https://github.com/scaleapi/scale-agentex-python/commit/3a60add048ff24266a45700b4e78def8ffed3e0b)) + + +### Bug Fixes + +* add litellm retry with exponential backoff for rate limit errors ([ccdb24a](https://github.com/scaleapi/scale-agentex-python/commit/ccdb24a08607298f8dafd748ee9e7fe8ba13d5fe)) +* **adk:** fix to queue drain ([#327](https://github.com/scaleapi/scale-agentex-python/issues/327)) ([a862a06](https://github.com/scaleapi/scale-agentex-python/commit/a862a0646365d86acd4b0e1cf470fce522a6fbb3)) +* **api:** remove agent_id and task_id parameters from states update method ([a7cbaae](https://github.com/scaleapi/scale-agentex-python/commit/a7cbaae4416e2d712623ecfac5e251c07c537958)) +* **client:** preserve hardcoded query params when merging with user params ([d2c4788](https://github.com/scaleapi/scale-agentex-python/commit/d2c47883c4247a0c5a318042ff38384ddc8db4ea)) +* ensure file data are only sent as 1 parameter ([48fae27](https://github.com/scaleapi/scale-agentex-python/commit/48fae27b6a761984f7fb70cb7a87da76a4192d12)) +* render .env.example template in agentex init ([#351](https://github.com/scaleapi/scale-agentex-python/issues/351)) ([6092595](https://github.com/scaleapi/scale-agentex-python/commit/6092595fa8a267b2c305baba09e2682c04d593b3)) +* Temporal Union deserialization causing tool_response messages to be lost ([79ef4dd](https://github.com/scaleapi/scale-agentex-python/commit/79ef4dd7a0ab1b8bb1151f5e16124ec5a947dfd4)) +* **temporal:** allowing-ACP-temporal-telemetry ([9b44eb0](https://github.com/scaleapi/scale-agentex-python/commit/9b44eb0f5c6482984f972674d7a8612980c5b576)) +* **tests:** repair test_streaming_model so all 28 tests run and pass ([#334](https://github.com/scaleapi/scale-agentex-python/issues/334)) ([7e5e69c](https://github.com/scaleapi/scale-agentex-python/commit/7e5e69c132c89d054516e1a762e0437375859663)) +* **tracing:** Fix memory leak in SGP tracing processors ([#302](https://github.com/scaleapi/scale-agentex-python/issues/302)) ([f43dac4](https://github.com/scaleapi/scale-agentex-python/commit/f43dac4fa7ca7090b37c6c3bf285eb12515764bb)) +* **tutorials:** stop at130-langgraph workflow deadlock on graph compile ([#399](https://github.com/scaleapi/scale-agentex-python/issues/399)) ([bd90a61](https://github.com/scaleapi/scale-agentex-python/commit/bd90a613958a330f1a6670f621000a9aaed1025b)) + + +### Performance Improvements + +* **client:** optimize file structure copying in multipart requests ([f5064f9](https://github.com/scaleapi/scale-agentex-python/commit/f5064f939788d72fedac91436982a8848d0f1f4f)) +* **tracing:** larger span batch + linger_ms for high-volume ingest ([#397](https://github.com/scaleapi/scale-agentex-python/issues/397)) ([c0d6330](https://github.com/scaleapi/scale-agentex-python/commit/c0d633052d373daa63e8cefb9339736c0a7855fb)) +* **tracing:** skip span-start upsert by default (end-only ingest) ([#394](https://github.com/scaleapi/scale-agentex-python/issues/394)) ([ae1c7ca](https://github.com/scaleapi/scale-agentex-python/commit/ae1c7caa8599f5f82492086d04caae9a6d2b7c7d)) + + +### Chores + +* **ci:** upgrade `actions/github-script` ([7c867e8](https://github.com/scaleapi/scale-agentex-python/commit/7c867e8960b51234e5e41a9b8e3129c1dada5680)) +* gitignore .claude/scheduled_tasks.lock ([#400](https://github.com/scaleapi/scale-agentex-python/issues/400)) ([e186352](https://github.com/scaleapi/scale-agentex-python/commit/e1863526408451d087568676feafca033a4656c4)) + + +### Documentation + +* **api:** clarify name parameter behavior in agent task creation ([ce5af72](https://github.com/scaleapi/scale-agentex-python/commit/ce5af729cc3a0f05905d0cebfe2ef18c16d8563e)) +* clarify task name is optional in adk.acp.create_task ([#392](https://github.com/scaleapi/scale-agentex-python/issues/392)) ([bd41d9b](https://github.com/scaleapi/scale-agentex-python/commit/bd41d9bb10f08a354f02f982e6507847c19d2ad9)) + + +### Refactors + +* **config:** promote deployment-config models to agentex.config.* ([#396](https://github.com/scaleapi/scale-agentex-python/issues/396)) ([9825dba](https://github.com/scaleapi/scale-agentex-python/commit/9825dba3301754e2a86632214adcc62ff97e28bd)) + +## 0.12.0 (2026-06-02) + +Full Changelog: [v0.11.9...v0.12.0](https://github.com/scaleapi/scale-agentex-python/compare/v0.11.9...v0.12.0) + +### Features + +* **api:** Bump edition to switch rye -> UV ([1bd4ff7](https://github.com/scaleapi/scale-agentex-python/commit/1bd4ff7c3299ea4238cd3e36141f7e4b035967ef)) + + +### Bug Fixes + +* cap Python test matrix at 3.13 and align dev tooling versions ([#391](https://github.com/scaleapi/scale-agentex-python/issues/391)) ([729763c](https://github.com/scaleapi/scale-agentex-python/commit/729763c9652faf3a68386083d6f617dd48f642b7)) + +## 0.11.9 (2026-06-02) + +Full Changelog: [v0.11.8...v0.11.9](https://github.com/scaleapi/scale-agentex-python/compare/v0.11.8...v0.11.9) + +### Features + +* **api:** add register build api endpoint ([30c5da4](https://github.com/scaleapi/scale-agentex-python/commit/30c5da47d84ce2bfbfbb798c2f62b9552881db7d)) + +## 0.11.8 (2026-06-01) + +Full Changelog: [v0.11.7...v0.11.8](https://github.com/scaleapi/scale-agentex-python/compare/v0.11.7...v0.11.8) + +### Features + +* **cli:** add Temporal + LangGraph agent template and example ([#383](https://github.com/scaleapi/scale-agentex-python/issues/383)) ([bbc9e02](https://github.com/scaleapi/scale-agentex-python/commit/bbc9e02d2a2b063a3e509a07ffca8ca4bf459e57)) +* **tracing:** OTel span queue and export telemetry (SGPINF-1863) ([#373](https://github.com/scaleapi/scale-agentex-python/issues/373)) ([6669012](https://github.com/scaleapi/scale-agentex-python/commit/6669012638481a63bdd7629582818796ca31bdf3)) + +## 0.11.7 (2026-06-01) + +Full Changelog: [v0.11.6...v0.11.7](https://github.com/scaleapi/scale-agentex-python/compare/v0.11.6...v0.11.7) + +### Features + +* **examples:** OpenAI Agents SDK local-sandbox tutorials (sync + async + temporal) ([#377](https://github.com/scaleapi/scale-agentex-python/issues/377)) ([a66d239](https://github.com/scaleapi/scale-agentex-python/commit/a66d23955fa1a98296ef4e8b09c11afe9461268a)) + + +### Performance Improvements + +* **tracing:** bounded-concurrency span export ([#374](https://github.com/scaleapi/scale-agentex-python/issues/374)) ([7b32a0d](https://github.com/scaleapi/scale-agentex-python/commit/7b32a0d826b3ed864a3bf9de256ff8da1dafb942)) + + +### Chores + +* back-merge release 0.11.6 into next ([#384](https://github.com/scaleapi/scale-agentex-python/issues/384)) ([13d3eab](https://github.com/scaleapi/scale-agentex-python/commit/13d3eab0657f1dd5a8b7ade6c7381d3230d60aff)) + +## 0.11.6 (2026-05-29) + +Full Changelog: [v0.11.5...v0.11.6](https://github.com/scaleapi/scale-agentex-python/compare/v0.11.5...v0.11.6) + +### Features + +* **api:** add cleaned_at field to task response types ([38ed338](https://github.com/scaleapi/scale-agentex-python/commit/38ed3384094f7f07f6b2482489f457fd1dc4f76d)) +* **deps:** bump openai-agents to >=0.14.3 for scale-sandbox oai_agents adapter ([#375](https://github.com/scaleapi/scale-agentex-python/issues/375)) ([e1b31d9](https://github.com/scaleapi/scale-agentex-python/commit/e1b31d91abadec572989b805592b788500d61994)) +* **lib:** expose data_converter kwarg on AgentexWorker and Temporal client APIs ([#372](https://github.com/scaleapi/scale-agentex-python/issues/372)) ([d04624e](https://github.com/scaleapi/scale-agentex-python/commit/d04624e6899e43a0429ef2deeb84509265b9f636)) + + +### Bug Fixes + +* **tutorials:** restore tutorial CI deps after agentex-sdk 0.11.5 (pytest + debugpy) ([#379](https://github.com/scaleapi/scale-agentex-python/issues/379)) ([0a2418c](https://github.com/scaleapi/scale-agentex-python/commit/0a2418cc9f9b06e3bdc46099106e50d226412fa0)) + + +### Performance Improvements + +* **tracing:** span queue linger + per-loop httpx keepalive ([#362](https://github.com/scaleapi/scale-agentex-python/issues/362)) ([feec842](https://github.com/scaleapi/scale-agentex-python/commit/feec8426f79e9f02533451d44997717655fd33f2)) + + +### Chores + +* back-merge release 0.11.5 into next ([#381](https://github.com/scaleapi/scale-agentex-python/issues/381)) ([ab5a7d9](https://github.com/scaleapi/scale-agentex-python/commit/ab5a7d9732a56d47efad469675c7630046106ef6)) +* **deps:** drop unused runtime deps and exclude tests from wheel ([#367](https://github.com/scaleapi/scale-agentex-python/issues/367)) ([f4303d1](https://github.com/scaleapi/scale-agentex-python/commit/f4303d1e7211783d19beca6554e44eb73bb29c42)) + + +### Refactors + +* **types:** promote protocol types to agentex.protocol.* ([#371](https://github.com/scaleapi/scale-agentex-python/issues/371)) ([6f1c14f](https://github.com/scaleapi/scale-agentex-python/commit/6f1c14fd61077da52038361642a9fbc4a0a56c8b)) + +## 0.11.5 (2026-05-29) + +Full Changelog: [v0.11.4...v0.11.5](https://github.com/scaleapi/scale-agentex-python/compare/v0.11.4...v0.11.5) + +### Features + +* **api:** add cleaned_at field to task response types ([38ed338](https://github.com/scaleapi/scale-agentex-python/commit/38ed3384094f7f07f6b2482489f457fd1dc4f76d)) +* **deps:** bump openai-agents to >=0.14.3 for scale-sandbox oai_agents adapter ([#375](https://github.com/scaleapi/scale-agentex-python/issues/375)) ([e1b31d9](https://github.com/scaleapi/scale-agentex-python/commit/e1b31d91abadec572989b805592b788500d61994)) + + +### Performance Improvements + +* **tracing:** span queue linger + per-loop httpx keepalive ([#362](https://github.com/scaleapi/scale-agentex-python/issues/362)) ([feec842](https://github.com/scaleapi/scale-agentex-python/commit/feec8426f79e9f02533451d44997717655fd33f2)) + + +### Chores + +* **deps:** drop unused runtime deps and exclude tests from wheel ([#367](https://github.com/scaleapi/scale-agentex-python/issues/367)) ([f4303d1](https://github.com/scaleapi/scale-agentex-python/commit/f4303d1e7211783d19beca6554e44eb73bb29c42)) + + +### Refactors + +* **types:** promote protocol types to agentex.protocol.* ([#371](https://github.com/scaleapi/scale-agentex-python/issues/371)) ([6f1c14f](https://github.com/scaleapi/scale-agentex-python/commit/6f1c14fd61077da52038361642a9fbc4a0a56c8b)) + +## 0.11.4 (2026-05-26) + +Full Changelog: [v0.11.3...v0.11.4](https://github.com/scaleapi/scale-agentex-python/compare/v0.11.3...v0.11.4) + +### Chores + +* **deps:** relax redis pin to support 6.x/7.x ([#363](https://github.com/scaleapi/scale-agentex-python/issues/363)) ([7817ced](https://github.com/scaleapi/scale-agentex-python/commit/7817ced90b80430a69b6f51a6841aa921a33a093)) +* relax requires-python floor to >= 3.11 ([#366](https://github.com/scaleapi/scale-agentex-python/issues/366)) ([a064f92](https://github.com/scaleapi/scale-agentex-python/commit/a064f928c0fac868ec1486ef49382a9baf73b5e0)) + +## 0.11.3 (2026-05-20) + +Full Changelog: [v0.11.2...v0.11.3](https://github.com/scaleapi/scale-agentex-python/compare/v0.11.2...v0.11.3) + +### Features + +* added Pydantic AI sync, async, temporal integration ([#359](https://github.com/scaleapi/scale-agentex-python/issues/359)) ([781dfe1](https://github.com/scaleapi/scale-agentex-python/commit/781dfe172373c2e01fb642b3c98af6908c98218a)) +* **api:** add schedule, checkpoints, and deployment endpoints ([53b5c36](https://github.com/scaleapi/scale-agentex-python/commit/53b5c3673e54ee4b49debd049483f1a1d4b0673d)) + + +### Bug Fixes + +* resolve lint and test failures from new endpoints ([#360](https://github.com/scaleapi/scale-agentex-python/issues/360)) ([bdf129c](https://github.com/scaleapi/scale-agentex-python/commit/bdf129c8ab976ed84aa9932d5585a753280a6a34)) + +## 0.11.2 (2026-05-13) + +Full Changelog: [v0.11.1...v0.11.2](https://github.com/scaleapi/scale-agentex-python/compare/v0.11.1...v0.11.2) + +### Bug Fixes + +* **messages:** stamp agent messages with workflow.now() for monotonic ordering ([#356](https://github.com/scaleapi/scale-agentex-python/issues/356)) ([afe5265](https://github.com/scaleapi/scale-agentex-python/commit/afe526509393d7f51e4edc261211792992ffee58)) + +## 0.11.1 (2026-05-13) + +Full Changelog: [v0.11.0...v0.11.1](https://github.com/scaleapi/scale-agentex-python/compare/v0.11.0...v0.11.1) + +### ⚠ BREAKING CHANGES + +* remove AgentexTracingProcessor from default tracing processors ([#349](https://github.com/scaleapi/scale-agentex-python/issues/349)) + +### Features + +* **api:** add models for event requests, surface created_at for messages ([1998d73](https://github.com/scaleapi/scale-agentex-python/commit/1998d73741ed32f6e527d847a7c951a6f880cab9)) +* **api:** api update ([da06505](https://github.com/scaleapi/scale-agentex-python/commit/da065051e22cd49f7d47facd33db5bbb50d61f6d)) +* **api:** revert model additions ([a02c15b](https://github.com/scaleapi/scale-agentex-python/commit/a02c15bfe1169a84d59647d409755d7bfcc029d0)) +* **internal/types:** support eagerly validating pydantic iterators ([2c528c6](https://github.com/scaleapi/scale-agentex-python/commit/2c528c6db24cb64b7fffadafe3e8c46f316f0d56)) +* remove AgentexTracingProcessor from default tracing processors ([#349](https://github.com/scaleapi/scale-agentex-python/issues/349)) ([73eca7a](https://github.com/scaleapi/scale-agentex-python/commit/73eca7ad620a7e0a8bd0180b9dee02a7dde12dbb)) +* **streaming:** emit OTel metrics for ttft, tps, token counts ([#347](https://github.com/scaleapi/scale-agentex-python/issues/347)) ([3bf7d1f](https://github.com/scaleapi/scale-agentex-python/commit/3bf7d1f32f95e1346cdc823e3d1f4f027635e2dd)) + + +### Bug Fixes + +* **client:** add missing f-string prefix in file type error message ([dcb1cb4](https://github.com/scaleapi/scale-agentex-python/commit/dcb1cb489bc565828c16c327c5ab6b678b13c0fa)) +* render .env.example template in agentex init ([#351](https://github.com/scaleapi/scale-agentex-python/issues/351)) ([6092595](https://github.com/scaleapi/scale-agentex-python/commit/6092595fa8a267b2c305baba09e2682c04d593b3)) +* **tracing:** make SGP processor stateless to stop dropping span closes ([#354](https://github.com/scaleapi/scale-agentex-python/issues/354)) ([5e9f28d](https://github.com/scaleapi/scale-agentex-python/commit/5e9f28d2f1453b3b6faf993acf9f67a6fd098952)) +* wire SGP_CLIENT_BASE_URL and silence openai-agents tracer in templates ([#352](https://github.com/scaleapi/scale-agentex-python/issues/352)) ([870324e](https://github.com/scaleapi/scale-agentex-python/commit/870324e7bb87cefc20a79dc344d8603a836ca9b5)) + +## 0.11.0 (2026-05-07) + +Full Changelog: [v0.10.5...v0.11.0](https://github.com/scaleapi/scale-agentex-python/compare/v0.10.5...v0.11.0) + +### Features + +* make workflow execution timeout configurable via env var ([#348](https://github.com/scaleapi/scale-agentex-python/issues/348)) ([4094708](https://github.com/scaleapi/scale-agentex-python/commit/4094708a84026aafe19eae19d022118bb26e1a72)) + +## 0.10.5 (2026-05-05) + +Full Changelog: [v0.10.4...v0.10.5](https://github.com/scaleapi/scale-agentex-python/compare/v0.10.4...v0.10.5) + +### Features + +* **api:** api update ([ffaecd5](https://github.com/scaleapi/scale-agentex-python/commit/ffaecd5a94b4082f9ef38d5c89286eabf3811759)) +* **openai_agents:** expose real `usage`, `response_id`, plumb `previous_response_id`, opt-in `prompt_cache_key` for stateful responses and prompt caching ([#335](https://github.com/scaleapi/scale-agentex-python/issues/335)) ([ba5d64b](https://github.com/scaleapi/scale-agentex-python/commit/ba5d64be1f959ff1a35b30e647a0a5ead21a8402)) + + +### Chores + +* **internal:** reformat pyproject.toml ([ba06702](https://github.com/scaleapi/scale-agentex-python/commit/ba06702fd362656d594f73852ad2c690383892a8)) +* **internal:** reformat pyproject.toml ([3faf5d5](https://github.com/scaleapi/scale-agentex-python/commit/3faf5d5927abdc3036862d4d06e085cda0eb6cd4)) +* **internal:** version bump ([168cc44](https://github.com/scaleapi/scale-agentex-python/commit/168cc44f8199015e232cd2bddf1669a08ee90778)) +* **internal:** version bump ([5715828](https://github.com/scaleapi/scale-agentex-python/commit/5715828a358c20b1cc895a696d0c8d803ec71932)) + +## 0.10.4 (2026-05-04) + +Full Changelog: [v0.10.3...v0.10.4](https://github.com/scaleapi/scale-agentex-python/compare/v0.10.3...v0.10.4) + +### Features + +* add service account id option for registering agentex agents ([8365771](https://github.com/scaleapi/scale-agentex-python/commit/83657710ddb95d61bb5173ca881fe602344ff495)) + +## 0.10.3 (2026-04-30) + +Full Changelog: [v0.10.2...v0.10.3](https://github.com/scaleapi/scale-agentex-python/compare/v0.10.2...v0.10.3) + +### Features + +* **api:** api update ([16ab771](https://github.com/scaleapi/scale-agentex-python/commit/16ab771ab1396b94c768ec5185c2f8ed07eff556)) +* **api:** api update ([fe77732](https://github.com/scaleapi/scale-agentex-python/commit/fe77732da48c872739bc6296d2932d4d9c810a35)) +* support setting headers via env ([a73fd73](https://github.com/scaleapi/scale-agentex-python/commit/a73fd73ea036fc195c124636337acdc0552f18f1)) + + +### Bug Fixes + +* **adk:** Always inject headers on execute activity ([#337](https://github.com/scaleapi/scale-agentex-python/issues/337)) ([9d80e0b](https://github.com/scaleapi/scale-agentex-python/commit/9d80e0b797a9ed7a0838003294dc7a595ab18de5)) +* allow litellm security patch ([#336](https://github.com/scaleapi/scale-agentex-python/issues/336)) ([c980948](https://github.com/scaleapi/scale-agentex-python/commit/c9809482d5e6095063115d1851f0b92a5e5a3755)) +* **tests:** repair test_streaming_model so all 28 tests run and pass ([#334](https://github.com/scaleapi/scale-agentex-python/issues/334)) ([7e5e69c](https://github.com/scaleapi/scale-agentex-python/commit/7e5e69c132c89d054516e1a762e0437375859663)) +* use correct field name format for multipart file arrays ([bd6d362](https://github.com/scaleapi/scale-agentex-python/commit/bd6d362aee81873b7969b0367488029e2bb0314b)) + + +### Performance Improvements + +* **streaming:** coalesce per-token publishes to Redis (50ms / 128-char window) ([#333](https://github.com/scaleapi/scale-agentex-python/issues/333)) ([e6f11c4](https://github.com/scaleapi/scale-agentex-python/commit/e6f11c45e6dc3186770088688ad45cc251387e4a)) + + +### Chores + +* **internal:** more robust bootstrap script ([f004301](https://github.com/scaleapi/scale-agentex-python/commit/f0043013a44ddcd9f356a8e0a548e4a295cb1b1d)) + +## 0.10.2 (2026-04-21) + +Full Changelog: [v0.10.1...v0.10.2](https://github.com/scaleapi/scale-agentex-python/compare/v0.10.1...v0.10.2) + +### Features + +* **api:** api update ([d5b9945](https://github.com/scaleapi/scale-agentex-python/commit/d5b99455c248a629bb2c56a2b5daf192d9f70db8)) + + +### Bug Fixes + +* **adk:** fix to queue drain ([#327](https://github.com/scaleapi/scale-agentex-python/issues/327)) ([b59d6d8](https://github.com/scaleapi/scale-agentex-python/commit/b59d6d8b59cec9548ec468cae3827d785c9f86f7)) + + +### Performance Improvements + +* **client:** optimize file structure copying in multipart requests ([87fe899](https://github.com/scaleapi/scale-agentex-python/commit/87fe899713a2ec88f1c32b347a7d5c78124aaf56)) + +## 0.10.1 (2026-04-17) + +Full Changelog: [v0.10.0...v0.10.1](https://github.com/scaleapi/scale-agentex-python/compare/v0.10.0...v0.10.1) + +## 0.10.0 (2026-04-14) + +Full Changelog: [v0.9.10...v0.10.0](https://github.com/scaleapi/scale-agentex-python/compare/v0.9.10...v0.10.0) + +### Features + +* add AgentCard for self-describing agent capabilities ([#296](https://github.com/scaleapi/scale-agentex-python/issues/296)) ([6509be1](https://github.com/scaleapi/scale-agentex-python/commit/6509be1e5d9bc53e6058b22c45c760e04a4c4006)) +* **api:** api update ([8abce2b](https://github.com/scaleapi/scale-agentex-python/commit/8abce2ba6131732688f04bacff33da506e47c77f)) + + +### Bug Fixes + +* ensure file data are only sent as 1 parameter ([48fae27](https://github.com/scaleapi/scale-agentex-python/commit/48fae27b6a761984f7fb70cb7a87da76a4192d12)) +* Temporal Union deserialization causing tool_response messages to be lost ([79ef4dd](https://github.com/scaleapi/scale-agentex-python/commit/79ef4dd7a0ab1b8bb1151f5e16124ec5a947dfd4)) +* **temporal:** allowing-ACP-temporal-telemetry ([9b44eb0](https://github.com/scaleapi/scale-agentex-python/commit/9b44eb0f5c6482984f972674d7a8612980c5b576)) + +## 0.9.10 (2026-04-07) + +Full Changelog: [v0.9.9...v0.9.10](https://github.com/scaleapi/scale-agentex-python/compare/v0.9.9...v0.9.10) + +### Features + +* **adk:** Revamp run_claude_agent_activity to use more streaming ([#309](https://github.com/scaleapi/scale-agentex-python/issues/309)) ([25069d3](https://github.com/scaleapi/scale-agentex-python/commit/25069d3dccc7534ecfba114b581878af758c3487)) + +## 0.9.9 (2026-04-07) + +Full Changelog: [v0.9.8...v0.9.9](https://github.com/scaleapi/scale-agentex-python/compare/v0.9.8...v0.9.9) + +### Bug Fixes + +* **client:** preserve hardcoded query params when merging with user params ([4a97659](https://github.com/scaleapi/scale-agentex-python/commit/4a97659b768335bc241e78d3897a9bd665ce1a25)) + +## 0.9.8 (2026-04-06) + +Full Changelog: [v0.9.7...v0.9.8](https://github.com/scaleapi/scale-agentex-python/compare/v0.9.7...v0.9.8) + +### Features + +* **adk:** allow all ClaudeAgentOptions in run_claude_agent_activity ([e41aec7](https://github.com/scaleapi/scale-agentex-python/commit/e41aec738f230070c5db1dcbf7e08abc1ef538d9)) +* pass AGENTEX_DEPLOYMENT_ID in registration metadata ([#305](https://github.com/scaleapi/scale-agentex-python/issues/305)) ([31af8c6](https://github.com/scaleapi/scale-agentex-python/commit/31af8c6fc4aaafad57b70ded4883ced1254aeb1b)) +* **tracing:** Add background queue for async span processing ([#303](https://github.com/scaleapi/scale-agentex-python/issues/303)) ([3a60add](https://github.com/scaleapi/scale-agentex-python/commit/3a60add048ff24266a45700b4e78def8ffed3e0b)) + + +### Bug Fixes + +* **tracing:** Fix memory leak in SGP tracing processors ([#302](https://github.com/scaleapi/scale-agentex-python/issues/302)) ([f43dac4](https://github.com/scaleapi/scale-agentex-python/commit/f43dac4fa7ca7090b37c6c3bf285eb12515764bb)) + +## 0.9.7 (2026-03-30) + +Full Changelog: [v0.9.6...v0.9.7](https://github.com/scaleapi/scale-agentex-python/compare/v0.9.6...v0.9.7) + +### Features + +* **lib:** Add task updates to adk ([ff12ae1](https://github.com/scaleapi/scale-agentex-python/commit/ff12ae199b38223c7c71b703fc8b11d5de99b0d8)) + +## 0.9.6 (2026-03-30) + +Full Changelog: [v0.9.5...v0.9.6](https://github.com/scaleapi/scale-agentex-python/compare/v0.9.5...v0.9.6) + +### Features + +* **api:** add task state update methods ([d699e24](https://github.com/scaleapi/scale-agentex-python/commit/d699e245d6c8f28034370ea6a654e11a3b78dc20)) +* **api:** keep backwards compatible models ([3ec2a1e](https://github.com/scaleapi/scale-agentex-python/commit/3ec2a1e9987cd69fbcfeee8a8a6449b6825a1d49)) +* **api:** use DeploymentHistory instead of DeploymentHistoryRetrieveResponse ([4c63d9c](https://github.com/scaleapi/scale-agentex-python/commit/4c63d9c340e56d7f602f77f2f1fb33b005577402)) +* **internal:** implement indices array format for query and form serialization ([3bf3db1](https://github.com/scaleapi/scale-agentex-python/commit/3bf3db1f692b44ceb5f4ea39cb8c4fd0f81c01ee)) + + +### Bug Fixes + +* **deps:** bump minimum typing-extensions version ([fd76bc9](https://github.com/scaleapi/scale-agentex-python/commit/fd76bc994dca633c4966967c132323985eafa642)) +* **pydantic:** do not pass `by_alias` unless set ([235636b](https://github.com/scaleapi/scale-agentex-python/commit/235636b424dd4595f1510a87e6b79f3b2e103c97)) +* sanitize endpoint path params ([e6472be](https://github.com/scaleapi/scale-agentex-python/commit/e6472bea7d34a72d070079441b359bef25e87830)) + + +### Chores + +* **ci:** skip lint on metadata-only changes ([f4d5053](https://github.com/scaleapi/scale-agentex-python/commit/f4d5053766e5864338229218f2402d60f431d1fa)) +* **ci:** skip uploading artifacts on stainless-internal branches ([45e7622](https://github.com/scaleapi/scale-agentex-python/commit/45e76227d8b0d5d1c2f398e9945b71eb5953e791)) +* format all `api.md` files ([e67fa69](https://github.com/scaleapi/scale-agentex-python/commit/e67fa69c072f462ea86ecd67b888afa5f97cc7cc)) +* **internal:** add request options to SSE classes ([b788da0](https://github.com/scaleapi/scale-agentex-python/commit/b788da0d1b9fb6100dffb4a99b761ddcb7f0160e)) +* **internal:** bump dependencies ([95112dd](https://github.com/scaleapi/scale-agentex-python/commit/95112dd25a3bf8a49bd1080bfddefd403e64cfcb)) +* **internal:** fix lint error on Python 3.14 ([cb99db1](https://github.com/scaleapi/scale-agentex-python/commit/cb99db1857e373c3dc47d4f5ff6861d06b0ddce4)) +* **internal:** make `test_proxy_environment_variables` more resilient ([7bfaa75](https://github.com/scaleapi/scale-agentex-python/commit/7bfaa75be00bf8f11030f42a3dc6fdcd980c5823)) +* **internal:** make `test_proxy_environment_variables` more resilient to env ([fd1a06e](https://github.com/scaleapi/scale-agentex-python/commit/fd1a06e212cf1a314ac7c61e4d51879401e120f9)) +* **internal:** remove mock server code ([3a5ae0f](https://github.com/scaleapi/scale-agentex-python/commit/3a5ae0f0451610ae56284307d4c2bee1ac2964c1)) +* **internal:** tweak CI branches ([2e74af0](https://github.com/scaleapi/scale-agentex-python/commit/2e74af08e3e2dd4179550e9dd1cf22881195ac91)) +* **internal:** update gitignore ([aba7c4f](https://github.com/scaleapi/scale-agentex-python/commit/aba7c4f8264fdad515a4926884f855c2d87aa910)) +* **internal:** version bump ([1ef69ed](https://github.com/scaleapi/scale-agentex-python/commit/1ef69ed5415d3112055a8040eccfb6eca452e532)) +* **internal:** version bump ([1132255](https://github.com/scaleapi/scale-agentex-python/commit/1132255a0cd7aec1daed38e4110cd6bac53f930a)) +* **internal:** version bump ([60e5402](https://github.com/scaleapi/scale-agentex-python/commit/60e5402c4502957aee7848ab3cdcbfb41503a8ae)) +* update mock server docs ([8c5c6d3](https://github.com/scaleapi/scale-agentex-python/commit/8c5c6d38214b13f645f6fbd75efbbb8116458589)) + +## 0.9.5 (2026-03-24) + +Full Changelog: [v0.9.4...v0.9.5](https://github.com/scaleapi/scale-agentex-python/compare/v0.9.4...v0.9.5) + +## 0.9.4 (2026-02-18) + +Full Changelog: [v0.9.3...v0.9.4](https://github.com/scaleapi/scale-agentex-python/compare/v0.9.3...v0.9.4) + +## 0.9.3 (2026-02-13) + +Full Changelog: [v0.9.2...v0.9.3](https://github.com/scaleapi/scale-agentex-python/compare/v0.9.2...v0.9.3) + +### Features + +* add HTTP-proxy LangGraph checkpointer ([19fae2f](https://github.com/scaleapi/scale-agentex-python/commit/19fae2f6e3ce4302066a403cac4c6499410ec4ad)) +* add OCI Helm registry support for agent deployments ([#255](https://github.com/scaleapi/scale-agentex-python/issues/255)) ([5f054b5](https://github.com/scaleapi/scale-agentex-python/commit/5f054b514ff919479b0914883ed163279820c848)) + +## 0.9.2 (2026-02-06) + +Full Changelog: [v0.9.1...v0.9.2](https://github.com/scaleapi/scale-agentex-python/compare/v0.9.1...v0.9.2) + +### Features + +* **client:** add custom JSON encoder for extended type support ([a0720ab](https://github.com/scaleapi/scale-agentex-python/commit/a0720abb088583ce4b596e464f7483a4be728e29)) + + +### Bug Fixes + +* add litellm retry with exponential backoff for rate limit errors ([ccdb24a](https://github.com/scaleapi/scale-agentex-python/commit/ccdb24a08607298f8dafd748ee9e7fe8ba13d5fe)) + +## 0.9.1 (2026-01-26) + +Full Changelog: [v0.9.0...v0.9.1](https://github.com/scaleapi/scale-agentex-python/compare/v0.9.0...v0.9.1) + +### Chores + +* **ci:** upgrade `actions/github-script` ([71d5c6c](https://github.com/scaleapi/scale-agentex-python/commit/71d5c6c67362f18e0cbdc27cca37672778ff6b1f)) + +## 0.9.0 (2026-01-21) + +Full Changelog: [v0.8.2...v0.9.0](https://github.com/scaleapi/scale-agentex-python/compare/v0.8.2...v0.9.0) + +### Features + +* **api:** api update ([33ade28](https://github.com/scaleapi/scale-agentex-python/commit/33ade2859c35413ecb4972a68a85cc0ef426e864)) +* **client:** add support for binary request streaming ([07e2881](https://github.com/scaleapi/scale-agentex-python/commit/07e2881a23ad2c624306c8d10ab661ddef42deec)) + + +### Chores + +* **internal:** update `actions/checkout` version ([64d91f6](https://github.com/scaleapi/scale-agentex-python/commit/64d91f6984c577e0a8a1546bc0f96f944d343a7d)) + +## 0.8.2 (2026-01-02) + +Full Changelog: [v0.8.1...v0.8.2](https://github.com/scaleapi/scale-agentex-python/compare/v0.8.1...v0.8.2) + +### Features + +* **api:** api update ([f2115eb](https://github.com/scaleapi/scale-agentex-python/commit/f2115ebf273043a87ea50b39837138bfc30a63d6)) + +## 0.8.1 (2025-12-22) + +Full Changelog: [v0.8.0...v0.8.1](https://github.com/scaleapi/scale-agentex-python/compare/v0.8.0...v0.8.1) + +### Features + +* **api:** add messages/paginated endpoint ([3e03aff](https://github.com/scaleapi/scale-agentex-python/commit/3e03aff8490e0556cb05052d385156eda8f28107)) +* **api:** add messages/paginated to stainless config ([2473ded](https://github.com/scaleapi/scale-agentex-python/commit/2473ded39274bcd0a16d7314667fcf7f55e829c2)) +* **api:** api update ([2e4ec2f](https://github.com/scaleapi/scale-agentex-python/commit/2e4ec2f28413ee58afa664b793565d6be4da5dfe)) +* **api:** api update ([ed21ad8](https://github.com/scaleapi/scale-agentex-python/commit/ed21ad8c34cd11e80af9128181764489a0541740)) +* **api:** api update ([86a166a](https://github.com/scaleapi/scale-agentex-python/commit/86a166aba5538411ebcc0ed74291505e01a466f2)) +* **api:** api update ([4c95c94](https://github.com/scaleapi/scale-agentex-python/commit/4c95c94df570277fc49281f1343cb012e8da2334)) +* **api:** api update ([f6eccdf](https://github.com/scaleapi/scale-agentex-python/commit/f6eccdf975eaef9b257ef3f20f087f2f2f9b3665)) +* **api:** api update ([41067fb](https://github.com/scaleapi/scale-agentex-python/commit/41067fb79725787e0ceb20dcf16029998bcbca24)) +* **api:** api update ([cdc9c63](https://github.com/scaleapi/scale-agentex-python/commit/cdc9c636be6f26e84772d1d1ef9d47cddcd9dabc)) +* **api:** api update ([413d9c8](https://github.com/scaleapi/scale-agentex-python/commit/413d9c806d918d7c5da3d0249c0f11d4b9f0894e)) +* **api:** api update ([1b4bf7d](https://github.com/scaleapi/scale-agentex-python/commit/1b4bf7d3a11306a50ec0eb9c20764c585d0e98e4)) +* **api:** manual updates ([131e836](https://github.com/scaleapi/scale-agentex-python/commit/131e836b5bda8248f847b00308b6711a1ee84ee0)) +* **api:** update via SDK Studio ([2a6c7fa](https://github.com/scaleapi/scale-agentex-python/commit/2a6c7fa919ad255f9e53e7f97f195065599a05e9)) + + +### Bug Fixes + +* ensure streams are always closed ([7bb9db8](https://github.com/scaleapi/scale-agentex-python/commit/7bb9db851a213d261e585cd2f156046f05cf85db)) +* **types:** allow pyright to infer TypedDict types within SequenceNotStr ([9cfc9d6](https://github.com/scaleapi/scale-agentex-python/commit/9cfc9d66579a11f3eaf248bafbfddb422e878a58)) +* use async_to_httpx_files in patch method ([8abb539](https://github.com/scaleapi/scale-agentex-python/commit/8abb539a340af3a2a42482757412c0c408817461)) + + +### Chores + +* add missing docstrings ([81f1fa9](https://github.com/scaleapi/scale-agentex-python/commit/81f1fa9b3c440d893b8ea8f773ab2592eb333d65)) +* **deps:** mypy 1.18.1 has a regression, pin to 1.17 ([e20aaa4](https://github.com/scaleapi/scale-agentex-python/commit/e20aaa495384f547dd18c8d31496f70b4a37e0dd)) +* **docs:** use environment variables for authentication in code snippets ([a30f6ae](https://github.com/scaleapi/scale-agentex-python/commit/a30f6aebca8de5be72eb7bcf7a3b3ccea28479bc)) +* **internal:** add `--fix` argument to lint script ([0ef4242](https://github.com/scaleapi/scale-agentex-python/commit/0ef4242888cc6ed341536e1ab1fbf6b03c723de9)) +* **internal:** add missing files argument to base client ([28d1738](https://github.com/scaleapi/scale-agentex-python/commit/28d1738d3af8feb00f6f641e159221fb41c42983)) +* speedup initial import ([8e50946](https://github.com/scaleapi/scale-agentex-python/commit/8e50946321c32e42a7b25cf9ae8b8e9b020a7ac9)) +* update lockfile ([a3a2e4f](https://github.com/scaleapi/scale-agentex-python/commit/a3a2e4fbcf6e6e4bcbadab50c6b9236e4514dae2)) + +## 0.8.0 (2025-12-17) + +Full Changelog: [v0.7.4...v0.8.0](https://github.com/scaleapi/scale-agentex-python/compare/v0.7.4...v0.8.0) + +### Features + +* **api:** api update ([2e4ec2f](https://github.com/scaleapi/scale-agentex-python/commit/2e4ec2f28413ee58afa664b793565d6be4da5dfe)) + + +### Bug Fixes + +* use async_to_httpx_files in patch method ([8abb539](https://github.com/scaleapi/scale-agentex-python/commit/8abb539a340af3a2a42482757412c0c408817461)) + +## 0.7.4 (2025-12-17) + +Full Changelog: [v0.7.3...v0.7.4](https://github.com/scaleapi/scale-agentex-python/compare/v0.7.3...v0.7.4) + +### Features + +* **api:** api update ([ed21ad8](https://github.com/scaleapi/scale-agentex-python/commit/ed21ad8c34cd11e80af9128181764489a0541740)) +* **api:** api update ([86a166a](https://github.com/scaleapi/scale-agentex-python/commit/86a166aba5538411ebcc0ed74291505e01a466f2)) +* **api:** api update ([4c95c94](https://github.com/scaleapi/scale-agentex-python/commit/4c95c94df570277fc49281f1343cb012e8da2334)) + + +### Chores + +* **internal:** add missing files argument to base client ([28d1738](https://github.com/scaleapi/scale-agentex-python/commit/28d1738d3af8feb00f6f641e159221fb41c42983)) +* speedup initial import ([8e50946](https://github.com/scaleapi/scale-agentex-python/commit/8e50946321c32e42a7b25cf9ae8b8e9b020a7ac9)) + +## 0.7.3 (2025-12-10) + +Full Changelog: [v0.7.2...v0.7.3](https://github.com/scaleapi/scale-agentex-python/compare/v0.7.2...v0.7.3) + +## 0.7.2 (2025-12-10) + +Full Changelog: [v0.7.1...v0.7.2](https://github.com/scaleapi/scale-agentex-python/compare/v0.7.1...v0.7.2) + +## 0.7.1 (2025-12-09) + +Full Changelog: [v0.7.0...v0.7.1](https://github.com/scaleapi/scale-agentex-python/compare/v0.7.0...v0.7.1) + +### Features + +* **api:** api update ([92b2710](https://github.com/scaleapi/scale-agentex-python/commit/92b2710e0f060a8d59f8d8237c3ca7b8e923867a)) + +## 0.7.0 (2025-12-09) + +Full Changelog: [v0.6.7...v0.7.0](https://github.com/scaleapi/scale-agentex-python/compare/v0.6.7...v0.7.0) + +### Features + +* **api:** add messages/paginated endpoint ([3e03aff](https://github.com/scaleapi/scale-agentex-python/commit/3e03aff8490e0556cb05052d385156eda8f28107)) +* **api:** add messages/paginated to stainless config ([2473ded](https://github.com/scaleapi/scale-agentex-python/commit/2473ded39274bcd0a16d7314667fcf7f55e829c2)) +* **api:** api update ([f6eccdf](https://github.com/scaleapi/scale-agentex-python/commit/f6eccdf975eaef9b257ef3f20f087f2f2f9b3665)) +* **api:** api update ([41067fb](https://github.com/scaleapi/scale-agentex-python/commit/41067fb79725787e0ceb20dcf16029998bcbca24)) +* **api:** api update ([cdc9c63](https://github.com/scaleapi/scale-agentex-python/commit/cdc9c636be6f26e84772d1d1ef9d47cddcd9dabc)) +* **api:** api update ([413d9c8](https://github.com/scaleapi/scale-agentex-python/commit/413d9c806d918d7c5da3d0249c0f11d4b9f0894e)) +* **api:** api update ([1b4bf7d](https://github.com/scaleapi/scale-agentex-python/commit/1b4bf7d3a11306a50ec0eb9c20764c585d0e98e4)) +* **api:** manual updates ([131e836](https://github.com/scaleapi/scale-agentex-python/commit/131e836b5bda8248f847b00308b6711a1ee84ee0)) + + +### Bug Fixes + +* ensure streams are always closed ([7bb9db8](https://github.com/scaleapi/scale-agentex-python/commit/7bb9db851a213d261e585cd2f156046f05cf85db)) +* **types:** allow pyright to infer TypedDict types within SequenceNotStr ([9cfc9d6](https://github.com/scaleapi/scale-agentex-python/commit/9cfc9d66579a11f3eaf248bafbfddb422e878a58)) + + +### Chores + +* add missing docstrings ([81f1fa9](https://github.com/scaleapi/scale-agentex-python/commit/81f1fa9b3c440d893b8ea8f773ab2592eb333d65)) +* **deps:** mypy 1.18.1 has a regression, pin to 1.17 ([e20aaa4](https://github.com/scaleapi/scale-agentex-python/commit/e20aaa495384f547dd18c8d31496f70b4a37e0dd)) +* **docs:** use environment variables for authentication in code snippets ([a30f6ae](https://github.com/scaleapi/scale-agentex-python/commit/a30f6aebca8de5be72eb7bcf7a3b3ccea28479bc)) +* update lockfile ([a3a2e4f](https://github.com/scaleapi/scale-agentex-python/commit/a3a2e4fbcf6e6e4bcbadab50c6b9236e4514dae2)) + +## 0.6.7 (2025-11-19) + +Full Changelog: [v0.6.6...v0.6.7](https://github.com/scaleapi/scale-agentex-python/compare/v0.6.6...v0.6.7) + +## 0.6.6 (2025-11-12) + +Full Changelog: [v0.6.5...v0.6.6](https://github.com/scaleapi/scale-agentex-python/compare/v0.6.5...v0.6.6) + +### Bug Fixes + +* compat with Python 3.14 ([9a62f23](https://github.com/scaleapi/scale-agentex-python/commit/9a62f23376ef797bafe67f61552eb7635286caa3)) +* **compat:** update signatures of `model_dump` and `model_dump_json` for Pydantic v1 ([cf857f9](https://github.com/scaleapi/scale-agentex-python/commit/cf857f9191f10a971e9cba2a8c764229ed4a7dfe)) + + +### Chores + +* **internal:** restore stats ([5ec0383](https://github.com/scaleapi/scale-agentex-python/commit/5ec0383d9d6a85b342263ba49b8e3893924c59fc)) +* **package:** drop Python 3.8 support ([3d4dc37](https://github.com/scaleapi/scale-agentex-python/commit/3d4dc37f87b8d8f1debbe6505746342e461772ba)) + +## 0.6.5 (2025-11-06) + +Full Changelog: [v0.6.4...v0.6.5](https://github.com/scaleapi/scale-agentex-python/compare/v0.6.4...v0.6.5) + +## 0.6.4 (2025-11-06) + +Full Changelog: [v0.6.3...v0.6.4](https://github.com/scaleapi/scale-agentex-python/compare/v0.6.3...v0.6.4) + +## 0.6.3 (2025-11-06) + +Full Changelog: [v0.6.2...v0.6.3](https://github.com/scaleapi/scale-agentex-python/compare/v0.6.2...v0.6.3) + +## 0.6.2 (2025-11-05) + +Full Changelog: [v0.6.1...v0.6.2](https://github.com/scaleapi/scale-agentex-python/compare/v0.6.1...v0.6.2) + +### Features + +* **api:** update via SDK Studio ([b732dfa](https://github.com/scaleapi/scale-agentex-python/commit/b732dfac50cacc90c84a751fd6c75d18fa5b43ed)) + +## 0.6.1 (2025-11-05) + +Full Changelog: [v0.6.0...v0.6.1](https://github.com/scaleapi/scale-agentex-python/compare/v0.6.0...v0.6.1) + +### Features + +* **api:** api update ([f6189a4](https://github.com/scaleapi/scale-agentex-python/commit/f6189a43e1430fdd16c8d10e6ad835d9dfa5871c)) +* **api:** api update ([714c719](https://github.com/scaleapi/scale-agentex-python/commit/714c7194e488e6070c99e200b91189f50dcdb831)) + +## 0.6.0 (2025-11-04) + +Full Changelog: [v0.5.3...v0.6.0](https://github.com/scaleapi/scale-agentex-python/compare/v0.5.3...v0.6.0) + +### Features + +* **api:** api update ([ec61dd3](https://github.com/scaleapi/scale-agentex-python/commit/ec61dd3124fbf169dcdcced262a30bfbed080b5f)) + + +### Chores + +* **internal:** grammar fix (it's -> its) ([36e27da](https://github.com/scaleapi/scale-agentex-python/commit/36e27daed52435b300f090ac4643cd502a817a1e)) + +## 0.5.3 (2025-10-31) + +Full Changelog: [v0.5.2...v0.5.3](https://github.com/scaleapi/scale-agentex-python/compare/v0.5.2...v0.5.3) + +### Chores + +* re apply example updates ([043973b](https://github.com/scaleapi/scale-agentex-python/commit/043973bec649ab2304eff7a313938e1e3e5377e5)) + +## 0.5.2 (2025-10-31) + +Full Changelog: [v0.5.0...v0.5.2](https://github.com/scaleapi/scale-agentex-python/compare/v0.5.0...v0.5.2) + +### Features + +* **api:** manual updates ([dc66b57](https://github.com/scaleapi/scale-agentex-python/commit/dc66b57618525669b3aa15676343ef542675a5f9)) +* bump the helm chart version ([1ffafb0](https://github.com/scaleapi/scale-agentex-python/commit/1ffafb0406138d6abd84254fa394b88c4a28ce70)) + + +### Chores + +* sync repo ([0e05416](https://github.com/scaleapi/scale-agentex-python/commit/0e05416219ca93ae347e6175804bc0f2259a6b44)) + +## 0.5.0 (2025-10-28) + +Full Changelog: [v0.4.28...v0.5.0](https://github.com/scaleapi/agentex-python/compare/v0.4.28...v0.5.0) + +### Features + +* **api:** api update ([129fae6](https://github.com/scaleapi/agentex-python/commit/129fae69844e655b5dd02b6f67c44d15f5dbfa93)) + +## 0.4.28 (2025-10-28) + +Full Changelog: [v0.4.27...v0.4.28](https://github.com/scaleapi/agentex-python/compare/v0.4.27...v0.4.28) + +## 0.4.27 (2025-10-27) + +Full Changelog: [v0.4.26...v0.4.27](https://github.com/scaleapi/agentex-python/compare/v0.4.26...v0.4.27) + +### Features + +* **api:** api update ([f5e4fd2](https://github.com/scaleapi/agentex-python/commit/f5e4fd2f2fbb2c7e67e51795fba1f0b2e13048de)) + +## 0.4.26 (2025-10-21) + +Full Changelog: [v0.4.25...v0.4.26](https://github.com/scaleapi/agentex-python/compare/v0.4.25...v0.4.26) + +### Features + +* **api:** api update ([0c1dedd](https://github.com/scaleapi/agentex-python/commit/0c1dedd0fecb05e3684f110cc589f2abe55acb97)) +* **api:** api update ([719dc74](https://github.com/scaleapi/agentex-python/commit/719dc74f7844e2a3c14e46996e353d9c632b8e0a)) + + +### Chores + +* bump `httpx-aiohttp` version to 0.1.9 ([21c7921](https://github.com/scaleapi/agentex-python/commit/21c79210a0d65944fec5010fcc581a2d85fb94ab)) + +## 0.4.25 (2025-10-10) + +Full Changelog: [v0.4.24...v0.4.25](https://github.com/scaleapi/agentex-python/compare/v0.4.24...v0.4.25) + +## 0.4.24 (2025-10-10) + +Full Changelog: [v0.4.23...v0.4.24](https://github.com/scaleapi/agentex-python/compare/v0.4.23...v0.4.24) + +### Features + +* **api:** manual updates ([09996ea](https://github.com/scaleapi/agentex-python/commit/09996ea688a7225670bdd9d944b64801fac7acce)) + + +### Bug Fixes + +* health check port handling ([#138](https://github.com/scaleapi/agentex-python/issues/138)) ([fe22301](https://github.com/scaleapi/agentex-python/commit/fe223012db49768f38c4de56b5d5744031b631d1)) + + +### Chores + +* do not install brew dependencies in ./scripts/bootstrap by default ([2675e14](https://github.com/scaleapi/agentex-python/commit/2675e14bf9f3a0113a849caf2283376c448f9d03)) +* improve example values ([6997fe5](https://github.com/scaleapi/agentex-python/commit/6997fe57910ea54d6d71b25fdea4497925c8ec63)) +* **internal:** detect missing future annotations with ruff ([f1aa71f](https://github.com/scaleapi/agentex-python/commit/f1aa71f89bb0e8369e6d895b5111dc15fd1e2c12)) +* **internal:** update pydantic dependency ([156ea64](https://github.com/scaleapi/agentex-python/commit/156ea64a4fa317d3ab483e7b9b6ba63471b618ef)) +* **internal:** version bump ([8567752](https://github.com/scaleapi/agentex-python/commit/85677527f5c8d393f0eea0a2a629da48fb56f4a9)) +* **internal:** version bump ([45206dd](https://github.com/scaleapi/agentex-python/commit/45206dd28643403800c386b75e1c9a442c8978ae)) +* **internal:** version bump ([98354ba](https://github.com/scaleapi/agentex-python/commit/98354ba2e7630798e25a8e278cba44c1aacc1e08)) +* **internal:** version bump ([aa2a8db](https://github.com/scaleapi/agentex-python/commit/aa2a8db5907f78b4b39849a1900dae27412359bb)) +* **internal:** version bump ([73bba2a](https://github.com/scaleapi/agentex-python/commit/73bba2a59e77fa31caab5b668781b71bc7c5ec2d)) +* **types:** change optional parameter type from NotGiven to Omit ([2117d77](https://github.com/scaleapi/agentex-python/commit/2117d77219da097e784d5d2deab1632a2855dae9)) + +## 0.4.23 (2025-10-02) + +Full Changelog: [v0.4.22...v0.4.23](https://github.com/scaleapi/agentex-python/compare/v0.4.22...v0.4.23) + +### Features + +* Adding Agent info to SGP tracing metadata ([#85](https://github.com/scaleapi/agentex-python/issues/85)) ([900f66b](https://github.com/scaleapi/agentex-python/commit/900f66b60bc61ac515a7e43172d573a31c623fa9)) + +## 0.4.22 (2025-10-01) + +Full Changelog: [v0.4.21...v0.4.22](https://github.com/scaleapi/agentex-python/compare/v0.4.21...v0.4.22) + +## 0.4.21 (2025-10-01) + +Full Changelog: [v0.4.20...v0.4.21](https://github.com/scaleapi/agentex-python/compare/v0.4.20...v0.4.21) + +## 0.4.20 (2025-10-01) + +Full Changelog: [v0.4.19...v0.4.20](https://github.com/scaleapi/agentex-python/compare/v0.4.19...v0.4.20) + +## 0.4.19 (2025-10-01) + +Full Changelog: [v0.4.18...v0.4.19](https://github.com/scaleapi/agentex-python/compare/v0.4.18...v0.4.19) + +### Features + +* Adds helm config to Agent Environment ([#125](https://github.com/scaleapi/agentex-python/issues/125)) ([e4b39b5](https://github.com/scaleapi/agentex-python/commit/e4b39b5f319452bbc6650a7ef41b3a3179bb3b93)) + +## 0.4.18 (2025-09-29) + +Full Changelog: [v0.4.17...v0.4.18](https://github.com/scaleapi/agentex-python/compare/v0.4.17...v0.4.18) + +### Chores + +* **internal:** version bump ([eded756](https://github.com/scaleapi/agentex-python/commit/eded756bde2f3b4cfcf02c7a9cf72e70a82dd9aa)) + +## 0.4.17 (2025-09-29) + +Full Changelog: [v0.4.16...v0.4.17](https://github.com/scaleapi/agentex-python/compare/v0.4.16...v0.4.17) + +## 0.4.16 (2025-09-16) + +Full Changelog: [v0.4.15...v0.4.16](https://github.com/scaleapi/agentex-python/compare/v0.4.15...v0.4.16) + +## 0.4.15 (2025-09-16) + +Full Changelog: [v0.4.14...v0.4.15](https://github.com/scaleapi/agentex-python/compare/v0.4.14...v0.4.15) + +## 0.4.14 (2025-09-16) + +Full Changelog: [v0.4.13...v0.4.14](https://github.com/scaleapi/agentex-python/compare/v0.4.13...v0.4.14) + +### Features + +* add previous_response_id parameter to OpenAI module ([7a78844](https://github.com/scaleapi/agentex-python/commit/7a78844f9efbfac606c7e52d1f469db0728c9e56)) + +## 0.4.13 (2025-09-12) + +Full Changelog: [v0.4.12...v0.4.13](https://github.com/scaleapi/agentex-python/compare/v0.4.12...v0.4.13) + +### Features + +* **api:** api update ([0102183](https://github.com/scaleapi/agentex-python/commit/0102183a8f5a23dbdaf905ffbe7ffbcf59bf7b21)) +* **api:** api update ([8a6edb1](https://github.com/scaleapi/agentex-python/commit/8a6edb13046ca24bf6c45fc018e32de498d48869)) + +## 0.4.12 (2025-09-08) + +Full Changelog: [v0.4.11...v0.4.12](https://github.com/scaleapi/agentex-python/compare/v0.4.11...v0.4.12) + +### ⚠ BREAKING CHANGES + +* task_cancel now requires explicit agent_name/agent_id parameter to identify which agent owns the task being cancelled + +### Bug Fixes + +* task cancellation architectural bug ([f9a72a9](https://github.com/scaleapi/agentex-python/commit/f9a72a94f96afe86d3cc80f4f85ea368279d4517)) + +## 0.4.11 (2025-09-04) + +Full Changelog: [v0.4.10...v0.4.11](https://github.com/scaleapi/agentex-python/compare/v0.4.10...v0.4.11) + +### Features + +* Guardrail support ([e3e9bf9](https://github.com/scaleapi/agentex-python/commit/e3e9bf9dd6cf16b9a783638690d4a31914be8139)) +* improve future compat with pydantic v3 ([f0d8624](https://github.com/scaleapi/agentex-python/commit/f0d86244065c88bb2777db8fabeb1921e7e01116)) +* multiple guardrails ([ea8f98a](https://github.com/scaleapi/agentex-python/commit/ea8f98a973ba486e854cf14528a88eb73a203cf8)) +* **templates:** add custom activity timeout guidance for temporal agents ([7658256](https://github.com/scaleapi/agentex-python/commit/765825680132677ea0351f2a9410f472ee754906)) +* **types:** replace List[str] with SequenceNotStr in params ([f319781](https://github.com/scaleapi/agentex-python/commit/f3197818637574cd92b2c1f710679155eddf5af7)) + + +### Bug Fixes + +* Adding new example for guardrails instead of using 10_async ([15dc44b](https://github.com/scaleapi/agentex-python/commit/15dc44b333a977564c9974cc089d5ef578840714)) +* avoid newer type syntax ([6b5c82a](https://github.com/scaleapi/agentex-python/commit/6b5c82aab9ebcf755575b641aced2b77a13a71c3)) + + +### Chores + +* **internal:** add Sequence related utils ([496034d](https://github.com/scaleapi/agentex-python/commit/496034db4d6cba361c1f392a4bb86f6ab057e878)) +* **internal:** change ci workflow machines ([7445d94](https://github.com/scaleapi/agentex-python/commit/7445d94cb860f92911ec97ecd951149557956c6a)) +* **internal:** move mypy configurations to `pyproject.toml` file ([e96cd34](https://github.com/scaleapi/agentex-python/commit/e96cd34629d5ea173446c3184fbfe28bd2b370a0)) +* **internal:** update pyright exclude list ([d952430](https://github.com/scaleapi/agentex-python/commit/d952430ab4cbc41bca06010bbcfea3eeb022073e)) + +## 0.4.10 (2025-08-24) + +Full Changelog: [v0.4.9...v0.4.10](https://github.com/scaleapi/agentex-python/compare/v0.4.9...v0.4.10) + +## 0.4.9 (2025-08-22) + +Full Changelog: [v0.4.8...v0.4.9](https://github.com/scaleapi/agentex-python/compare/v0.4.8...v0.4.9) + +## 0.4.8 (2025-08-22) + +Full Changelog: [v0.4.7...v0.4.8](https://github.com/scaleapi/agentex-python/compare/v0.4.7...v0.4.8) + +## 0.4.7 (2025-08-22) + +Full Changelog: [v0.4.6...v0.4.7](https://github.com/scaleapi/agentex-python/compare/v0.4.6...v0.4.7) + +### Chores + +* update github action ([677e95d](https://github.com/scaleapi/agentex-python/commit/677e95de075b7031cfc4971d7d09769daaa5b2af)) + +## 0.4.6 (2025-08-20) + +Full Changelog: [v0.4.5...v0.4.6](https://github.com/scaleapi/agentex-python/compare/v0.4.5...v0.4.6) + +### Features + +* **api:** api update ([7b4c80a](https://github.com/scaleapi/agentex-python/commit/7b4c80acb502c29df63a3d66a1b29b653d2e3cf5)) + + +### Chores + +* generate release ([0836e4a](https://github.com/scaleapi/agentex-python/commit/0836e4a632e8f3aa0cd05fc6b61581f8c8be9bcd)) + +## 0.4.5 (2025-08-20) + +Full Changelog: [v0.4.4...v0.4.5](https://github.com/scaleapi/agentex-python/compare/v0.4.4...v0.4.5) + +### Features + +* **api:** manual updates ([34a53aa](https://github.com/scaleapi/agentex-python/commit/34a53aa28b8f862d74dd1603d92b7dd5dd28ddb1)) + + +### Bug Fixes + +* enable FunctionTool serialization for Temporal worker nodes ([c9eb040](https://github.com/scaleapi/agentex-python/commit/c9eb04002825195187cd58f34c9185349a63566e)) +* **tools:** handle callable objects in model serialization to facilitate tool calling ([4e9bb87](https://github.com/scaleapi/agentex-python/commit/4e9bb87d7faa2c2e1643893a168f7c6affd2809d)) + + +### Chores + +* demonstrate FunctionTool use in a (temporal) tutorial ([3a72043](https://github.com/scaleapi/agentex-python/commit/3a7204333c328fab8ba0f1d31fd26994ea176ecf)) + +## 0.4.4 (2025-08-17) + +Full Changelog: [v0.4.3...v0.4.4](https://github.com/scaleapi/agentex-python/compare/v0.4.3...v0.4.4) + +## 0.4.3 (2025-08-17) + +Full Changelog: [v0.4.2...v0.4.3](https://github.com/scaleapi/agentex-python/compare/v0.4.2...v0.4.3) + +## 0.4.2 (2025-08-17) + +Full Changelog: [v0.4.1...v0.4.2](https://github.com/scaleapi/agentex-python/compare/v0.4.1...v0.4.2) + +## 0.4.1 (2025-08-16) + +Full Changelog: [v0.4.0...v0.4.1](https://github.com/scaleapi/agentex-python/compare/v0.4.0...v0.4.1) + +## 0.4.0 (2025-08-15) + +Full Changelog: [v0.3.0...v0.4.0](https://github.com/scaleapi/agentex-python/compare/v0.3.0...v0.4.0) + +### Features + +* **api:** manual updates ([ce2a201](https://github.com/scaleapi/agentex-python/commit/ce2a201227ff6659874672fc7c6a890f25dfaa08)) +* **api:** manual updates ([7afbafd](https://github.com/scaleapi/agentex-python/commit/7afbafd03fdcbd464305fe6f0592141117d3527c)) + +## 0.3.0 (2025-08-14) + +Full Changelog: [v0.2.10...v0.3.0](https://github.com/scaleapi/agentex-python/compare/v0.2.10...v0.3.0) + +### Features + +* **api:** api update ([ad779b4](https://github.com/scaleapi/agentex-python/commit/ad779b4ce6a9f21b4f69c88770269b404ac25818)) +* **api:** manual updates ([9dc2f75](https://github.com/scaleapi/agentex-python/commit/9dc2f7511750884ec6754d91e6d27592f85b72e5)) + +## 0.2.10 (2025-08-13) + +Full Changelog: [v0.2.9...v0.2.10](https://github.com/scaleapi/agentex-python/compare/v0.2.9...v0.2.10) + +## 0.2.9 (2025-08-12) + +Full Changelog: [v0.2.8...v0.2.9](https://github.com/scaleapi/agentex-python/compare/v0.2.8...v0.2.9) + +### Chores + +* **internal:** update test skipping reason ([4affc92](https://github.com/scaleapi/agentex-python/commit/4affc925c69ed626d429732b470d4d1535b1be8d)) + +## 0.2.8 (2025-08-09) + +Full Changelog: [v0.2.7...v0.2.8](https://github.com/scaleapi/agentex-python/compare/v0.2.7...v0.2.8) + +### Chores + +* **internal:** update comment in script ([401f1d7](https://github.com/scaleapi/agentex-python/commit/401f1d79034ecb0b556a26debde79681bc21e8ae)) +* update @stainless-api/prism-cli to v5.15.0 ([4d332d0](https://github.com/scaleapi/agentex-python/commit/4d332d0f77a5a11ca6781a5fc7690ae82653cadb)) + +## 0.2.7 (2025-08-08) + +Full Changelog: [v0.2.6...v0.2.7](https://github.com/scaleapi/agentex-python/compare/v0.2.6...v0.2.7) + +### Features + +* **api:** api update ([e3d08ba](https://github.com/scaleapi/agentex-python/commit/e3d08baad59346db48e04a394a929d6347dafa07)) +* debug features ([40d8db2](https://github.com/scaleapi/agentex-python/commit/40d8db22dcc8f00a6c78e9bc3e1d036ebd1423b6)) + + +### Chores + +* **internal:** fix ruff target version ([1b880e1](https://github.com/scaleapi/agentex-python/commit/1b880e1dd81d47bb9df12507f13351611ff6367f)) + +## 0.2.6 (2025-08-01) + +Full Changelog: [v0.2.5...v0.2.6](https://github.com/scaleapi/agentex-python/compare/v0.2.5...v0.2.6) + +### Features + +* **api:** add query params to tasks.list ([d4902d5](https://github.com/scaleapi/agentex-python/commit/d4902d52caf82e2f57d1bbf19527cdc1448ed397)) +* **client:** support file upload requests ([e004b30](https://github.com/scaleapi/agentex-python/commit/e004b304c22286151330c2200bcb85046a7ac111)) + +## 0.2.5 (2025-07-30) + +Full Changelog: [v0.2.4...v0.2.5](https://github.com/scaleapi/agentex-python/compare/v0.2.4...v0.2.5) + +### Features + +* **api:** api update ([f90002c](https://github.com/scaleapi/agentex-python/commit/f90002c247a94cddc17307fb4eded12359cc9ad8)) +* **api:** api update ([aee4ad1](https://github.com/scaleapi/agentex-python/commit/aee4ad10e588386e9af1b4828d16ddba1805dca0)) +* **api:** manual updates ([55efcdd](https://github.com/scaleapi/agentex-python/commit/55efcdd55f2a20d1172da95cd551751d8be0d0df)) + +## 0.2.4 (2025-07-29) + +Full Changelog: [v0.2.3...v0.2.4](https://github.com/scaleapi/agentex-python/compare/v0.2.3...v0.2.4) + +## 0.2.3 (2025-07-29) + +Full Changelog: [v0.2.2...v0.2.3](https://github.com/scaleapi/agentex-python/compare/v0.2.2...v0.2.3) + +## 0.2.2 (2025-07-28) + +Full Changelog: [v0.2.1...v0.2.2](https://github.com/scaleapi/agentex-python/compare/v0.2.1...v0.2.2) + +### Features + +* **api:** api update ([eb79533](https://github.com/scaleapi/agentex-python/commit/eb79533dd041b7fccccc6a75abedd0c87e9c55e5)) + +## 0.2.1 (2025-07-27) + +Full Changelog: [v0.2.0...v0.2.1](https://github.com/scaleapi/agentex-python/compare/v0.2.0...v0.2.1) + +## 0.2.0 (2025-07-25) + +Full Changelog: [v0.1.1...v0.2.0](https://github.com/scaleapi/agentex-python/compare/v0.1.1...v0.2.0) + +### Features + +* **api:** update typescript sdk with big changes ([2c75d64](https://github.com/scaleapi/agentex-python/commit/2c75d642348df727505778c347efa568930ea4f0)) + + +### Chores + +* **project:** add settings file for vscode ([0f926cc](https://github.com/scaleapi/agentex-python/commit/0f926cce7df375de33627f8212caacf64f89b1ed)) + +## 0.1.1 (2025-07-24) + +Full Changelog: [v0.1.0...v0.1.1](https://github.com/scaleapi/agentex-python/compare/v0.1.0...v0.1.1) + +### Features + +* **api:** manual updates ([714e97e](https://github.com/scaleapi/agentex-python/commit/714e97ed1813a4a91b421fb77fadaf2afac2450d)) +* **api:** manual updates ([8dccfbd](https://github.com/scaleapi/agentex-python/commit/8dccfbdd9b8b887bfb99c79a9a28163215560ae4)) +* **api:** manual updates ([03af884](https://github.com/scaleapi/agentex-python/commit/03af884e31a3df4d42a863c06c5ab4dfc2374374)) + +## 0.1.0 (2025-07-23) + +Full Changelog: [v0.1.0-alpha.6...v0.1.0](https://github.com/scaleapi/agentex-python/compare/v0.1.0-alpha.6...v0.1.0) + +### Features + +* **api:** manual updates ([84010e4](https://github.com/scaleapi/agentex-python/commit/84010e4adecf7c779abd9a828000a3b50d9d3ac3)) + +## 0.1.0-alpha.6 (2025-07-23) + +Full Changelog: [v0.1.0-alpha.5...v0.1.0-alpha.6](https://github.com/scaleapi/agentex-python/compare/v0.1.0-alpha.5...v0.1.0-alpha.6) + +### Features + +* **api:** api update ([af18034](https://github.com/scaleapi/agentex-python/commit/af18034e4173794ebf42eff688f26d64caca4e64)) +* **api:** api update ([be9b603](https://github.com/scaleapi/agentex-python/commit/be9b60326817566d5c5edcbd7b7babb6db07e539)) +* **api:** manual updates ([bbe3be3](https://github.com/scaleapi/agentex-python/commit/bbe3be30aa9fb8d7a677f0e9f0be4dd565563d6e)) + +## 0.1.0-alpha.5 (2025-07-23) + +Full Changelog: [v0.1.0-alpha.4...v0.1.0-alpha.5](https://github.com/scaleapi/agentex-python/compare/v0.1.0-alpha.4...v0.1.0-alpha.5) + +### Features + +* **api:** deprecate name subresource ([14881c0](https://github.com/scaleapi/agentex-python/commit/14881c0ff2922e0a622975a0f5b314de99d7aabb)) +* **api:** manual updates ([d999a43](https://github.com/scaleapi/agentex-python/commit/d999a438c409f04b7e36b5df2d9b080d1d1b0e4a)) +* **api:** manual updates ([a885d8d](https://github.com/scaleapi/agentex-python/commit/a885d8dbabfe2cc2a556ef02e75e5502fd799c46)) + + +### Bug Fixes + +* **api:** build errors ([7bde6b7](https://github.com/scaleapi/agentex-python/commit/7bde6b727d6d16ebd6805ef843596fc3224445a6)) +* **parsing:** parse extra field types ([d40e6e0](https://github.com/scaleapi/agentex-python/commit/d40e6e0d6911be0bc9bfc419e02bd7c1d5ad5be4)) + +## 0.1.0-alpha.4 (2025-07-22) + +Full Changelog: [v0.1.0-alpha.3...v0.1.0-alpha.4](https://github.com/scaleapi/agentex-python/compare/v0.1.0-alpha.3...v0.1.0-alpha.4) + +## 0.1.0-alpha.3 (2025-07-22) + +Full Changelog: [v0.1.0-alpha.2...v0.1.0-alpha.3](https://github.com/scaleapi/agentex-python/compare/v0.1.0-alpha.2...v0.1.0-alpha.3) + +### Features + +* **api:** api update ([afedf45](https://github.com/scaleapi/agentex-python/commit/afedf4541ba6219cd04ef7af39a1d451abde75a4)) + +## 0.1.0-alpha.2 (2025-07-22) + +Full Changelog: [v0.1.0-alpha.1...v0.1.0-alpha.2](https://github.com/scaleapi/agentex-python/compare/v0.1.0-alpha.1...v0.1.0-alpha.2) + +## 0.1.0-alpha.1 (2025-07-22) + +Full Changelog: [v0.0.1-alpha.1...v0.1.0-alpha.1](https://github.com/scaleapi/agentex-python/compare/v0.0.1-alpha.1...v0.1.0-alpha.1) + +### Features + +* **api:** manual updates ([06f5fe1](https://github.com/scaleapi/agentex-python/commit/06f5fe115ace5ec4ca8149cd0afa6207b193a04c)) + +## 0.0.1-alpha.1 (2025-07-22) + +Full Changelog: [v0.0.1-alpha.0...v0.0.1-alpha.1](https://github.com/scaleapi/agentex-python/compare/v0.0.1-alpha.0...v0.0.1-alpha.1) + +### Chores + +* sync repo ([bc305f4](https://github.com/scaleapi/agentex-python/commit/bc305f43efedb5b7d7b28eaa059bce1d280c9dbb)) +* update SDK settings ([e5a06b4](https://github.com/scaleapi/agentex-python/commit/e5a06b4e3d8f8ad15d55b92393d7ddd833415f86)) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..7dd7f2ed3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,133 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Contribution workflow + +- This repository is a Stainless-generated SDK. Open PRs against the `next` branch (not `main`). + Stainless watches `next` and release-please opens release PRs from `next` → `main`. +- PR titles must follow [Conventional Commits](https://www.conventionalcommits.org/) — the + `Validate PR title (Conventional Commits)` CI check enforces this on every PR. +- The `Validate PR base branch` CI check fails on PRs targeting `main` from non-automation accounts + and posts a comment with resolution steps. Add the `target-main` label only for genuine + exceptions (e.g. an urgent hotfix). +- See `CONTRIBUTING.md` for the full workflow. + +## Development Commands + +### Package Management in the top level repo +- Use `rye` for dependency management (preferred) +- Run `./scripts/bootstrap` to set up the environment +- Or use `rye sync --all-features` directly + +Special note: the individual tutorials maintain their own tutorial specific virtualenv using `uv`. So when testing/running tutorials, you `uv run` instead of `rye run`. Everything else is similar. + +#### Testing +- Run tests: `rye run pytest` or `./scripts/test` +- Run specific test: `rye run pytest path/to/test_file.py::TestClass::test_method -v` +- Mock server is automatically started for tests, runs on port 4010 + +#### Linting and Formatting +- Format code: `rye run format` or `./scripts/format` + * The repository is still in flux, so running format might accidentally change files that aren't part of your scope of changes. So always run `run rye format` with additional arguments to constrain the formatting to the files that you are modifying. +- Lint code: `rye run lint` or `./scripts/lint` +- Type check: `rye run typecheck` (runs both pyright and mypy) + +### Building and Running +- Build package: `rye build` + + + +### CLI Commands +The package provides the `agentex` CLI with these main commands: +- `agentex agents` - Get, list, run, build, and deploy agents +- `agentex tasks` - Get, list, and delete tasks +- `agentex secrets` - Sync, get, list, and delete secrets +- `agentex uv` - UV wrapper with AgentEx-specific enhancements +- `agentex init` - Initialize new agent projects + +### Agent Development +- Run agents: `agentex agents run --manifest manifest.yaml` +- Debug agents: `agentex agents run --manifest manifest.yaml --debug-worker` +- Debug with custom port: `agentex agents run --manifest manifest.yaml --debug-worker --debug-port 5679` + +## Architecture Overview + +### Code Structure +- `/src/agentex/` - Core SDK and generated API client code +- `/src/agentex/protocol/` - **Canonical** location for wire-protocol shapes + (JSON-RPC envelopes, ACP method-param types). Depends only on `pydantic` + and the Stainless-generated `agentex.types.*` surface, so it is safe to + import from a future slim REST-only install. + - `acp.py` - `RPCMethod`, `CreateTaskParams`, `SendMessageParams`, + `SendEventParams`, `CancelTaskParams`, `RPC_SYNC_METHODS`, + `PARAMS_MODEL_BY_METHOD` + - `json_rpc.py` - `JSONRPCRequest`, `JSONRPCResponse`, `JSONRPCError` +- `/src/agentex/config/` - **Canonical** location for deployment/agent + configuration models (manifest shapes). Depends only on `pydantic`, so it is + safe to import from a slim REST-only install. + - `agent_config.py`, `build_config.py`, `deployment_config.py`, + `local_development_config.py`, `environment_config.py`, `agent_manifest.py` + (model classes only), plus their model deps `credentials.py` and + `agent_configs.py` + - yaml loaders / build machinery (`load_environments_config*`, + `load_agent_manifest`, `build_context_manager`, `BuildContextManager`) stay + in `agentex.lib.sdk.config.*` so these models stay slim-safe +- `/src/agentex/lib/` - Custom library code (not modified by code generator) + - `/cli/` - Command-line interface implementation + - `/core/` - Core services, adapters, and temporal workflows + - `/sdk/` - SDK utilities and FastACP implementation + - `config/` - manifest loaders + Docker build machinery (`agent_manifest`'s + `load_agent_manifest`/`build_context_manager`/`BuildContextManager`, + `validation`, `project_config`) plus **back-compat shims** for the model + classes now canonical under `agentex.config.*` + - `/types/` - Custom type definitions + - `acp.py`, `json_rpc.py` - **back-compat shims** re-exporting from + `agentex.protocol.*`. `credentials.py`, `agent_configs.py` - shims + re-exporting from `agentex.config.*`. Existing `from agentex.lib...` + imports keep working; new code should import from the canonical paths. + - Other modules (`tracing`, `agent_card`, `fastacp`, `llm_messages`, + `converters`, etc.) stay here — they have heavier transitive deps + (temporal, openai-agents, model_utils/yaml) and aren't slim-safe. + - `/utils/` - Utility functions +- `/examples/` - Example implementations and tutorials +- `/tests/` - Test suites + +### Key Components + +**SDK Architecture:** +- **Client Layer**: HTTP client for AgentEx API (`_client.py`, `resources/`) +- **CLI Layer**: Typer-based command interface (`lib/cli/`) +- **Core Services**: Temporal workflows, adapters, and services (`lib/core/`) +- **FastACP**: Fast Agent Communication Protocol implementation (`lib/sdk/fastacp/`) +- **State Machine**: Workflow state management (`lib/sdk/state_machine/`) + +**Temporal Integration:** +- Workflow definitions in `lib/core/temporal/` +- Activity definitions for different providers +- Worker implementations for running temporal workflows + +**Agent Framework:** +- Manifest-driven agent configuration +- Support for multiple agent types (sync, temporal-based) +- Debugging support with VS Code integration + +### Code Generation +Most SDK code is auto-generated. Manual changes are preserved in: +- `src/agentex/lib/` directory +- `examples/` directory +- Merge conflicts may occur between manual patches and generator changes + +### Key Dependencies +- `temporalio` - Temporal workflow engine +- `typer` - CLI framework +- `pydantic` - Data validation +- `httpx` - HTTP client +- `fastapi` - Web framework +- `ruff` - Linting and formatting +- `pytest` - Testing framework + +### Environment Requirements +- Python 3.12+ required +- Uses Rye for dependency management +- Supports both sync and async client patterns \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..e8e6e8813 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,159 @@ +## Setting up the environment + +### With `uv` + +We use [uv](https://docs.astral.sh/uv/) to manage dependencies because it will automatically provision a Python environment with the expected Python version. To set it up, run: + +```sh +$ ./scripts/bootstrap +``` + +Or [install uv manually](https://docs.astral.sh/uv/getting-started/installation/) and run: + +```sh +$ uv sync --all-extras +``` + +You can then run scripts using `uv run python script.py` or by manually activating the virtual environment: + +```sh +# manually activate - https://docs.python.org/3/library/venv.html#how-venvs-work +$ source .venv/bin/activate + +# now you can omit the `uv run` prefix +$ python script.py +``` + +### Without `uv` + +Alternatively if you don't want to install `uv`, you can stick with the standard `pip` setup by ensuring you have the Python version specified in `.python-version`, create a virtual environment however you desire and then install dependencies using this command: + +```sh +$ pip install -r requirements-dev.lock +``` + +## Contribution workflow + +This repository is generated and released by [Stainless](https://www.stainless.com/). To keep the +release pipeline working, contributions need to follow the branch model and commit conventions below. + +### Branch model + +- Always open PRs against the `next` branch — not `main`. Stainless watches `next` to produce SDK + builds and the automated version-bump PR. +- Typical flow: + 1. Pull the latest `next` locally and branch off it. + 2. Make and push your changes, then open a PR targeting `next`. + 3. Get the PR reviewed and merged into `next`. + 4. Stainless will open (or update) a release PR bumping the version — review and merge that PR + to ship to `main`/PyPI. A new release PR will not be cut while a previous one is still open, + so unblock pending release PRs before expecting a new one. +- Do not merge generated code directly into `next` via PR. Let the generator produce those changes. +- The `Validate PR base branch` CI check fails on PRs targeting `main` from non-automation accounts + and posts a comment with resolution steps. If you genuinely need to PR directly to `main` (e.g. an + urgent hotfix), add the `target-main` label to bypass the check. + +### Conventional commits + +Commit messages and PR titles must follow [Conventional Commits](https://www.conventionalcommits.org/), +because the changelog and release notes are derived from them. The `Validate PR title (Conventional Commits)` +CI check enforces this on every PR. Common prefixes: + +- `feat(api): ...` — new functionality +- `fix(types): ...` — bug fixes +- `docs(readme): ...` — documentation-only changes (required for manual README/docs overrides to be + picked up by the generator) +- `chore(internal): ...` — internal changes that don't affect users + +## Modifying/Adding code + +Most of the SDK is generated code. Modifications to code will be persisted between generations, but may +result in merge conflicts between manual patches and changes from the generator. The generator will never +modify the contents of the `src/agentex/lib/` and `examples/` directories. + +## Adding and running examples + +All files in the `examples/` directory are not modified by the generator and can be freely edited or added to. + +```py +# add an example to examples/.py + +#!/usr/bin/env -S uv run python +… +``` + +```sh +$ chmod +x examples/.py +# run the example against your api +$ ./examples/.py +``` + +## Using the repository from source + +If you’d like to use the repository from source, you can either install from git or link to a cloned repository: + +To install via git: + +```sh +$ pip install git+ssh://git@github.com/scaleapi/scale-agentex-python.git +``` + +Alternatively, you can build from source and install the wheel file: + +Building this package will create two files in the `dist/` directory, a `.tar.gz` containing the source files and a `.whl` that can be used to install the package efficiently. + +To create a distributable version of the library, all you have to do is run this command: + +```sh +$ uv build +# or +$ python -m build +``` + +Then to install: + +```sh +$ pip install ./path-to-wheel-file.whl +``` + +## Running tests + +```sh +$ ./scripts/test +``` + +## Linting and formatting + +This repository uses [ruff](https://github.com/astral-sh/ruff) and +[black](https://github.com/psf/black) to format the code in the repository. + +To lint: + +```sh +$ ./scripts/lint +``` + +To format and fix all ruff issues automatically: + +```sh +$ ./scripts/format +``` + +## Publishing and releases + +Changes made to this repository via the automated release PR pipeline should publish to PyPI automatically. If +the changes aren't made through the automated pipeline, you may want to make releases manually. + +### Publish with a GitHub workflow + +You can release to package managers by using [the `Publish PyPI` GitHub action](https://www.github.com/scaleapi/scale-agentex-python/actions/workflows/publish-pypi.yml). This requires a setup organization or repository secret to be set up. + +### Publish manually + +If you need to manually release a package, you can run the `bin/publish-pypi` script with a `PYPI_TOKEN` set on +the environment. + +## 🤖 **Vibe Coding Setup** + +This repository is setup with some pre-canned prompts for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) as well as [Cursor](https://cursor.com/). + diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..23fcbcddb --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Agentex + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 7264db847..cfe1611d8 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,414 @@ -# repository-template -A repository template for repository creation at Scale AI. + +# Agentex Python API library + + +[![PyPI version](https://img.shields.io/pypi/v/agentex-client.svg?label=pypi%20(stable))](https://pypi.org/project/agentex-client/) + +The Agentex Python library provides convenient access to the Agentex REST API from any Python 3.9+ +application. The library includes type definitions for all request params and response fields, +and offers both synchronous and asynchronous clients powered by [httpx](https://github.com/encode/httpx). + +It is generated with [Stainless](https://www.stainless.com/). + +## Documentation + +The REST API documentation can be found on [docs.gp.scale.com](https://docs.gp.scale.com). The full API of this library can be found in [api.md](api.md). + +## Installation + +```sh +# install from PyPI +pip install agentex-client +``` ## Usage -### Automatic -Request a new repository from the slackbot `Onyx` using `/onyx` and input the appropriate information such as desired language(s). -Optionally scaffolds `CODEOWNERS` and `entity.datadog.yaml` when a GitHub owning team is selected. Workflow inputs `github_owner` and `description` are optional and skipped when unset. +The full API of this library can be found in [api.md](api.md). + +```python +import os +from agentex import Agentex + +client = Agentex( + api_key=os.environ.get("AGENTEX_SDK_API_KEY"), # This is the default and can be omitted + # defaults to "production". + environment="development", +) + +tasks = client.tasks.list() +``` + +While you can provide an `api_key` keyword argument, +we recommend using [python-dotenv](https://pypi.org/project/python-dotenv/) +to add `AGENTEX_SDK_API_KEY="My API Key"` to your `.env` file +so that your API Key is not stored in source control. + +## Async usage + +Simply import `AsyncAgentex` instead of `Agentex` and use `await` with each API call: + +```python +import os +import asyncio +from agentex import AsyncAgentex + +client = AsyncAgentex( + api_key=os.environ.get("AGENTEX_SDK_API_KEY"), # This is the default and can be omitted + # defaults to "production". + environment="development", +) + + +async def main() -> None: + tasks = await client.tasks.list() + + +asyncio.run(main()) +``` + +Functionality between the synchronous and asynchronous clients is otherwise identical. + +## Debugging + +AgentEx provides built-in debugging support for **temporal projects** during local development. + +```bash +# Basic debugging +uv run agentex agents run --manifest manifest.yaml --debug-worker + +# Wait for debugger to attach before starting +uv run agentex agents run --manifest manifest.yaml --debug-worker --wait-for-debugger + +# Custom debug port +uv run agentex agents run --manifest manifest.yaml --debug-worker --debug-port 5679 +``` + +For **VS Code**, add this configuration to `.vscode/launch.json`: + +```json +{ + "name": "Attach to AgentEx Worker", + "type": "debugpy", + "request": "attach", + "connect": { "host": "localhost", "port": 5678 }, + "pathMappings": [{ "localRoot": "${workspaceFolder}", "remoteRoot": "." }], + "justMyCode": false, + "console": "integratedTerminal" +} +``` + +The debug server automatically finds an available port starting from 5678 and prints connection details when starting. + +### With aiohttp + +By default, the async client uses `httpx` for HTTP requests. However, for improved concurrency performance you may also use `aiohttp` as the HTTP backend. + +You can enable this by installing `aiohttp`: + +```sh +# install from PyPI +pip install agentex-client[aiohttp] +``` + +Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: + +```python +import os +import asyncio +from agentex import DefaultAioHttpClient +from agentex import AsyncAgentex + + +async def main() -> None: + async with AsyncAgentex( + api_key=os.environ.get("AGENTEX_SDK_API_KEY"), # This is the default and can be omitted + http_client=DefaultAioHttpClient(), + ) as client: + tasks = await client.tasks.list() + + +asyncio.run(main()) +``` + +## Using types + +Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typing.html#typing.TypedDict). Responses are [Pydantic models](https://docs.pydantic.dev) which also provide helper methods for things like: + +- Serializing back into JSON, `model.to_json()` +- Converting to a dictionary, `model.to_dict()` + +Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`. + +## Nested params + +Nested parameters are dictionaries, typed using `TypedDict`, for example: + +```python +from agentex import Agentex + +client = Agentex() + +schedule = client.agents.schedules.create( + agent_id="agent_id", + initial_input={"content": "content"}, + name="name", +) +print(schedule.initial_input) +``` + +## Handling errors + +When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `agentex.APIConnectionError` is raised. + +When the API returns a non-success status code (that is, 4xx or 5xx +response), a subclass of `agentex.APIStatusError` is raised, containing `status_code` and `response` properties. + +All errors inherit from `agentex.APIError`. + +```python +import agentex +from agentex import Agentex + +client = Agentex() + +try: + client.tasks.list() +except agentex.APIConnectionError as e: + print("The server could not be reached") + print(e.__cause__) # an underlying Exception, likely raised within httpx. +except agentex.RateLimitError as e: + print("A 429 status code was received; we should back off a bit.") +except agentex.APIStatusError as e: + print("Another non-200-range status code was received") + print(e.status_code) + print(e.response) +``` + +Error codes are as follows: + +| Status Code | Error Type | +| ----------- | -------------------------- | +| 400 | `BadRequestError` | +| 401 | `AuthenticationError` | +| 403 | `PermissionDeniedError` | +| 404 | `NotFoundError` | +| 422 | `UnprocessableEntityError` | +| 429 | `RateLimitError` | +| >=500 | `InternalServerError` | +| N/A | `APIConnectionError` | + +### Retries + +Certain errors are automatically retried 2 times by default, with a short exponential backoff. +Connection errors (for example, due to a network connectivity problem), 408 Request Timeout, 409 Conflict, +429 Rate Limit, and >=500 Internal errors are all retried by default. + +You can use the `max_retries` option to configure or disable retry settings: + +```python +from agentex import Agentex + +# Configure the default for all requests: +client = Agentex( + # default is 2 + max_retries=0, +) + +# Or, configure per-request: +client.with_options(max_retries=5).tasks.list() +``` + +### Timeouts + +By default requests time out after 1 minute. You can configure this with a `timeout` option, +which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/timeouts/#fine-tuning-the-configuration) object: + +```python +from agentex import Agentex + +# Configure the default for all requests: +client = Agentex( + # 20 seconds (default is 1 minute) + timeout=20.0, +) + +# More granular control: +client = Agentex( + timeout=httpx.Timeout(60.0, read=5.0, write=10.0, connect=2.0), +) + +# Override per-request: +client.with_options(timeout=5.0).tasks.list() +``` + +On timeout, an `APITimeoutError` is thrown. + +Note that requests that time out are [retried twice by default](#retries). + +## Advanced + +### Logging + +We use the standard library [`logging`](https://docs.python.org/3/library/logging.html) module. + +You can enable logging by setting the environment variable `AGENTEX_LOG` to `info`. + +```shell +$ export AGENTEX_LOG=info +``` + +Or to `debug` for more verbose logging. + +### How to tell whether `None` means `null` or missing + +In an API response, a field may be explicitly `null`, or missing entirely; in either case, its value is `None` in this library. You can differentiate the two cases with `.model_fields_set`: + +```py +if response.my_field is None: + if 'my_field' not in response.model_fields_set: + print('Got json like {}, without a "my_field" key present at all.') + else: + print('Got json like {"my_field": null}.') +``` + +### Accessing raw response data (e.g. headers) + +The "raw" Response object can be accessed by prefixing `.with_raw_response.` to any HTTP method call, e.g., + +```py +from agentex import Agentex + +client = Agentex() +response = client.tasks.with_raw_response.list() +print(response.headers.get('X-My-Header')) + +task = response.parse() # get the object that `tasks.list()` would have returned +print(task) +``` + +These methods return an [`APIResponse`](https://github.com/scaleapi/scale-agentex-python/tree/main/src/agentex/_response.py) object. + +The async client returns an [`AsyncAPIResponse`](https://github.com/scaleapi/scale-agentex-python/tree/main/src/agentex/_response.py) with the same structure, the only difference being `await`able methods for reading the response content. + +#### `.with_streaming_response` + +The above interface eagerly reads the full response body when you make the request, which may not always be what you want. + +To stream the response body, use `.with_streaming_response` instead, which requires a context manager and only reads the response body once you call `.read()`, `.text()`, `.json()`, `.iter_bytes()`, `.iter_text()`, `.iter_lines()` or `.parse()`. In the async client, these are async methods. + +```python +with client.tasks.with_streaming_response.list() as response: + print(response.headers.get("X-My-Header")) + + for line in response.iter_lines(): + print(line) +``` + +The context manager is required so that the response will reliably be closed. + +### Making custom/undocumented requests + +This library is typed for convenient access to the documented API. + +If you need to access undocumented endpoints, params, or response properties, the library can still be used. + +#### Undocumented endpoints + +To make requests to undocumented endpoints, you can make requests using `client.get`, `client.post`, and other +http verbs. Options on the client will be respected (such as retries) when making this request. + +```py +import httpx + +response = client.post( + "/foo", + cast_to=httpx.Response, + body={"my_param": True}, +) + +print(response.headers.get("x-foo")) +``` + +#### Undocumented request params + +If you want to explicitly send an extra param, you can do so with the `extra_query`, `extra_body`, and `extra_headers` request +options. + +#### Undocumented response properties + +To access undocumented response properties, you can access the extra fields like `response.unknown_prop`. You +can also get all the extra fields on the Pydantic model as a dict with +[`response.model_extra`](https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel.model_extra). + +### Configuring the HTTP client + +You can directly override the [httpx client](https://www.python-httpx.org/api/#client) to customize it for your use case, including: + +- Support for [proxies](https://www.python-httpx.org/advanced/proxies/) +- Custom [transports](https://www.python-httpx.org/advanced/transports/) +- Additional [advanced](https://www.python-httpx.org/advanced/clients/) functionality + +```python +import httpx +from agentex import Agentex, DefaultHttpxClient + +client = Agentex( + # Or use the `AGENTEX_BASE_URL` env var + base_url="http://my.test.server.example.com:8083", + http_client=DefaultHttpxClient( + proxy="http://my.test.proxy.example.com", + transport=httpx.HTTPTransport(local_address="0.0.0.0"), + ), +) +``` + +You can also customize the client on a per-request basis by using `with_options()`: + +```python +client.with_options(http_client=DefaultHttpxClient(...)) +``` + +### Managing HTTP resources + +By default the library closes underlying HTTP connections whenever the client is [garbage collected](https://docs.python.org/3/reference/datamodel.html#object.__del__). You can manually close the client using the `.close()` method if desired, or with a context manager that closes when exiting. + +```py +from agentex import Agentex + +with Agentex() as client: + # make requests here + ... + +# HTTP client is now closed +``` + +## Versioning + +This package generally follows [SemVer](https://semver.org/spec/v2.0.0.html) conventions, though certain backwards-incompatible changes may be released as minor versions: + +1. Changes that only affect static types, without breaking runtime behavior. +2. Changes to library internals which are technically public but not intended or documented for external use. _(Please open a GitHub issue to let us know if you are relying on such internals.)_ +3. Changes that we do not expect to impact the vast majority of users in practice. + +We take backwards-compatibility seriously and work hard to ensure you can rely on a smooth upgrade experience. + +We are keen for your feedback; please open an [issue](https://www.github.com/scaleapi/scale-agentex-python/issues) with questions, bugs, or suggestions. + +### Determining the installed version + +If you've upgraded to the latest version but aren't seeing any new features you were expecting then your python environment is likely still using an older version. + +You can determine the version that is being used at runtime with: + +```py +import agentex +print(agentex.__version__) +``` + +## Requirements + +Python 3.9 or higher. -### Manual -Requires repository creation permissions and an appropriately-permissioned REPO_SETUP_TOKEN +## Contributing -1. Create a new repository using this template -2. Add a secret `REPO_SETUP_TOKEN` to the new repository -3. Run the GitHub workflow `repository-setup`, inputting parameters as desired. -4. Allow the workflow to run and set up language-specific files and settings. +See [the contributing documentation](./CONTRIBUTING.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..95eca20aa --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# Security Policy + +## Reporting Security Issues + +This SDK is generated by [Stainless Software Inc](http://stainless.com). Stainless takes security seriously, and encourages you to report any security vulnerability promptly so that appropriate action can be taken. + +To report a security issue, please contact the Stainless team at security@stainless.com. + +## Responsible Disclosure + +We appreciate the efforts of security researchers and individuals who help us maintain the security of +SDKs we generate. If you believe you have found a security vulnerability, please adhere to responsible +disclosure practices by allowing us a reasonable amount of time to investigate and address the issue +before making any information public. + +## Reporting Non-SDK Related Security Issues + +If you encounter security issues that are not directly related to SDKs but pertain to the services +or products provided by Agentex, please follow the respective company's security reporting guidelines. + +### Agentex Terms and Policies + +Please contact roxanne.farhad@scale.com for any questions or concerns regarding the security of our services. + +--- + +Thank you for helping us keep the SDKs and systems they interact with secure. diff --git a/adk/CHANGELOG.md b/adk/CHANGELOG.md new file mode 100644 index 000000000..3b2cc8d62 --- /dev/null +++ b/adk/CHANGELOG.md @@ -0,0 +1,165 @@ +# Changelog + +## 0.26.0 (2026-09-14) + +Full Changelog: [agentex-sdk-v0.25.0...agentex-sdk-v0.26.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.25.0...agentex-sdk-v0.26.0) + +### Chores + +* **agentex-sdk:** Synchronize agentex versions + +## 0.25.0 (2026-08-26) + +Full Changelog: [agentex-sdk-v0.24.0...agentex-sdk-v0.25.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.24.0...agentex-sdk-v0.25.0) + +### Chores + +* **agentex-sdk:** Synchronize agentex versions + +## 0.24.0 (2026-08-11) + +Full Changelog: [agentex-sdk-v0.23.0...agentex-sdk-v0.24.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.23.0...agentex-sdk-v0.24.0) + +### Chores + +* **agentex-sdk:** Synchronize agentex versions + +## 0.23.0 (2026-08-07) + +Full Changelog: [agentex-sdk-v0.22.2...agentex-sdk-v0.23.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.22.2...agentex-sdk-v0.23.0) + +### Features + +* propagate error categories to SGP spans ([#486](https://github.com/scaleapi/scale-agentex-python/issues/486)) ([f2b1808](https://github.com/scaleapi/scale-agentex-python/commit/f2b18087c5049f7e2a159f8e1ad3bf069b01eb23)) + +## 0.22.2 (2026-07-30) + +Full Changelog: [agentex-sdk-v0.22.1...agentex-sdk-v0.22.2](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.22.1...agentex-sdk-v0.22.2) + +### Chores + +* **agentex-sdk:** Synchronize agentex versions + +## 0.22.1 (2026-07-29) + +Full Changelog: [agentex-sdk-v0.22.0...agentex-sdk-v0.22.1](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.22.0...agentex-sdk-v0.22.1) + +### Chores + +* **agentex-sdk:** Synchronize agentex versions + +## 0.22.0 (2026-07-29) + +Full Changelog: [agentex-sdk-v0.21.0...agentex-sdk-v0.22.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.21.0...agentex-sdk-v0.22.0) + +### Chores + +* **agentex-sdk:** Synchronize agentex versions + +## 0.21.0 (2026-07-28) + +Full Changelog: [agentex-sdk-v0.20.0...agentex-sdk-v0.21.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.20.0...agentex-sdk-v0.21.0) + +### Chores + +* **agentex-sdk:** Synchronize agentex versions + +## 0.20.0 (2026-07-16) + +Full Changelog: [agentex-sdk-v0.19.0...agentex-sdk-v0.20.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.19.0...agentex-sdk-v0.20.0) + +### Chores + +* **agentex-sdk:** Synchronize agentex versions + +## 0.19.0 (2026-07-14) + +Full Changelog: [agentex-sdk-v0.18.0...agentex-sdk-v0.19.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.18.0...agentex-sdk-v0.19.0) + +### Chores + +* **agentex-sdk:** Synchronize agentex versions + +## 0.18.0 (2026-07-10) + +Full Changelog: [agentex-sdk-v0.17.0...agentex-sdk-v0.18.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.17.0...agentex-sdk-v0.18.0) + +### Bug Fixes + +* cap openai <2.45 for openai-agents 0.14.x compatibility ([#459](https://github.com/scaleapi/scale-agentex-python/issues/459)) ([14c124d](https://github.com/scaleapi/scale-agentex-python/commit/14c124d363ed964ed8c08e10a95ca3939095ea92)) + + +### Chores + +* **internal:** version bump ([7aeb893](https://github.com/scaleapi/scale-agentex-python/commit/7aeb8937bb794586f7d5931bdc5964d007762b4c)) +* **internal:** version bump ([fcddeea](https://github.com/scaleapi/scale-agentex-python/commit/fcddeea8ef4bdff0a5f7735156c3003166464eac)) + +## 0.17.0 (2026-07-01) + +Full Changelog: [agentex-sdk-v0.16.2...agentex-sdk-v0.17.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.16.2...agentex-sdk-v0.17.0) + +### Chores + +* **agentex-sdk:** Synchronize agentex versions + +## 0.16.2 (2026-06-29) + +Full Changelog: [agentex-sdk-v0.15.0...agentex-sdk-v0.16.2](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.15.0...agentex-sdk-v0.16.2) + +### Bug Fixes + +* **adk:** release streaming buffer repair in sdk ([#449](https://github.com/scaleapi/scale-agentex-python/issues/449)) ([20795cb](https://github.com/scaleapi/scale-agentex-python/commit/20795cb158244767207b6d3758929014bc015bb6)) + +## 0.15.0 (2026-06-24) + +Full Changelog: [agentex-sdk-v0.14.0...agentex-sdk-v0.15.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.14.0...agentex-sdk-v0.15.0) + +### ⚠ BREAKING CHANGES + +* **harness:** consolidate the LangGraph harness + remove tracing handler ([#430](https://github.com/scaleapi/scale-agentex-python/issues/430)) + +### Bug Fixes + +* **harness:** harden Claude Code + OpenAI taps and span tracing ([#446](https://github.com/scaleapi/scale-agentex-python/issues/446)) ([5b4359d](https://github.com/scaleapi/scale-agentex-python/commit/5b4359dcf28f390f780215ed954fa52e8cb4dd7c)) + + +### Refactors + +* **harness:** consolidate the LangGraph harness + remove tracing handler ([#430](https://github.com/scaleapi/scale-agentex-python/issues/430)) ([a3fb5ad](https://github.com/scaleapi/scale-agentex-python/commit/a3fb5ad51f6392a48cbb8324f15c9619f10244b6)) + +## 0.14.0 (2026-06-23) + +Full Changelog: [agentex-sdk-v0.13.2...agentex-sdk-v0.14.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.13.2...agentex-sdk-v0.14.0) + +### Features + +* **harness:** public adk facade + docs for the unified harness surface (PR 9) ([#423](https://github.com/scaleapi/scale-agentex-python/issues/423)) ([fa60632](https://github.com/scaleapi/scale-agentex-python/commit/fa60632f9be84315a3fdc627745ae5b605994bd8)) + +## 0.13.2 (2026-06-22) + +Full Changelog: [agentex-sdk-v0.13.1...agentex-sdk-v0.13.2](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.13.1...agentex-sdk-v0.13.2) + +## 0.13.1 (2026-06-17) + +Full Changelog: [agentex-sdk-v0.13.0...agentex-sdk-v0.13.1](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.13.0...agentex-sdk-v0.13.1) + +### Bug Fixes + +* **packaging:** guard agentex-client surface, bump floor, smoke-test wheel install ([#406](https://github.com/scaleapi/scale-agentex-python/issues/406)) ([a5abbb9](https://github.com/scaleapi/scale-agentex-python/commit/a5abbb9669c6ab71c52e60db72676c95c20d840d)) + + +### Documentation + +* drop stale keep_files / dashboard-config comments ([#401](https://github.com/scaleapi/scale-agentex-python/issues/401)) ([23858df](https://github.com/scaleapi/scale-agentex-python/commit/23858df775d0a617c6418eed28f1b68c9bf9ed5c)) + +## 0.13.0 (2026-06-10) + +Full Changelog: [agentex-sdk-v0.12.0...agentex-sdk-v0.13.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.12.0...agentex-sdk-v0.13.0) + +### ⚠ BREAKING CHANGES + +* **packaging:** release tag scheme changes from v* to -v*. + +### Features + +* **packaging:** introduce slim agentex-client + heavy agentex-sdk split ([bbfb22e](https://github.com/scaleapi/scale-agentex-python/commit/bbfb22eb113dd1f3d5ddf82b4d377895f5ae5466)) diff --git a/adk/README.md b/adk/README.md new file mode 100644 index 000000000..206ba993b --- /dev/null +++ b/adk/README.md @@ -0,0 +1,34 @@ +# agentex-sdk + +The Agent Development Kit (ADK) overlay for the Agentex API. + +## What's in here + +This package ships everything under `agentex.lib.*`: + +- **ACP server** (`agentex.lib.sdk.fastacp`) — FastAPI-based agent control plane. +- **Temporal workflows** (`agentex.lib.core.temporal`) — durable agent execution. +- **CLI** (`agentex.lib.cli`) — `agentex init`, `agentex run`, deploy helpers. +- **LLM provider integrations** (`agentex.lib.adk.providers`, `agentex.lib.core.temporal.plugins`) — OpenAI Agents, Claude Agent SDK, pydantic-ai, langgraph, litellm. +- **Observability** (`agentex.lib.core.tracing`, `agentex.lib.core.observability`) — SGP, Datadog, OpenTelemetry tracing processors. + +## Installation + +```sh +pip install agentex-sdk +``` + +This automatically pulls in [`agentex-client`](../) (the slim Stainless-generated REST client) so `from agentex import Agentex, AsyncAgentex` works the same as before. + +## When to use this vs `agentex-client` + +- **`agentex-sdk`** — you're authoring agents. Pulls everything: ACP server, Temporal, MCP, LLM providers, observability, CLI. ~37 deps. +- **`agentex-client`** — you only need to call the Agentex REST API. No agent authoring, no Temporal workflows, no FastACP server, no provider integrations. 6 deps. + +The two packages contribute disjoint files to the `agentex.*` namespace — `agentex/lib/*` ships only from `agentex-sdk`. + +## 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. + +The wheel source is assembled from `src/agentex/lib/**` by `adk/hatch_build.py`. diff --git a/adk/docs/harness.md b/adk/docs/harness.md new file mode 100644 index 000000000..62094d469 --- /dev/null +++ b/adk/docs/harness.md @@ -0,0 +1,206 @@ +# Unified Harness Surface + +The unified harness surface gives every agent harness (pydantic-ai, LangGraph, OpenAI Agents, and future parsers) a single, shared path to streaming, message persistence, and tracing. The Agentex `StreamTaskMessage*` event stream is the canonical wire format. A harness tap produces that stream once; the shared machinery delivers it and derives spans from it. + +All public names are re-exported from `agentex.lib.adk`: + +```python +from agentex.lib.adk import ( + UnifiedEmitter, + SpanTracer, + TurnUsage, + TurnResult, + HarnessTurn, + StreamTaskMessage, + OpenSpan, + CloseSpan, + SpanSignal, +) +``` + +The implementation lives at `src/agentex/lib/core/harness/`. + +--- + +## The canonical stream: `StreamTaskMessage` + +`StreamTaskMessage` is a union of the four wire-protocol update types: + +``` +StreamTaskMessageStart - opens a content slot (text, reasoning, tool request, ...) +StreamTaskMessageDelta - appends a token/fragment to an open slot +StreamTaskMessageFull - posts a complete message in one shot (tool response, ...) +StreamTaskMessageDone - closes an open slot +``` + +Every harness tap produces a sequence of these. Everything downstream (delivery, tracing) reads the same sequence. + +--- + +## Per-harness taps: `convert__to_agentex_events` + +A tap is an async generator that translates the harness's native event stream into `StreamTaskMessage*` events. The shipped taps are: + +| Harness | Tap function | Exported from | +|---|---|---| +| pydantic-ai | `convert_pydantic_ai_to_agentex_events` | `agentex.lib.adk` | +| LangGraph | `convert_langgraph_to_agentex_events` | `agentex.lib.adk` | +| claude-code | `convert_claude_code_to_agentex_events` | `agentex.lib.adk` | +| codex | `convert_codex_to_agentex_events` | `agentex.lib.adk` | +| OpenAI Agents | `convert_openai_to_agentex_events` | `agentex.lib.adk.providers._modules.sync_provider` | + +Each harness also provides a `HarnessTurn` wrapper that pairs its tap's event stream with usage extraction: `PydanticAITurn`, `LangGraphTurn`, `ClaudeCodeTurn`, `CodexTurn`, and `OpenAITurn`. + +--- + +## `HarnessTurn` protocol + +`HarnessTurn` is the interface a harness turn object must satisfy to plug into `UnifiedEmitter`: + +```python +@runtime_checkable +class HarnessTurn(Protocol): + @property + def events(self) -> AsyncIterator[StreamTaskMessage]: ... + + def usage(self) -> TurnUsage: ... +``` + +`events` is the canonical stream for this turn. `usage()` is valid only after `events` is exhausted (async generators cannot cleanly return a value to the consumer, so usage travels out-of-band). + +--- + +## `TurnUsage` + +Token counts and cost for one turn, harness-independent: + +```python +class TurnUsage(BaseModel): + model: str | None = None + input_tokens: int | None = None + output_tokens: int | None = None + cached_input_tokens: int | None = None + reasoning_tokens: int | None = None + total_tokens: int | None = None + cost_usd: float | None = None + duration_ms: int | None = None + num_llm_calls: int = 0 + num_tool_calls: int = 0 + num_reasoning_blocks: int = 0 +``` + +Field names align with `agentex.lib.core.observability.llm_metrics` for easy conversion. + +--- + +## `UnifiedEmitter` + +`UnifiedEmitter` ties a turn's canonical stream, tracing context, and delivery mode together. Construct one per turn with the task/trace context from the request: + +```python +emitter = UnifiedEmitter( + task_id=params.task.id, + trace_id=params.task.id, # or None to disable tracing + parent_span_id=turn_span.id if turn_span else None, +) +``` + +**Tracing is on by default** when `trace_id` is provided. To disable it explicitly, pass `tracer=False`. To inject a custom `SpanTracer` (e.g. in tests), pass it as `tracer=`. + +### Delivery mode 1: `yield_turn` (sync HTTP ACP) + +For sync ACP agents that return events directly over the HTTP response: + +```python +@acp.on_message_send +async def handle(params): + turn = MyHarnessTurn(params) # implements HarnessTurn + async for event in emitter.yield_turn(turn): + yield event +``` + +`yield_turn` forwards each event to the caller and traces spans as a side effect. It is a passthrough when `tracer` is `None`. + +### Delivery mode 2: `auto_send_turn` (async/Temporal) + +For async or Temporal agents that push to the task stream via Redis: + +```python +result: TurnResult = await emitter.auto_send_turn(turn, created_at=workflow.now()) +``` + +`auto_send_turn` drives `adk.streaming` contexts for every message in the stream, derives and records spans, and returns a `TurnResult` with the final text and usage. Pass `created_at` under Temporal to back-date message timestamps deterministically. + +--- + +## `TurnResult` + +```python +class TurnResult(BaseModel): + final_text: str = "" + usage: TurnUsage = TurnUsage() +``` + +Returned by `auto_send_turn`. `final_text` is the last text segment of the turn (multi-step runs return only the final segment, matching `stream_langgraph_events` / `stream_pydantic_ai_events` semantics). + +--- + +## Tracing: span derivation + +Spans are derived from the canonical stream by `SpanDeriver` (pure, no `adk` dependency) and dispatched to `adk.tracing` by `SpanTracer`. The mapping: + +- `StreamTaskMessageStart(ToolRequestContent)` + `StreamTaskMessageDone` on that index -> tool span open (keyed by `tool_call_id`) +- `StreamTaskMessageFull(ToolResponseContent)` whose `tool_call_id` was opened -> tool span close +- `StreamTaskMessageFull(ToolRequestContent)` (harnesses that emit tool calls as Full) -> opens a tool span; matching `Full(ToolResponseContent)` closes it +- `StreamTaskMessageStart(ReasoningContent)` + `StreamTaskMessageDone` -> reasoning span + +`SpanTracer` is `SpanDeriver`'s consumer. You can inject a custom `SpanTracer` via `UnifiedEmitter(tracer=)` for advanced use or testing. + +--- + +## Usage examples by channel + +### Sync ACP (`yield_turn`) + +Build the harness's `HarnessTurn` wrapper and iterate `emitter.yield_turn(turn)` — the emitter forwards each event to the caller and traces spans as a side effect: + +```python +import agentex.lib.adk as adk +from agentex.lib.adk import UnifiedEmitter, ClaudeCodeTurn + +@acp.on_message_send +async def handle(params): + task_id = params.task.id + async with adk.tracing.span(trace_id=task_id, name="message", ...) as turn_span: + emitter = UnifiedEmitter( + task_id=task_id, + trace_id=task_id, + parent_span_id=turn_span.id if turn_span else None, + ) + turn = ClaudeCodeTurn(claude_code_stream) # any HarnessTurn + async for event in emitter.yield_turn(turn): + yield event +``` + +Every harness follows the same shape — swap `ClaudeCodeTurn` for `PydanticAITurn`, `LangGraphTurn`, `CodexTurn`, or `OpenAITurn` and feed it that harness's native stream. + +### Async Temporal (auto-send) + +```python +from agentex.lib.adk import UnifiedEmitter + +emitter = UnifiedEmitter( + task_id=task_id, + trace_id=task_id, + parent_span_id=parent_span_id, +) +result = await emitter.auto_send_turn(turn, created_at=workflow.now()) +# result.final_text — last text segment +# result.usage — TurnUsage (tokens, cost, ...) +``` + +--- + +## Migration + +- [Migrating to `agentex-client` 0.16.0 / `agentex-sdk` 0.15.0](./migration-0.16.0.md) — removed LangGraph/Pydantic-AI tracing handlers (tracing is now derived from the canonical stream), private `_modules` path moves, the OpenAI harness facade relocation, and the new `run_turn` Temporal entry point. diff --git a/adk/docs/migration-0.16.0.md b/adk/docs/migration-0.16.0.md new file mode 100644 index 000000000..b76da55ba --- /dev/null +++ b/adk/docs/migration-0.16.0.md @@ -0,0 +1,272 @@ +# Migration Guide — `agentex-client` 0.16.0 / `agentex-sdk` 0.15.0 + +This release consolidates the LangGraph, Pydantic-AI, and OpenAI Agents harnesses +onto the **unified harness surface** (`UnifiedEmitter` + `SpanDeriver`), introduces +`run_turn` as the single Temporal entry point for OpenAI Agents, renders +hosted/server-side tool calls in the Temporal streaming model, and ships new CLI +init templates. + +Most consumers only need to act on **section 1** (removed tracing handlers). +Sections 2–3 only matter if you import private modules. Section 4 lists the new, +opt-in capabilities. Section 5 documents the defect fixes shipped on top of the +release. + +--- + +## 1. Tracing handlers removed (LangGraph + Pydantic-AI) — **action required** + +The bespoke tracing callback handlers are **gone** from the public +`agentex.lib.adk` surface: + +| Removed | | +|---|---| +| `agentex.lib.adk.create_langgraph_tracing_handler` | + class `AgentexLangGraphTracingHandler` | +| `agentex.lib.adk.create_pydantic_ai_tracing_handler` | + class `AgentexPydanticAITracingHandler` | + +Span tracing is now **derived automatically** from the canonical +`StreamTaskMessage*` stream by `UnifiedEmitter`. You no longer construct or pass a +callback handler — you wrap the run in the harness `*Turn` and drive delivery +through the emitter, and spans fall out of the stream. + +### LangGraph + +**Before** + +```python +from agentex.lib import adk + +handler = adk.create_langgraph_tracing_handler( + trace_id=trace_id, + parent_span_id=parent_span_id, +) +result = await graph.ainvoke(state, config={"callbacks": [handler]}) +``` + +**After** + +```python +from agentex.lib.adk import stream_langgraph_events # facade name unchanged + +# Streaming delivery + tracing are handled for you; no callbacks wiring. +async for event in stream_langgraph_events(graph, state, ...): + ... +``` + +or, when you own the emitter directly: + +```python +from agentex.lib.adk import LangGraphTurn +from agentex.lib.core.harness import UnifiedEmitter + +emitter = UnifiedEmitter(...) +await emitter.auto_send_turn(LangGraphTurn(...)) # or: emitter.yield_turn(...) +``` + +### Pydantic-AI + +**Before** + +```python +handler = adk.create_pydantic_ai_tracing_handler(trace_id=..., parent_span_id=...) +``` + +**After** + +```python +from agentex.lib.adk import PydanticAITurn, stream_pydantic_ai_events +from agentex.lib.core.harness import UnifiedEmitter + +# Wrap in PydanticAITurn and drive UnifiedEmitter.yield_turn / auto_send_turn. +await UnifiedEmitter(...).auto_send_turn(PydanticAITurn(...)) +``` + +The `agentex init` templates were migrated to this pattern. If you scaffolded +from an older template, regenerate (or diff against a fresh template) for the +canonical shape. + +--- + +## 2. Private `_modules` import paths changed — **only if you import privates** + +Each harness now exposes exactly `__sync.py` + `__turn.py` under +`agentex.lib.adk._modules`. Several private modules were deleted and their +functions relocated. If you imported the **public facade names** from +`agentex.lib.adk`, **nothing changes**. Repoint only if you reached into the +private modules directly: + +| Old (deleted) private import | New location | Public facade (unchanged) | +|---|---|---| +| `_modules._langgraph_async.stream_langgraph_events` | `_modules._langgraph_turn` | `adk.stream_langgraph_events` | +| `_modules._langgraph_messages.emit_langgraph_messages` | `_modules._langgraph_sync` | `adk.emit_langgraph_messages` | +| `_modules._langgraph_tracing.*` | **removed** (see §1) | — | +| `_modules._pydantic_ai_async.stream_pydantic_ai_events` | `_modules._pydantic_ai_turn` | `adk.stream_pydantic_ai_events` | +| `_modules._pydantic_ai_tracing.*` | **removed** (see §1) | — | + +✅ These facade names are unchanged and keep working: +`stream_langgraph_events`, `emit_langgraph_messages`, +`convert_langgraph_to_agentex_events`, `LangGraphTurn`, +`stream_pydantic_ai_events`, `convert_pydantic_ai_to_agentex_events`, +`PydanticAITurn`. + +--- + +## 3. OpenAI harness moved into `adk/_modules` + facade export + +The OpenAI Agents harness now lives alongside the others: + +- `OpenAITurn`, `openai_usage_to_turn_usage` → `agentex.lib.adk._modules._openai_turn` +- `convert_openai_to_agentex_events` → `agentex.lib.adk._modules._openai_sync` + +New **public** facade exports (prefer these): + +```python +from agentex.lib.adk import ( + OpenAITurn, + convert_openai_to_agentex_events, + openai_usage_to_turn_usage, +) +``` + +Back-compat shims remain at +`agentex.lib.adk.providers._modules.{openai_turn,sync_provider}` **for one +release** — migrate to the facade names before the next minor. + +--- + +## 4. New capabilities (opt-in, no migration required) + +- **`run_turn` — unified Temporal entry point for OpenAI Agents.** + + ```python + from agentex.lib.core.temporal.plugins.openai_agents import run_turn, OpenAIAgentsTurnResult + + result = await run_turn( + agent, input, + task_id=task_id, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + result.final_output # raw SDK final_output + result.usage # normalized TurnUsage for the turn span + ``` + + It emits each tool call exactly once (the streaming model is the sole + tool-**request** emitter; hooks emit tool **responses**), traces per-tool spans, + normalizes token usage, and drains orphaned tool spans in a `finally` block if + the run terminates mid-tool. Existing `TemporalStreamingHooks` callers keep + working — `run_turn` is additive. If you pass your own `hooks` subclass, also + set `emit_tool_requests=False` and forward `trace_id` / `parent_span_id` + yourself (they are only auto-applied to the default hooks). + +- **Hosted / server-side tool rendering** in `TemporalStreamingModel`: + web_search, file_search, code_interpreter, image_generation, server-side mcp, + computer, and local_shell calls now surface as ToolRequest/ToolResponse pairs. + +- **New CLI init templates:** `default` / `sync` / `temporal` flavors of + `claude-code` and `codex`, plus `default-openai-agents`. + +--- + +## 5. Defect fixes shipped with this migration + +These fixes harden the newly-added sync OpenAI converter +(`convert_openai_to_agentex_events` / `OpenAITurn`) and the Temporal hosted-tool +path. No API change — behavior only. + +1. **Malformed tool arguments no longer abort the turn.** The converter now + parses raw tool-call arguments through a defensive helper + (`_safe_parse_arguments`): a non-decodable string is preserved under `raw` + and a non-dict JSON value under `value`, instead of raising `JSONDecodeError` + and killing the run before later output is delivered. This matches the + Temporal streaming model's existing fallback. + +2. **Reasoning messages are closed.** Completed reasoning content/summary items + now emit a matching `StreamTaskMessageDone`. Previously the `Done` was + skipped, so `UnifiedEmitter.auto_send` never released the context and the + reasoning span could be marked incomplete (reasoning-model output appeared to + hang). + +3. **Text no longer collides with reasoning.** Every new text `item_id` now + reserves a fresh message index (matching the increment-then-use convention of + the reasoning/tool paths). Previously the first text item reused the current + index, so on reasoning-model streams the final answer could overwrite the + reasoning message, duplicate a `Start`, or route deltas into the wrong context. + +4. **Hosted-tool response shape aligned.** Hosted/server-side tool responses in + `TemporalStreamingModel` now emit `content` as a plain string, matching the + function-tool response path (`on_tool_end`) so hosted and function tools + render identically within the same flow. + +5. **Reasoning text now appears in derived spans.** `SpanDeriver` opened reasoning + spans with empty input and closed them with `output=None`, so reasoning/thinking + text never reached the trace (spans showed blank — read as "0 reasoning traces"). + It now accumulates the `ReasoningContentDelta` / `ReasoningSummaryDelta` text (and + any text seeded on the Start content) and records it as the span output. Affects + every harness that streams reasoning, including the Claude Code tap. + +6. **Claude Code: no more duplicate text messages.** The `stream-json` converter + deduped streamed-vs-materialized blocks by numeric block index and reset that + state after every materialized `assistant` envelope. A single streamed message + that materializes as several envelopes (thinking, then text) lost the dedup + marker between envelopes and re-emitted the text. Dedup is now **content-based** + (match the streamed block's text, consume once), which a numeric index cannot do + reliably. + +> Action: if you adopted `OpenAITurn` for **reasoning models** (o1/o3/gpt-5) on +> the sync path before these fixes, upgrade — fixes 2 and 3 are required for +> correct reasoning rendering. Claude Code agents on the unified harness tap should +> upgrade for fixes 5 and 6. + +--- + +## 6. Legacy Temporal `claude_agents` plugin → unified harness tap + +`agentex.lib.core.temporal.plugins.claude_agents` (`run_claude_agent_activity`, +`create_streaming_hooks`, `TemporalStreamingHooks`, `ClaudeMessageHandler`) is the +**original** Claude Code integration: it drives the Python `claude-agent-sdk` +directly and hand-rolls its own streaming + tracing. It is **superseded** by the +unified harness tap and slated for removal in a future release. It still works +today, so this migration is **recommended, not yet required** — but new Claude Code +agents should use the tap, and existing ones should plan to move. + +Why migrate: the tap routes Claude Code through the same canonical +`StreamTaskMessage*` stream as every other harness, so it gets central span +derivation (tool **and** reasoning spans), the single delivery path +(`UnifiedEmitter`), and fixes like the two above for free. The legacy plugin does +not derive reasoning spans at all and duplicates the streaming/tracing logic. + +**Before — legacy plugin activity:** + +```python +from agentex.lib.core.temporal.plugins.claude_agents import run_claude_agent_activity + +# In the workflow: +result = await workflow.execute_activity( + run_claude_agent_activity, + args=[prompt, workspace_path, allowed_tools, ...], + start_to_close_timeout=..., +) +``` + +**After — unified harness tap.** Run the CLI yourself (`claude -p --output-format +stream-json --include-partial-messages`), wrap its stdout in `ClaudeCodeTurn`, and +deliver through `UnifiedEmitter`: + +```python +from agentex.lib.adk import ClaudeCodeTurn, UnifiedEmitter + +# `stdout_lines` is an async iterator of the CLI's stdout lines (raw JSON strings +# or pre-parsed dicts) — e.g. read from sandbox.exec() / a subprocess. +turn = ClaudeCodeTurn(stdout_lines) + +emitter = UnifiedEmitter(task_id=task_id, trace_id=trace_id, parent_span_id=parent_span_id) +result = await emitter.auto_send_turn(turn, created_at=workflow.now()) +# result.final_text — last text segment +# result.usage — TurnUsage (tokens, cost, num_reasoning_blocks, ...) +``` + +The golden agent is the reference implementation +(`teams/sgp/agents/golden_agent/project/harness/`): it spawns the CLI in a sandbox, +yields stdout lines into `ClaudeCodeTurn`, and drives `auto_send_turn`. Known +remaining consumers to migrate: the `090_claude_agents_sdk_mvp` tutorial and the +`eval_dashboard_agent`. diff --git a/adk/hatch_build.py b/adk/hatch_build.py new file mode 100644 index 000000000..8baadfe5b --- /dev/null +++ b/adk/hatch_build.py @@ -0,0 +1,41 @@ +"""Builds the agentex/lib force-include map per-file so test files can be pruned +— force-include ignores `exclude` (hatchling #1395).""" + +from __future__ import annotations + +import os + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + +_SKIP_DIRS = {"__pycache__", "tests"} +_SKIP_NAMES = {"conftest.py", "pytest.ini", "run_tests.py"} +# Floor below the ~333 shippable files: a collapse means the walk broke — fail +# loud rather than ship a near-empty wheel. +_MIN_FILES = 320 + + +def _is_test_file(name: str) -> bool: + return name in _SKIP_NAMES or (name.startswith("test_") and name.endswith(".py")) + + +class CustomBuildHook(BuildHookInterface): + PLUGIN_NAME = "custom" + + def initialize(self, version: str, build_data: dict) -> None: # noqa: ARG002 + lib_root = os.path.normpath(os.path.join(self.root, "..", "src", "agentex", "lib")) + force_include = build_data.setdefault("force_include", {}) + collected = 0 + for dirpath, dirnames, filenames in os.walk(lib_root): + dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS] + for name in filenames: + if _is_test_file(name): + continue + src = os.path.join(dirpath, name) + rel = os.path.relpath(src, lib_root) + force_include[src] = os.path.join("agentex", "lib", rel) + collected += 1 + if collected < _MIN_FILES: + raise RuntimeError( + f"agentex/lib force-include collected only {collected} files " + f"(expected >= {_MIN_FILES}); aborting build." + ) diff --git a/adk/pyproject.toml b/adk/pyproject.toml new file mode 100644 index 000000000..b42b50e11 --- /dev/null +++ b/adk/pyproject.toml @@ -0,0 +1,109 @@ +[project] +# Hand-authored ADK overlay for agentex. This package contributes only +# `agentex/lib/*` to the agentex.* namespace; the REST client surface +# (agentex/{__init__.py, _*.py, types/, resources/}) ships from the slim +# sibling package `agentex-client` which is pinned as a runtime dep. +name = "agentex-sdk" +version = "0.26.0" +description = "Agent Development Kit (ADK) overlay for the Agentex API — FastACP server, Temporal workflows, LLM provider integrations, observability" +license = "Apache-2.0" +authors = [ + { name = "Agentex", email = "roxanne.farhad@scale.com" }, +] +readme = "README.md" + +dependencies = [ + # Co-released in lockstep; floor-only by design — a ceiling would + # eventually exclude the co-versioned slim (release-please can't bump it). + "agentex-client>=0.13.0", + # CLI surface (agentex.lib.cli.*, agentex.lib.sdk.config.*) + "typer>=0.16,<0.17", + "questionary>=2.0.1,<3", + "rich>=13.9.2,<14", + "yaspin>=3.1.0", + "pyyaml>=6.0.2,<7", + "python-on-whales>=0.73.0,<0.74", + "kubernetes>=25.0.0,<36.0.0", + "jsonref>=1.1.0,<2", + "jsonschema>=4.23.0,<5", + "jinja2>=3.1.3,<4", + "watchfiles>=0.24.0,<1.0", + # ACP server (FastAPI app surface) + "fastapi>=0.115.0", + "starlette>=0.49.1", + "uvicorn>=0.31.1", + "aiohttp>=3.10.10,<4", + # Temporal workflows + "temporalio>=1.26.0,<2", + "cloudpickle>=3.1.1", + # Async streaming infra + "redis>=5.2.0,<8", + # LLM provider integrations + "litellm>=1.83.7,<2", + "openai-agents>=0.14.3,<0.15", + # Cap <2.45: openai 2.45.0 makes InputTokensDetails.cache_write_tokens a + # required field, but openai-agents 0.14.x still builds + # InputTokensDetails(cached_tokens=0) (agents/usage.py), so every + # Runner.run_streamed raises a pydantic ValidationError at context setup. + # openai-agents 0.14.8 is the latest release, so there is no newer version + # to bump to; drop this ceiling once openai-agents ships a fix. + # litellm now supports openai 2.x (issue #13711 resolved: https://github.com/BerriAI/litellm/issues/13711) + "openai>=2.2,<2.45", + "claude-agent-sdk>=0.1.0", + "pydantic-ai-slim>=1.0,<2", + "langgraph-checkpoint>=2.0.0", + "scale-gp>=0.1.0a59", + "scale-gp-beta>=0.5.0", + "mcp>=1.4.1", + # Observability + "ddtrace>=3.13.0", + "opentelemetry-api>=1.20.0", + "opentelemetry-sdk>=1.20.0", + "json_log_formatter>=1.1.1", +] + +# 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", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Operating System :: OS Independent", + "Topic :: Software Development :: Libraries :: Python Modules", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +Homepage = "https://github.com/scaleapi/scale-agentex-python" +Repository = "https://github.com/scaleapi/scale-agentex-python" + +[project.scripts] +agentex = "agentex.lib.cli.commands.main:app" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +# Ship only agentex/lib/*, pulled in from the parent repo's `src/agentex/lib` +# tree. The rest of agentex.* (the Stainless-generated client) ships from the +# sibling agentex-client package, which this package pins as a runtime dep. +# Stainless explicitly preserves `src/agentex/lib/` across codegen (per +# CONTRIBUTING.md), so it's safe to keep the source where it is. +[tool.hatch.build.targets.wheel] +bypass-selection = true + +# Builds the ../src/agentex/lib force-include map per-file (see hatch_build.py) +# so test files can be pruned — force-include ignores `exclude` (hatchling #1395). +[tool.hatch.build.targets.wheel.hooks.custom] +path = "hatch_build.py" + +# Sdist deferred: hatchling can't represent the wheel's ../src/agentex/lib +# force-include in an sdist include list. CI + bin/publish-pypi pass --wheel. +[tool.hatch.build.targets.sdist] +include = [ + "/pyproject.toml", + "/README.md", +] diff --git a/api.md b/api.md new file mode 100644 index 000000000..a7515d9d2 --- /dev/null +++ b/api.md @@ -0,0 +1,277 @@ +# Shared Types + +```python +from agentex.types import DeleteResponse +``` + +# Agents + +Types: + +```python +from agentex.types import ( + AcpType, + Agent, + AgentRpcRequest, + AgentRpcResponse, + AgentRpcResult, + DataDelta, + ReasoningContentDelta, + ReasoningSummaryDelta, + TaskMessageContent, + TaskMessageDelta, + TaskMessageUpdate, + TextDelta, + ToolRequestDelta, + ToolResponseDelta, + AgentListResponse, +) +``` + +Methods: + +- client.agents.retrieve(agent_id) -> Agent +- client.agents.list(\*\*params) -> AgentListResponse +- client.agents.delete(agent_id) -> DeleteResponse +- client.agents.delete_by_name(agent_name) -> DeleteResponse +- client.agents.register_build(\*\*params) -> Agent +- client.agents.retrieve_by_name(agent_name) -> Agent +- client.agents.rpc(agent_id, \*\*params) -> AgentRpcResponse +- client.agents.rpc_by_name(agent_name, \*\*params) -> AgentRpcResponse + +## Deployments + +Types: + +```python +from agentex.types.agents import ( + DeploymentCreateResponse, + DeploymentRetrieveResponse, + DeploymentListResponse, + DeploymentPromoteResponse, +) +``` + +Methods: + +- client.agents.deployments.create(agent_id, \*\*params) -> DeploymentCreateResponse +- client.agents.deployments.retrieve(deployment_id, \*, agent_id) -> DeploymentRetrieveResponse +- client.agents.deployments.list(agent_id, \*\*params) -> DeploymentListResponse +- client.agents.deployments.delete(deployment_id, \*, agent_id) -> DeleteResponse +- client.agents.deployments.preview_rpc(deployment_id, \*, agent_id, \*\*params) -> AgentRpcResponse +- client.agents.deployments.promote(deployment_id, \*, agent_id) -> DeploymentPromoteResponse + +## Schedules + +Types: + +```python +from agentex.types.agents import ( + ScheduleCreateResponse, + ScheduleRetrieveResponse, + ScheduleUpdateResponse, + ScheduleListResponse, + SchedulePauseResponse, + SchedulePauseByNameResponse, + ScheduleResumeResponse, + ScheduleResumeByNameResponse, + ScheduleRetrieveByNameResponse, + ScheduleSkipResponse, + ScheduleTriggerResponse, + ScheduleTriggerByNameResponse, + ScheduleUnskipResponse, + ScheduleUpdateByNameResponse, +) +``` + +Methods: + +- client.agents.schedules.create(agent_id, \*\*params) -> ScheduleCreateResponse +- client.agents.schedules.retrieve(schedule_id, \*, agent_id) -> ScheduleRetrieveResponse +- client.agents.schedules.update(schedule_id, \*, agent_id, \*\*params) -> ScheduleUpdateResponse +- client.agents.schedules.list(agent_id, \*\*params) -> ScheduleListResponse +- client.agents.schedules.delete(schedule_id, \*, agent_id) -> DeleteResponse +- client.agents.schedules.delete_by_name(name, \*, agent_id) -> DeleteResponse +- client.agents.schedules.pause(schedule_id, \*, agent_id, \*\*params) -> SchedulePauseResponse +- client.agents.schedules.pause_by_name(name, \*, agent_id, \*\*params) -> SchedulePauseByNameResponse +- client.agents.schedules.resume(schedule_id, \*, agent_id, \*\*params) -> ScheduleResumeResponse +- client.agents.schedules.resume_by_name(name, \*, agent_id, \*\*params) -> ScheduleResumeByNameResponse +- client.agents.schedules.retrieve_by_name(name, \*, agent_id) -> ScheduleRetrieveByNameResponse +- client.agents.schedules.skip(schedule_id, \*, agent_id, \*\*params) -> ScheduleSkipResponse +- client.agents.schedules.trigger(schedule_id, \*, agent_id) -> ScheduleTriggerResponse +- client.agents.schedules.trigger_by_name(name, \*, agent_id) -> ScheduleTriggerByNameResponse +- client.agents.schedules.unskip(schedule_id, \*, agent_id, \*\*params) -> ScheduleUnskipResponse +- client.agents.schedules.update_by_name(path_name, \*, agent_id, \*\*params) -> ScheduleUpdateByNameResponse + +# Tasks + +Types: + +```python +from agentex.types import ( + Task, + TaskRetrieveResponse, + TaskListResponse, + TaskQueryWorkflowResponse, + TaskRetrieveByNameResponse, +) +``` + +Methods: + +- client.tasks.retrieve(task_id, \*\*params) -> TaskRetrieveResponse +- client.tasks.list(\*\*params) -> TaskListResponse +- client.tasks.delete(task_id) -> DeleteResponse +- client.tasks.cancel(task_id, \*\*params) -> Task +- client.tasks.complete(task_id, \*\*params) -> Task +- client.tasks.delete_by_name(task_name) -> DeleteResponse +- client.tasks.fail(task_id, \*\*params) -> Task +- client.tasks.interrupt(task_id, \*\*params) -> Task +- client.tasks.query_workflow(query_name, \*, task_id) -> TaskQueryWorkflowResponse +- client.tasks.retrieve_by_name(task_name, \*\*params) -> TaskRetrieveByNameResponse +- client.tasks.stream_events(task_id) -> object +- client.tasks.stream_events_by_name(task_name) -> object +- client.tasks.terminate(task_id, \*\*params) -> Task +- client.tasks.timeout(task_id, \*\*params) -> Task +- client.tasks.update_by_id(task_id, \*\*params) -> Task +- client.tasks.update_by_name(task_name, \*\*params) -> Task + +# Messages + +Types: + +```python +from agentex.types import ( + DataContent, + MessageAuthor, + MessageStyle, + ReasoningContent, + TaskMessage, + TextContent, + TextFormat, + ToolRequestContent, + ToolResponseContent, + MessageListResponse, + MessageListPaginatedResponse, +) +``` + +Methods: + +- client.messages.create(\*\*params) -> TaskMessage +- client.messages.retrieve(message_id) -> TaskMessage +- client.messages.update(message_id, \*\*params) -> TaskMessage +- client.messages.list(\*\*params) -> MessageListResponse +- client.messages.list_paginated(\*\*params) -> MessageListPaginatedResponse + +## Batch + +Types: + +```python +from agentex.types.messages import BatchCreateResponse, BatchUpdateResponse +``` + +Methods: + +- client.messages.batch.create(\*\*params) -> BatchCreateResponse +- client.messages.batch.update(\*\*params) -> BatchUpdateResponse + +# Spans + +Types: + +```python +from agentex.types import Span, SpanListResponse +``` + +Methods: + +- client.spans.create(\*\*params) -> Span +- client.spans.retrieve(span_id) -> Span +- client.spans.update(span_id, \*\*params) -> Span +- client.spans.list(\*\*params) -> SpanListResponse + +# States + +Types: + +```python +from agentex.types import State, StateListResponse +``` + +Methods: + +- client.states.create(\*\*params) -> State +- client.states.retrieve(state_id) -> State +- client.states.update(state_id, \*\*params) -> State +- client.states.list(\*\*params) -> StateListResponse +- client.states.delete(state_id) -> State + +# Events + +Types: + +```python +from agentex.types import Event, EventListResponse +``` + +Methods: + +- client.events.retrieve(event_id) -> Event +- client.events.list(\*\*params) -> EventListResponse + +# Tracker + +Types: + +```python +from agentex.types import AgentTaskTracker, TrackerListResponse +``` + +Methods: + +- client.tracker.retrieve(tracker_id) -> AgentTaskTracker +- client.tracker.update(tracker_id, \*\*params) -> AgentTaskTracker +- client.tracker.list(\*\*params) -> TrackerListResponse + +# DeploymentHistory + +Types: + +```python +from agentex.types import DeploymentHistory, DeploymentHistoryListResponse +``` + +Methods: + +- client.deployment_history.retrieve(deployment_id) -> DeploymentHistory +- client.deployment_history.list(\*\*params) -> DeploymentHistoryListResponse + +# Checkpoints + +Types: + +```python +from agentex.types import CheckpointListResponse, CheckpointGetTupleResponse, CheckpointPutResponse +``` + +Methods: + +- client.checkpoints.list(\*\*params) -> CheckpointListResponse +- client.checkpoints.delete_thread(\*\*params) -> None +- client.checkpoints.get_tuple(\*\*params) -> Optional[CheckpointGetTupleResponse] +- client.checkpoints.put(\*\*params) -> CheckpointPutResponse +- client.checkpoints.put_writes(\*\*params) -> None + +# Webhooks + +Types: + +```python +from agentex.types import WebhookCreateWebhookTriggerResponse +``` + +Methods: + +- client.webhooks.create_webhook_trigger(\*\*params) -> WebhookCreateWebhookTriggerResponse diff --git a/bin/check-release-environment b/bin/check-release-environment new file mode 100644 index 000000000..9168b4fce --- /dev/null +++ b/bin/check-release-environment @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +# This script is run by Release Doctor to validate the release environment. +# After the dual-package split (slim agentex-client + heavy agentex-sdk), +# both PyPI tokens must be present — one for each package name. If only +# PYPI_TOKEN is set, fall back to using it for both (back-compat for legacy +# single-token setups, which forces an account-scoped token). + +errors=() + +# Heavy `agentex-sdk` token (existing PyPI name). +if [ -z "${AGENTEX_PYPI_TOKEN}" ] && [ -z "${PYPI_TOKEN}" ]; then + errors+=("The AGENTEX_PYPI_TOKEN secret has not been set (and no fallback PYPI_TOKEN). Add it in repo secrets so the heavy 'agentex-sdk' package can be published.") +fi + +# Slim `agentex-client` token (new PyPI name). +if [ -z "${AGENTEX_CLIENT_PYPI_TOKEN}" ] && [ -z "${PYPI_TOKEN}" ]; then + errors+=("The AGENTEX_CLIENT_PYPI_TOKEN secret has not been set (and no fallback PYPI_TOKEN). Add it in repo secrets so the slim 'agentex-client' package can be published. Falling back to PYPI_TOKEN requires an account-scoped token.") +fi + +lenErrors=${#errors[@]} + +if [[ lenErrors -gt 0 ]]; then + echo -e "Found the following errors in the release environment:\n" + + for error in "${errors[@]}"; do + echo -e "- $error\n" + done + + exit 1 +fi + +echo "The environment is ready to push releases!" diff --git a/bin/publish-pypi b/bin/publish-pypi new file mode 100644 index 000000000..7107d194e --- /dev/null +++ b/bin/publish-pypi @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +# Publish only the package requested by the component tag. Manual dispatches can +# set PYPI_PACKAGE=all to publish both packages; in that case publish slim +# before heavy because the heavy package depends on the slim package. + +set -eux + +rm -rf dist +# --wheel: the heavy's cross-dir force-include can't build via sdist. +uv build --all-packages --wheel + +publish_client() { + uv publish --check-url https://pypi.org/simple/ --token="${AGENTEX_CLIENT_PYPI_TOKEN:-${PYPI_TOKEN:-}}" dist/agentex_client-*.whl +} + +publish_sdk() { + uv publish --check-url https://pypi.org/simple/ --token="${AGENTEX_PYPI_TOKEN:-${PYPI_TOKEN:-}}" dist/agentex_sdk-*.whl +} + +package="${PYPI_PACKAGE:-}" + +if [ -z "$package" ]; then + tag_name="${GITHUB_REF_NAME:-}" + if [ -z "$tag_name" ] && [[ "${GITHUB_REF:-}" == refs/tags/* ]]; then + tag_name="${GITHUB_REF#refs/tags/}" + fi + + case "$tag_name" in + agentex-client-v*) package="agentex-client" ;; + agentex-sdk-v*) package="agentex-sdk" ;; + *) + echo "Unable to infer package from tag '$tag_name'. Set PYPI_PACKAGE to one of: all, agentex-client, agentex-sdk." >&2 + exit 1 + ;; + esac +fi + +case "$package" in + all) + publish_client + publish_sdk + ;; + agentex-client) + publish_client + ;; + agentex-sdk) + publish_sdk + ;; + *) + echo "Unknown PYPI_PACKAGE '$package'. Expected one of: all, agentex-client, agentex-sdk." >&2 + exit 1 + ;; +esac diff --git a/examples/.keep b/examples/.keep new file mode 100644 index 000000000..d8c73e937 --- /dev/null +++ b/examples/.keep @@ -0,0 +1,4 @@ +File generated from our OpenAPI spec by Stainless. + +This directory can be used to store example files demonstrating usage of this SDK. +It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. \ No newline at end of file diff --git a/examples/demos/procurement_agent/.dockerignore b/examples/demos/procurement_agent/.dockerignore new file mode 100644 index 000000000..c4f7a8b4b --- /dev/null +++ b/examples/demos/procurement_agent/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store \ No newline at end of file diff --git a/examples/demos/procurement_agent/.gitignore b/examples/demos/procurement_agent/.gitignore new file mode 100644 index 000000000..92316bc3d --- /dev/null +++ b/examples/demos/procurement_agent/.gitignore @@ -0,0 +1,62 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +env/ +ENV/ +.venv + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Database files +*.db +*.sqlite +*.sqlite3 + +# Environment variables +.env +.env.local + +# Logs +*.log + +# OS +.DS_Store +Thumbs.db + +# Jupyter +.ipynb_checkpoints/ +*.ipynb_checkpoints + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# UV +.venv/ diff --git a/examples/demos/procurement_agent/Dockerfile b/examples/demos/procurement_agent/Dockerfile new file mode 100644 index 000000000..17dd0e680 --- /dev/null +++ b/examples/demos/procurement_agent/Dockerfile @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +# Install tctl (Temporal CLI) +RUN curl -L https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_arm64.tar.gz -o /tmp/tctl.tar.gz && \ + tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ + chmod +x /usr/local/bin/tctl && \ + rm /tmp/tctl.tar.gz + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy just the pyproject.toml file to optimize caching +COPY procurement_agent/pyproject.toml /app/procurement_agent/pyproject.toml + +WORKDIR /app/procurement_agent + +# Install the required Python packages using uv +RUN uv pip install --system . + +# Copy the project code +COPY procurement_agent/project /app/procurement_agent/project + +# Run the ACP server using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/examples/demos/procurement_agent/README.md b/examples/demos/procurement_agent/README.md new file mode 100644 index 000000000..878c2ec31 --- /dev/null +++ b/examples/demos/procurement_agent/README.md @@ -0,0 +1,412 @@ +# Procurement Agent Demo + +A demonstration of long-running, autonomous AI agents using **Temporal** and **AgentEx**. This agent manages construction procurement workflows that can run for months, respond to external events, and escalate to humans when needed. + +## What This Demo Shows + +This demo illustrates a **procurement manager for building construction** that: + +- **Runs for months or years** - Temporal workflows enable truly persistent agents +- **Responds to external events** - Not just human input, but signals from the real world (shipments, inspections, etc.) +- **Escalates to humans when needed** - Waits indefinitely for human decisions on critical issues +- **Learns from experience** - Remembers past human decisions and applies them to similar situations +- **Manages complex state** - Uses a database to track construction schedules and procurement items + +### Key Concepts + +**Long-Running Workflows**: Thanks to Temporal, the agent can live for months, surviving restarts and failures while maintaining full context. + +**External Event Integration**: The agent receives real-world signals (not just user messages) via Temporal signals and takes autonomous actions. + +**Human-in-the-Loop**: The agent can pause execution indefinitely (up to 24 hours) while waiting for human approval on critical decisions. + +**Learning System**: When a human makes a decision, the agent extracts learnings and applies them to future similar situations. + +**State Management**: Uses SQLite to persist construction schedules and procurement item status, providing queryable visibility into current operations without parsing conversation history. + +**Automatic Summarization**: When conversation history exceeds token limits (~40k tokens), the agent automatically summarizes older messages while preserving recent context, enabling indefinite conversation length. + +## Example Workflow + +Here's what happens when items move through the procurement pipeline: + +1. **Submittal Approved** → Agent issues purchase order and creates tracking record +2. **Shipment Departed Factory** → Agent ingests ETA and checks for schedule conflicts +3. **Shipment Arrived Site** → Agent notifies team and schedules quality inspection +4. **Inspection Failed** → Agent escalates to human with recommended action +5. **Human Decision** → Agent learns from the decision for next time + +## Running the Demo + +### Prerequisites + +You'll need three terminals running: + +1. **AgentEx Backend** (database, Temporal server, etc.) +2. **AgentEx UI** (web interface at localhost:3000) +3. **Procurement Agent** (this demo) + +### Step 1: Start AgentEx Backend + +From the `scale-agentex` repository: + +```bash +make dev +``` + +This starts all required services (Postgres, Temporal, Redis, etc.) via Docker Compose. Verify everything is healthy: + +```bash +# Optional: Use lazydocker for a better view +lzd +``` + +You should see Temporal UI at: http://localhost:8080 + +### Step 2: Start AgentEx Web UI + +From the `scale-agentex-web` repository: + +```bash +make dev +``` + +The UI will be available at: http://localhost:3000 + +### Step 3: Run the Procurement Agent + +From this directory (`examples/demos/procurement_agent`): + +```bash +# Install dependencies +uv sync + +# Run the agent +export ENVIRONMENT=development && uv run agentex agents run --manifest manifest.yaml +``` + +The agent will start and register with the AgentEx backend on port 8000. + +### Step 4: Create a Task + +Go to http://localhost:3000 and: + +1. Create a new task for the `procurement-agent` +2. Send a message like "Hello" to initialize the workflow +3. Note the **Workflow ID** from the Temporal UI at http://localhost:8080 + +### Step 5: Send Test Events + +Now simulate real-world procurement events: + +```bash +# Navigate to the scripts directory +cd project/scripts + +# Send events (you'll be prompted for the workflow ID) +uv run send_test_events.py + +# Or provide the workflow ID directly +uv run send_test_events.py +``` + +The script sends a series of events simulating the procurement lifecycle for multiple items: +- Steel Beams (passes inspection) +- HVAC Units (fails inspection - agent escalates) +- Windows (passes inspection) +- Flooring Materials (passes inspection) +- Electrical Panels (fails inspection - agent applies learnings) + +### Step 6: Observe the Agent + +Watch the agent in action: + +1. **AgentEx UI** (http://localhost:3000) - See agent responses and decisions +2. **Temporal UI** (http://localhost:8080) - View workflow execution, signals, and state +3. **Terminal** - Watch agent logs for detailed operation info + +When an inspection fails, the agent will: +- Analyze the situation +- Recommend an action +- Wait for your response in the AgentEx UI +- Learn from your decision for future similar situations + +## Project Structure + +``` +procurement_agent/ +├── project/ +│ ├── acp.py # ACP server & event handlers +│ ├── workflow.py # Main Temporal workflow logic +│ ├── run_worker.py # Temporal worker setup +│ ├── agents/ +│ │ ├── procurement_agent.py # Main AI agent with procurement tools +│ │ ├── extract_learnings_agent.py # Extracts learnings from human decisions +│ │ └── summarization_agent.py # Summarizes conversation history +│ ├── activities/ +│ │ └── activities.py # Temporal activities (POs, inspections, schedules) +│ ├── data/ +│ │ ├── database.py # SQLite operations +│ │ └── procurement.db # Persistent storage (auto-created) +│ ├── models/ +│ │ └── events.py # Event type definitions (Pydantic models) +│ ├── scripts/ +│ │ └── send_test_events.py # Event simulation script +│ └── utils/ +│ ├── learning_extraction.py # Utilities for extracting context from conversations +│ └── summarization.py # Token counting and summarization logic +├── manifest.yaml # Agent configuration +├── Dockerfile # Container definition +└── pyproject.toml # Dependencies (uv) +``` + +## How It Works + +### 1. Event-Driven Architecture + +The agent receives events via Temporal signals in `workflow.py`: + +```python +@workflow.signal +async def send_event(self, event: str) -> None: + # Validate and queue the event + await self.event_queue.put(event) +``` + +Events are validated against Pydantic models and processed by the AI agent. + +### 2. Human-in-the-Loop Pattern + +Critical decisions require human approval via the `wait_for_human` tool in `procurement_agent.py`: + +```python +@function_tool +async def wait_for_human(recommended_action: str) -> str: + """ + Pause execution until human provides input. + Waits up to 24 hours for response. + """ + await workflow.wait_condition( + lambda: not workflow_instance.human_queue.empty(), + timeout=timedelta(hours=24), + ) + # ... return human response +``` + +The workflow continues only after receiving human input through the AgentEx UI. + +### 3. State Management + +Instead of cramming everything into the LLM context window, the agent uses SQLite to manage: + +- **Master construction schedule** (delivery dates, buffer days, requirements) +- **Procurement items** (status, ETAs, purchase orders, inspection results) + +The database is accessed through Temporal activities with proper error handling and retry policies. + +### 4. Learning System + +When humans make decisions, the agent extracts learnings in `extract_learnings_agent.py`: + +```python +# After human input, extract the learning +extraction_result = await Runner.run(extract_agent, new_context, hooks=hooks) +learning = extraction_result.final_output + +# Store in workflow state for future reference +self.human_input_learnings.append(learning) +``` + +These learnings are passed into the agent's system prompt on subsequent runs. + +### 5. Automatic Summarization + +For long-running workflows, conversation history can grow unbounded. The agent automatically manages context using intelligent summarization: + +```python +# After each turn, check if summarization is needed +if should_summarize(self._state.input_list): + # Find messages to summarize (preserves last 10 turns, starts after previous summary) + messages_to_summarize, start_index, end_index = get_messages_to_summarize( + self._state.input_list, + last_summary_index + ) + + # Generate summary with dedicated agent + summary_agent = new_summarization_agent() + summary_result = await Runner.run(summary_agent, messages_to_summarize, hooks=hooks) + + # Replace summarized portion with compact summary + self._state.input_list = apply_summary_to_input_list(...) +``` + +Key features: +- **Token threshold**: Triggers at ~40k tokens to stay within model limits +- **Preserves recent context**: Always keeps last 10 user turns in full detail +- **Never re-summarizes**: Starts after the most recent summary to avoid information loss +- **Dedicated summarization agent**: GPT-4o agent focused on extracting key procurement events, decisions, and current state + +This enables workflows to run indefinitely without hitting context limits. + +### 6. Error Handling & Retries + +The workflow uses Temporal's retry policies for resilient execution: + +```python +retry_policy = RetryPolicy( + initial_interval=timedelta(seconds=1), + backoff_coefficient=2.0, # Exponential backoff + maximum_interval=timedelta(seconds=120), + maximum_attempts=5, + non_retryable_error_types=[ + "DataCorruptionError", + "ScheduleNotFoundError", + ] +) +``` + +Activities automatically retry on transient failures but fail fast on data corruption. + +## Key Features + +### Durability +- Workflows survive process restarts, crashes, and deployments +- All state is persisted in Temporal and SQLite +- No context is lost even after months of runtime + +### External Event Processing +- Responds to events from external systems (ERP, logistics, QA) +- Validates and processes events asynchronously +- Multiple event types supported (approvals, shipments, inspections) + +### Human Escalation +- Automatically escalates critical issues (schedule delays, inspection failures) +- Provides recommended actions to humans +- Waits indefinitely (up to 24 hours) for human response +- Continues workflow after receiving guidance + +### Learning & Adaptation +- Extracts patterns from human decisions +- Applies learned rules to similar future situations +- Becomes more autonomous over time +- Human maintains oversight and final authority + +### Observability +- Full workflow history in Temporal UI +- Real-time agent responses in AgentEx UI +- Detailed logging for debugging +- Database audit trail for all changes + +## Customizing the Demo + +### Modify the Construction Schedule + +Edit the default schedule in `project/data/database.py`: + +```python +DEFAULT_SCHEDULE = { + "project": { + "name": "Small Office Renovation", + "start_date": "2026-02-01", + "end_date": "2026-05-31" + }, + "deliveries": [ + { + "item": "Steel Beams", + "required_by": "2026-02-15", + "buffer_days": 5 + }, + # ... add more items + ] +} +``` + +### Add New Event Types + +1. Define the event in `project/models/events.py` +2. Update event validation in `workflow.py` +3. Teach the agent how to handle it in `procurement_agent.py` +4. Add test events in `project/scripts/send_test_events.py` + +### Change Agent Behavior + +Modify the agent's instructions in `project/agents/procurement_agent.py`: + +```python +def new_procurement_agent(master_construction_schedule: str, human_input_learnings: list) -> Agent: + instructions = f""" + You are a procurement agent for a commercial building construction project. + + [Your custom instructions here...] + """ + # ... +``` + +### Add New Tools + +Create new activities in `project/activities/activities.py` and register them as tools: + +```python +@activity.defn(name="my_custom_activity") +async def my_custom_activity(param: str) -> str: + # ... your logic + return result + +# Register in the agent +tools=[ + openai_agents.workflow.activity_as_tool( + my_custom_activity, + start_to_close_timeout=timedelta(minutes=10) + ), + # ... other tools +] +``` + +## Troubleshooting + +**Agent not appearing in UI** +- Verify agent is running on port 8000: `lsof -i :8000` +- Check `ENVIRONMENT=development` is set +- Review agent logs for errors + +**Events not being received** +- Confirm workflow ID is correct (check Temporal UI) +- Verify Temporal server is running: `docker ps | grep temporal` +- Check that send_test_events.py is using the right workflow ID + +**Human escalation timeout** +- The agent waits 24 hours for human input before timing out +- Respond in the AgentEx UI task thread +- Check that your message is being sent to the correct task + +**Database errors** +- The database is automatically created at `project/data/procurement.db` +- Delete the file to reset: `rm project/data/procurement.db` +- The agent will recreate it on next run + +**Import errors** +- Make sure dependencies are installed: `uv sync` +- Verify you're running from the correct directory +- Check Python version is 3.12+ + +## What's Next? + +This demo shows the foundation for autonomous, long-running agents. Potential applications include: + +- **Supply chain management** - Track orders, shipments, and inventory across months +- **Compliance workflows** - Monitor regulatory requirements and schedule audits +- **Customer success** - Proactive outreach based on usage patterns and lifecycle stage +- **Infrastructure management** - React to alerts, coordinate maintenance, escalate outages +- **Financial processes** - Invoice approval workflows, budget tracking, expense management + +The key insight: **AI agents don't just answer questions—they can run real-world processes autonomously over time.** + +## Learn More + +- [AgentEx Documentation](https://agentex.sgp.scale.com/docs/) +- [Temporal Documentation](https://docs.temporal.io/) +- [OpenAI Agents SDK](https://github.com/openai/agents-sdk) + +--- + +**Questions or issues?** Open an issue on the [scale-agentex GitHub repository](https://github.com/scaleapi/scale-agentex). diff --git a/examples/demos/procurement_agent/dev.ipynb b/examples/demos/procurement_agent/dev.ipynb new file mode 100644 index 000000000..53b70d152 --- /dev/null +++ b/examples/demos/procurement_agent/dev.ipynb @@ -0,0 +1,126 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"procurement-agent\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [], + "source": [ + "# (REQUIRED) Create a new task. For Async agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b03b0d37", + "metadata": {}, + "outputs": [], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a6927cc0", + "metadata": {}, + "outputs": [], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4864e354", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/examples/demos/procurement_agent/environments.yaml b/examples/demos/procurement_agent/environments.yaml new file mode 100644 index 000000000..90f44ae6c --- /dev/null +++ b/examples/demos/procurement_agent/environments.yaml @@ -0,0 +1,64 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-procurement-agent" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + # This is used to override the global helm values.yaml file in the agentex-agent helm charts + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + temporal-worker: + enabled: true + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/examples/demos/procurement_agent/evals/README.md b/examples/demos/procurement_agent/evals/README.md new file mode 100644 index 000000000..ddb96573c --- /dev/null +++ b/examples/demos/procurement_agent/evals/README.md @@ -0,0 +1,63 @@ +# Procurement Agent Evals + +Integration tests for the procurement agent that verify tool calls and database state. + +## Prerequisites + +1. AgentEx backend running (`make dev` from scale-agentex) +2. Procurement agent running: + ```bash + cd examples/demos/procurement_agent + export ENVIRONMENT=development + uv run agentex agents run --manifest manifest.yaml + ``` + +## Running Tests + +From the `procurement_agent` directory: + +```bash +# Run all tests +cd evals && uv run pytest + +# Run specific test file +cd evals && uv run pytest tasks/test_shipment_departed.py -v + +# Run single test +cd evals && uv run pytest tasks/test_shipment_departed.py::test_departed_01_no_flag_5_days_early -v +``` + +## Test Structure + +| File | Event Type | Focus | +|------|------------|-------| +| `test_submittal_approved.py` | Submittal_Approved | PO issued, DB entry | +| `test_shipment_departed.py` | Shipment_Departed | **False positive detection** | +| `test_shipment_arrived.py` | Shipment_Arrived | Team notification, inspection | +| `test_inspection_failed.py` | Inspection_Failed | Human-in-the-loop | +| `test_inspection_passed.py` | Inspection_Passed | Status update | + +## Test Cases Summary + +| Event | Tests | Key Assertions | +|-------|-------|----------------| +| Submittal_Approved | 2 | `issue_purchase_order` called, DB item created | +| Shipment_Departed | 6 | Forbidden: `flag_potential_issue` when ETA < required_by | +| Shipment_Arrived | 2 | `notify_team`, `schedule_inspection` called | +| Inspection_Failed | 3 | Human-in-loop: approve, approve+extra, reject+delete | +| Inspection_Passed | 2 | Forbidden: `wait_for_human`, `flag_potential_issue` | + +## Graders + +- **tool_calls.py**: Verifies required and forbidden tool calls in transcripts +- **database.py**: Verifies database state changes + +## False Positive Detection + +The `test_shipment_departed.py` tests are specifically designed to catch the false positive issue where the agent incorrectly flags conflicts. + +**Conflict logic:** +- **Flag if** ETA >= required_by (zero/negative buffer) +- **Don't flag if** ETA < required_by (has buffer remaining) + +The tests use `assert_forbidden_tools(["flag_potential_issue"])` to catch cases where the agent incorrectly escalates. diff --git a/examples/demos/procurement_agent/evals/__init__.py b/examples/demos/procurement_agent/evals/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/demos/procurement_agent/evals/conftest.py b/examples/demos/procurement_agent/evals/conftest.py new file mode 100644 index 000000000..28e299b45 --- /dev/null +++ b/examples/demos/procurement_agent/evals/conftest.py @@ -0,0 +1,227 @@ +""" +Pytest fixtures for procurement agent evals. + +Provides workflow setup, transcript extraction, and human input simulation. +""" +from __future__ import annotations + +import os +import uuid +import asyncio +from typing import Any, AsyncGenerator +from datetime import datetime as dt + +import pytest +import pytest_asyncio +from temporalio.client import Client, WorkflowHandle + +from agentex.types.task import Task +from agentex.types.agent import Agent +from agentex.lib.types.acp import CreateTaskParams + +# Set environment variables for local development +os.environ.setdefault("AGENT_NAME", "procurement-agent") +os.environ.setdefault("ACP_URL", "http://localhost:8000") +os.environ.setdefault("WORKFLOW_NAME", "procurement-agent") +os.environ.setdefault("WORKFLOW_TASK_QUEUE", "procurement_agent_queue") +os.environ.setdefault("TEMPORAL_ADDRESS", "localhost:7233") + + +@pytest.fixture(scope="session") +def event_loop(): + """Create an event loop for the test session.""" + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + +@pytest_asyncio.fixture(scope="session") +async def temporal_client() -> AsyncGenerator[Client, None]: + """Create a Temporal client for the test session.""" + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233") + ) + yield client + # Client doesn't need explicit close + + +@pytest_asyncio.fixture +async def workflow_handle(temporal_client: Client) -> AsyncGenerator[WorkflowHandle, None]: + """ + Start a fresh workflow for each test. + + Creates a unique workflow ID and starts the procurement agent workflow. + Yields the handle for sending signals and querying state. + """ + workflow_id = f"eval-{uuid.uuid4()}" + task_queue = os.environ.get("WORKFLOW_TASK_QUEUE", "procurement_agent_queue") + workflow_name = os.environ.get("WORKFLOW_NAME", "procurement-agent") + + # Create agent and task params + now = dt.now() + agent = Agent( + id="procurement-agent", + name="procurement-agent", + acp_type="agentic", + description="Procurement agent for construction delivery management", + created_at=now, + updated_at=now, + ) + task = Task(id=workflow_id) + create_task_params = CreateTaskParams(agent=agent, task=task, params=None) + + # Start the workflow + handle = await temporal_client.start_workflow( + workflow_name, + create_task_params, + id=workflow_id, + task_queue=task_queue, + ) + + # Give workflow time to initialize + await asyncio.sleep(2) + + yield handle + + # Cleanup: terminate the workflow after test + try: + await handle.terminate("Test completed") + except Exception: + pass # Workflow may have already completed + + +async def send_event(handle: WorkflowHandle, event: Any) -> None: + """ + Send an event to the workflow via signal. + + Args: + handle: The workflow handle + event: A Pydantic event model (will be serialized to JSON) + """ + event_json = event.model_dump_json() + await handle.signal("send_event", event_json) + + +async def send_human_response(handle: WorkflowHandle, response: str) -> None: + """ + Send a human response to the workflow. + + This simulates a user responding in the UI to a wait_for_human escalation. + + Args: + handle: The workflow handle + response: The human's text response + """ + # Import here to avoid circular imports + from agentex.types.task import Task + from agentex.types.agent import Agent + from agentex.types.event import Event + from agentex.lib.types.acp import SendEventParams + from agentex.types.text_content import TextContent + + now = dt.now() + agent = Agent( + id="procurement-agent", + name="procurement-agent", + acp_type="agentic", + description="Procurement agent for construction delivery management", + created_at=now, + updated_at=now, + ) + task = Task(id=handle.id) + event = Event( + id=str(uuid.uuid4()), + agent_id="procurement-agent", + task_id=handle.id, + sequence_id=1, + content=TextContent(author="user", content=response), + ) + params = SendEventParams(agent=agent, task=task, event=event) + + await handle.signal("receive_event", params) + + +async def wait_for_processing(_handle: WorkflowHandle, timeout_seconds: float = 60) -> None: + """ + Wait for the workflow to finish processing an event. + + Polls the workflow until no more activities are running. + + Args: + _handle: The workflow handle (unused, reserved for future polling) + timeout_seconds: Maximum time to wait + """ + # Simple approach: wait a fixed time for agent to process + # In production, you'd poll workflow state more intelligently + await asyncio.sleep(timeout_seconds) + + +async def get_workflow_transcript(handle: WorkflowHandle) -> list[dict[str, Any]]: + """ + Extract the conversation transcript from workflow history. + + Queries the workflow to get the internal state containing tool calls. + + Args: + handle: The workflow handle + + Returns: + List of message dicts containing tool calls and responses + """ + # Query workflow state to get the input_list (conversation history) + # This requires the workflow to expose a query handler + + # For now, we'll extract from workflow history events + # The tool calls appear in activity completions + transcript = [] + + async for event in handle.fetch_history_events(): + # Look for activity completed events + if hasattr(event, 'activity_task_completed_event_attributes'): + attrs = event.activity_task_completed_event_attributes + if attrs and hasattr(attrs, 'result'): + # Activity results contain tool execution info + transcript.append({ + "type": "activity_completed", + "result": str(attrs.result) if attrs.result else None, + }) + + # Look for activity scheduled events (contains tool name) + if hasattr(event, 'activity_task_scheduled_event_attributes'): + attrs = event.activity_task_scheduled_event_attributes + if attrs and hasattr(attrs, 'activity_type'): + activity_name = attrs.activity_type.name if attrs.activity_type else None + transcript.append({ + "type": "function_call", + "name": activity_name, + }) + + return transcript + + +async def get_transcript_event_count(handle: WorkflowHandle) -> int: + """Get the current number of events in the transcript.""" + transcript = await get_workflow_transcript(handle) + return len(transcript) + + +def get_new_tool_calls( + full_transcript: list[dict[str, Any]], + previous_count: int +) -> list[dict[str, Any]]: + """ + Get only the new tool calls since the previous checkpoint. + + Args: + full_transcript: The complete transcript from get_workflow_transcript + previous_count: The transcript length before the event was sent + + Returns: + List of new tool call entries + """ + return full_transcript[previous_count:] + + +def get_workflow_id(handle: WorkflowHandle) -> str: + """Get the workflow ID from a handle.""" + return handle.id diff --git a/examples/demos/procurement_agent/evals/fixtures/__init__.py b/examples/demos/procurement_agent/evals/fixtures/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/demos/procurement_agent/evals/fixtures/events.py b/examples/demos/procurement_agent/evals/fixtures/events.py new file mode 100644 index 000000000..31f1c919b --- /dev/null +++ b/examples/demos/procurement_agent/evals/fixtures/events.py @@ -0,0 +1,108 @@ +""" +Event fixtures for eval test cases. + +Provides factory functions to create events with configurable parameters. +""" +from typing import Optional +from datetime import datetime, timedelta + +from project.models.events import ( + EventType, + InspectionFailedEvent, + InspectionPassedEvent, + SubmitalApprovalEvent, + ShipmentArrivedSiteEvent, + ShipmentDepartedFactoryEvent, +) + + +def create_submittal_approved(item: str) -> SubmitalApprovalEvent: + """Create a Submittal_Approved event.""" + return SubmitalApprovalEvent( + event_type=EventType.SUBMITTAL_APPROVED, + item=item, + document_name=f"{item} Submittal.pdf", + document_url=f"/submittals/{item.lower().replace(' ', '_')}.pdf", + ) + + +def create_shipment_departed( + item: str, + eta: datetime, + date_departed: Optional[datetime] = None, +) -> ShipmentDepartedFactoryEvent: + """ + Create a Shipment_Departed_Factory event. + + Args: + item: The item name + eta: Estimated time of arrival (this is what gets compared to required_by) + date_departed: When shipment left factory (defaults to 7 days before ETA) + """ + if date_departed is None: + date_departed = eta - timedelta(days=7) + + return ShipmentDepartedFactoryEvent( + event_type=EventType.SHIPMENT_DEPARTED_FACTORY, + item=item, + eta=eta, + date_departed=date_departed, + location_address="218 W 18th St, New York, NY 10011", + ) + + +def create_shipment_arrived( + item: str, + date_arrived: datetime, +) -> ShipmentArrivedSiteEvent: + """Create a Shipment_Arrived_Site event.""" + return ShipmentArrivedSiteEvent( + event_type=EventType.SHIPMENT_ARRIVED_SITE, + item=item, + date_arrived=date_arrived, + location_address="650 Townsend St, San Francisco, CA 94103", + ) + + +def create_inspection_failed( + item: str, + inspection_date: Optional[datetime] = None, +) -> InspectionFailedEvent: + """Create an Inspection_Failed event.""" + if inspection_date is None: + inspection_date = datetime.now() + + return InspectionFailedEvent( + event_type=EventType.INSPECTION_FAILED, + item=item, + inspection_date=inspection_date, + document_name=f"{item} Inspection Report.pdf", + document_url=f"/inspections/{item.lower().replace(' ', '_')}_failed.pdf", + ) + + +def create_inspection_passed( + item: str, + inspection_date: Optional[datetime] = None, +) -> InspectionPassedEvent: + """Create an Inspection_Passed event.""" + if inspection_date is None: + inspection_date = datetime.now() + + return InspectionPassedEvent( + event_type=EventType.INSPECTION_PASSED, + item=item, + inspection_date=inspection_date, + document_name=f"{item} Inspection Report.pdf", + document_url=f"/inspections/{item.lower().replace(' ', '_')}_passed.pdf", + ) + + +# Default schedule reference (matches database.py DEFAULT_SCHEDULE) +SCHEDULE_REFERENCE = { + "Steel Beams": {"required_by": "2026-02-15", "buffer_days": 5}, + "HVAC Units": {"required_by": "2026-03-01", "buffer_days": 7}, + "Windows": {"required_by": "2026-03-15", "buffer_days": 10}, + "Flooring Materials": {"required_by": "2026-04-01", "buffer_days": 3}, + "Electrical Panels": {"required_by": "2026-04-15", "buffer_days": 5}, +} diff --git a/examples/demos/procurement_agent/evals/graders/__init__.py b/examples/demos/procurement_agent/evals/graders/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/demos/procurement_agent/evals/graders/database.py b/examples/demos/procurement_agent/evals/graders/database.py new file mode 100644 index 000000000..e1651662c --- /dev/null +++ b/examples/demos/procurement_agent/evals/graders/database.py @@ -0,0 +1,187 @@ +""" +Database grader - verifies database state after agent actions. +""" +from __future__ import annotations + +import json +from typing import Any, Optional +from pathlib import Path + +import aiosqlite # type: ignore[import-not-found] + +# Use the same DB path as the main application +DB_PATH = Path(__file__).parent.parent.parent / "project" / "data" / "procurement.db" + + +async def get_procurement_item(workflow_id: str, item: str) -> Optional[dict[str, Any]]: + """ + Get a procurement item from the database. + + Args: + workflow_id: The Temporal workflow ID + item: The item name + + Returns: + Dict with item fields or None if not found + """ + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute( + """ + SELECT workflow_id, item, status, eta, date_arrived, purchase_order_id, + created_at, updated_at + FROM procurement_items + WHERE workflow_id = ? AND item = ? + """, + (workflow_id, item) + ) as cursor: + row = await cursor.fetchone() + if row: + return dict(row) + return None + + +async def get_schedule_delivery(workflow_id: str, item: str) -> Optional[dict[str, Any]]: + """ + Get a delivery item from the master construction schedule. + + Args: + workflow_id: The Temporal workflow ID + item: The item name + + Returns: + Dict with delivery fields or None if not found + """ + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute( + """ + SELECT schedule_json + FROM master_construction_schedule + WHERE workflow_id = ? + """, + (workflow_id,) + ) as cursor: + row = await cursor.fetchone() + if row: + schedule = json.loads(row["schedule_json"]) + for delivery in schedule.get("deliveries", []): + if delivery.get("item") == item: + return delivery + return None + + +async def assert_procurement_item_exists( + workflow_id: str, + item: str, + expected_status: Optional[str] = None, + expected_po_id_not_null: bool = False, + expected_eta: Optional[str] = None, + expected_date_arrived: Optional[str] = None, +) -> dict[str, Any]: + """ + Assert a procurement item exists with expected fields. + + Args: + workflow_id: The Temporal workflow ID + item: The item name + expected_status: If provided, assert status matches + expected_po_id_not_null: If True, assert purchase_order_id is not null + expected_eta: If provided, assert ETA matches + expected_date_arrived: If provided, assert date_arrived matches + + Returns: + The procurement item record + + Raises: + AssertionError: If item doesn't exist or fields don't match + """ + record = await get_procurement_item(workflow_id, item) + + if record is None: + raise AssertionError( + f"Procurement item not found: workflow_id={workflow_id}, item={item}" + ) + + if expected_status is not None: + assert record["status"] == expected_status, ( + f"Expected status '{expected_status}', got '{record['status']}'" + ) + + if expected_po_id_not_null: + assert record["purchase_order_id"] is not None, ( + "Expected purchase_order_id to be set, but it was null" + ) + + if expected_eta is not None: + assert record["eta"] == expected_eta, ( + f"Expected ETA '{expected_eta}', got '{record['eta']}'" + ) + + if expected_date_arrived is not None: + assert record["date_arrived"] == expected_date_arrived, ( + f"Expected date_arrived '{expected_date_arrived}', got '{record['date_arrived']}'" + ) + + return record + + +async def assert_procurement_item_not_exists(workflow_id: str, item: str) -> None: + """ + Assert a procurement item does NOT exist (was deleted). + + Args: + workflow_id: The Temporal workflow ID + item: The item name + + Raises: + AssertionError: If item still exists + """ + record = await get_procurement_item(workflow_id, item) + if record is not None: + raise AssertionError( + f"Procurement item should not exist but was found: {record}" + ) + + +async def assert_schedule_item_not_exists(workflow_id: str, item: str) -> None: + """ + Assert an item is NOT in the master construction schedule (was removed). + + Args: + workflow_id: The Temporal workflow ID + item: The item name + + Raises: + AssertionError: If item still in schedule + """ + delivery = await get_schedule_delivery(workflow_id, item) + if delivery is not None: + raise AssertionError( + f"Schedule item should not exist but was found: {delivery}" + ) + + +async def assert_schedule_delivery_date( + workflow_id: str, + item: str, + expected_required_by: str +) -> None: + """ + Assert a delivery item has the expected required_by date. + + Args: + workflow_id: The Temporal workflow ID + item: The item name + expected_required_by: The expected date string + + Raises: + AssertionError: If date doesn't match + """ + delivery = await get_schedule_delivery(workflow_id, item) + if delivery is None: + raise AssertionError(f"Schedule delivery not found for item: {item}") + + assert delivery["required_by"] == expected_required_by, ( + f"Expected required_by '{expected_required_by}', got '{delivery['required_by']}'" + ) diff --git a/examples/demos/procurement_agent/evals/graders/tool_calls.py b/examples/demos/procurement_agent/evals/graders/tool_calls.py new file mode 100644 index 000000000..7c28a8626 --- /dev/null +++ b/examples/demos/procurement_agent/evals/graders/tool_calls.py @@ -0,0 +1,80 @@ +""" +Tool call grader - extracts and verifies tool calls from workflow transcripts. +""" +from __future__ import annotations + +from typing import Any + + +def extract_tool_calls(transcript: list[dict[str, Any]]) -> list[str]: + """ + Extract tool/function names from a workflow transcript. + + The transcript is the messages array from the agent run, containing + items with type="function_call" for tool invocations. + + Args: + transcript: List of message dicts from agent execution + + Returns: + List of tool names that were called + """ + tool_calls = [] + for item in transcript: + if isinstance(item, dict): + # Handle function_call type (from OpenAI agents format) + if item.get("type") == "function_call": + tool_name = item.get("name") + if tool_name: + tool_calls.append(tool_name) + # Handle tool_calls nested in assistant messages + if item.get("role") == "assistant" and "tool_calls" in item: + for tc in item.get("tool_calls", []): + if isinstance(tc, dict) and "function" in tc: + tool_name = tc["function"].get("name") + if tool_name: + tool_calls.append(tool_name) + return tool_calls + + +def assert_required_tools(transcript: list[dict[str, Any]], required: list[str]) -> None: + """ + Assert that all required tools were called. + + Args: + transcript: The workflow transcript + required: List of tool names that must appear + + Raises: + AssertionError: If any required tool is missing + """ + called = set(extract_tool_calls(transcript)) + missing = set(required) - called + if missing: + raise AssertionError( + f"Required tools not called: {missing}. " + f"Tools that were called: {called}" + ) + + +def assert_forbidden_tools(transcript: list[dict[str, Any]], forbidden: list[str]) -> None: + """ + Assert that forbidden tools were NOT called. + + This is critical for catching false positives (e.g., flagging conflicts + when there shouldn't be any). + + Args: + transcript: The workflow transcript + forbidden: List of tool names that must NOT appear + + Raises: + AssertionError: If any forbidden tool was called + """ + called = set(extract_tool_calls(transcript)) + violations = called & set(forbidden) + if violations: + raise AssertionError( + f"Forbidden tools were called: {violations}. " + f"These tools should NOT have been invoked in this scenario." + ) diff --git a/examples/demos/procurement_agent/evals/pytest.ini b/examples/demos/procurement_agent/evals/pytest.ini new file mode 100644 index 000000000..71d66ba7f --- /dev/null +++ b/examples/demos/procurement_agent/evals/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +asyncio_mode = auto +testpaths = tasks +python_files = test_*.py +python_functions = test_* +markers = + asyncio: mark test as async +addopts = -v --tb=short diff --git a/examples/demos/procurement_agent/evals/report.html b/examples/demos/procurement_agent/evals/report.html new file mode 100644 index 000000000..8b3b6ea04 --- /dev/null +++ b/examples/demos/procurement_agent/evals/report.html @@ -0,0 +1,1094 @@ + + + + + report.html + + + + +

report.html

+

Report generated on 20-Jan-2026 at 11:45:33 by pytest-html + v4.2.0

+
+

Environment

+
+
+ + + + + +
+
+

Summary

+
+
+

15 tests took 00:23:01.

+

(Un)check the boxes to filter the results.

+
+ +
+
+
+
+ + 2 Failed, + + 13 Passed, + + 0 Skipped, + + 0 Expected failures, + + 0 Unexpected passes, + + 0 Errors, + + 0 Reruns + + 0 Retried, +
+
+  /  +
+
+
+
+
+
+
+
+ + + + + + + + + +
ResultTestDurationLinks
+
+
+ +
+ + \ No newline at end of file diff --git a/examples/demos/procurement_agent/evals/tasks/__init__.py b/examples/demos/procurement_agent/evals/tasks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/demos/procurement_agent/evals/tasks/test_inspection_failed.py b/examples/demos/procurement_agent/evals/tasks/test_inspection_failed.py new file mode 100644 index 000000000..a975ecd5d --- /dev/null +++ b/examples/demos/procurement_agent/evals/tasks/test_inspection_failed.py @@ -0,0 +1,162 @@ +""" +Tests for Inspection_Failed event handling with human-in-the-loop. + +Verifies: +- Agent escalates to human (wait_for_human called) +- Agent responds correctly to different human inputs: + 1. "Yes" - executes recommended action + 2. "Yes, and also..." - executes action + additional request + 3. "No, delete..." - removes item from schedule +""" +from datetime import datetime + +import pytest + +from evals.conftest import ( + send_event, + get_workflow_id, + send_human_response, + wait_for_processing, + get_workflow_transcript, +) +from evals.fixtures.events import ( + create_shipment_arrived, + create_inspection_failed, + create_shipment_departed, + create_submittal_approved, +) +from evals.graders.database import ( + assert_schedule_delivery_date, + assert_procurement_item_exists, + assert_schedule_item_not_exists, + assert_procurement_item_not_exists, +) +from evals.graders.tool_calls import assert_required_tools + + +async def setup_through_arrived(workflow_handle, item: str) -> None: + """Helper to set up item through shipment arrived state.""" + eta = datetime(2026, 2, 15, 11, 0) + arrival = datetime(2026, 2, 15, 10, 30) + + await send_event(workflow_handle, create_submittal_approved(item)) + await wait_for_processing(workflow_handle, timeout_seconds=30) + + await send_event(workflow_handle, create_shipment_departed(item, eta=eta)) + await wait_for_processing(workflow_handle, timeout_seconds=30) + + await send_event(workflow_handle, create_shipment_arrived(item, date_arrived=arrival)) + await wait_for_processing(workflow_handle, timeout_seconds=30) + + +@pytest.mark.asyncio +async def test_failed_01_human_approves(workflow_handle): + """ + Test Inspection_Failed where human approves recommendation. + + Human response: "Yes" + Expected: Agent executes its recommended action + """ + item = "HVAC Units" + workflow_id = get_workflow_id(workflow_handle) + + # Setup through arrived state + await setup_through_arrived(workflow_handle, item) + + # Send inspection failed + event = create_inspection_failed(item) + await send_event(workflow_handle, event) + + # Wait for agent to escalate (call wait_for_human) + await wait_for_processing(workflow_handle, timeout_seconds=30) + + # Send human approval + await send_human_response(workflow_handle, "Yes") + + # Wait for agent to process response + await wait_for_processing(workflow_handle, timeout_seconds=30) + + # Note: wait_for_human is a function_tool (not Temporal activity), + # so we verify the workflow responded correctly by checking DB state + + # DB should still have the item (agent executed recommendation) + await assert_procurement_item_exists( + workflow_id=workflow_id, + item=item, + ) + + +@pytest.mark.asyncio +async def test_failed_02_human_approves_with_extra_action(workflow_handle): + """ + Test Inspection_Failed where human approves + requests extra action. + + Human response: "Yes, and also update the delivery date to 2026-03-15" + Expected: Agent executes recommendation AND updates delivery date + """ + item = "HVAC Units" + workflow_id = get_workflow_id(workflow_handle) + + await setup_through_arrived(workflow_handle, item) + + event = create_inspection_failed(item) + await send_event(workflow_handle, event) + await wait_for_processing(workflow_handle, timeout_seconds=30) + + # Human approves AND requests delivery date update + await send_human_response( + workflow_handle, + "Yes, and also update the delivery date to 2026-03-15" + ) + await wait_for_processing(workflow_handle, timeout_seconds=60) # More time for extra action + + transcript = await get_workflow_transcript(workflow_handle) + # Note: wait_for_human is a function_tool (not visible in Temporal history) + # Verify the agent responded to human input by calling update_delivery_date_for_item + assert_required_tools(transcript, [ + "update_delivery_date_for_item", # Should update schedule + ]) + + # Verify schedule was updated + await assert_schedule_delivery_date( + workflow_id=workflow_id, + item=item, + expected_required_by="2026-03-15", + ) + + +@pytest.mark.asyncio +async def test_failed_03_human_rejects_delete(workflow_handle): + """ + Test Inspection_Failed where human rejects and requests deletion. + + Human response: "No, remove it from the master schedule entirely" + Expected: Item removed from schedule AND procurement items + """ + item = "HVAC Units" + workflow_id = get_workflow_id(workflow_handle) + + await setup_through_arrived(workflow_handle, item) + + event = create_inspection_failed(item) + await send_event(workflow_handle, event) + await wait_for_processing(workflow_handle, timeout_seconds=30) + + # Human rejects and requests deletion + await send_human_response( + workflow_handle, + "No, remove it from the master schedule entirely" + ) + await wait_for_processing(workflow_handle, timeout_seconds=60) + + transcript = await get_workflow_transcript(workflow_handle) + # Note: wait_for_human is a function_tool (not visible in Temporal history) + # Verify the agent responded to human input by removing/deleting items + assert_required_tools(transcript, [ + "remove_delivery_item", # Remove from schedule + "delete_procurement_item_activity", # Delete tracking record + ]) + + # Verify item was deleted from both places + await assert_procurement_item_not_exists(workflow_id, item) + await assert_schedule_item_not_exists(workflow_id, item) diff --git a/examples/demos/procurement_agent/evals/tasks/test_inspection_passed.py b/examples/demos/procurement_agent/evals/tasks/test_inspection_passed.py new file mode 100644 index 000000000..815a5ab23 --- /dev/null +++ b/examples/demos/procurement_agent/evals/tasks/test_inspection_passed.py @@ -0,0 +1,116 @@ +""" +Tests for Inspection_Passed event handling. + +Verifies: +- Procurement item status updated to passed +- No escalation to human (forbidden tools) +""" +from datetime import datetime + +import pytest + +from evals.conftest import ( + send_event, + get_workflow_id, + get_new_tool_calls, + wait_for_processing, + get_workflow_transcript, + get_transcript_event_count, +) +from evals.fixtures.events import ( + create_shipment_arrived, + create_inspection_passed, + create_shipment_departed, + create_submittal_approved, +) +from evals.graders.database import assert_procurement_item_exists +from evals.graders.tool_calls import assert_required_tools, assert_forbidden_tools + + +async def setup_through_arrived(workflow_handle, item: str, eta: datetime, arrival: datetime) -> None: + """Helper to set up item through shipment arrived state.""" + await send_event(workflow_handle, create_submittal_approved(item)) + await wait_for_processing(workflow_handle, timeout_seconds=30) + + await send_event(workflow_handle, create_shipment_departed(item, eta=eta)) + await wait_for_processing(workflow_handle, timeout_seconds=30) + + await send_event(workflow_handle, create_shipment_arrived(item, date_arrived=arrival)) + await wait_for_processing(workflow_handle, timeout_seconds=30) + + +@pytest.mark.asyncio +async def test_passed_01_steel_beams(workflow_handle): + """ + Test Inspection_Passed for Steel Beams. + + Expected: + - update_procurement_item_activity called + - NO wait_for_human (should not escalate on success) + - NO flag_potential_issue + - DB shows inspection_passed status + """ + item = "Steel Beams" + workflow_id = get_workflow_id(workflow_handle) + + eta = datetime(2026, 2, 10, 14, 30) + arrival = datetime(2026, 2, 10, 15, 45) + await setup_through_arrived(workflow_handle, item, eta, arrival) + + # Get transcript count BEFORE sending inspection_passed + previous_count = await get_transcript_event_count(workflow_handle) + + # Send inspection passed + event = create_inspection_passed(item) + await send_event(workflow_handle, event) + await wait_for_processing(workflow_handle, timeout_seconds=30) + + # Verify tool calls for THIS EVENT ONLY (not entire workflow) + full_transcript = await get_workflow_transcript(workflow_handle) + new_tool_calls = get_new_tool_calls(full_transcript, previous_count) + assert_required_tools(new_tool_calls, ["update_procurement_item_activity"]) + assert_forbidden_tools(new_tool_calls, [ + "wait_for_human", # Should NOT escalate on success + "flag_potential_issue", # Should NOT flag issues + ]) + + # Verify DB state + await assert_procurement_item_exists( + workflow_id=workflow_id, + item=item, + expected_status="inspection_passed", + ) + + +@pytest.mark.asyncio +async def test_passed_02_windows(workflow_handle): + """ + Test Inspection_Passed for Windows. + + Same expectations as Steel Beams. + """ + item = "Windows" + workflow_id = get_workflow_id(workflow_handle) + + eta = datetime(2026, 3, 5, 16, 0) + arrival = datetime(2026, 3, 5, 16, 20) + await setup_through_arrived(workflow_handle, item, eta, arrival) + + # Get transcript count BEFORE sending inspection_passed + previous_count = await get_transcript_event_count(workflow_handle) + + event = create_inspection_passed(item) + await send_event(workflow_handle, event) + await wait_for_processing(workflow_handle, timeout_seconds=30) + + # Verify tool calls for THIS EVENT ONLY + full_transcript = await get_workflow_transcript(workflow_handle) + new_tool_calls = get_new_tool_calls(full_transcript, previous_count) + assert_required_tools(new_tool_calls, ["update_procurement_item_activity"]) + assert_forbidden_tools(new_tool_calls, ["wait_for_human", "flag_potential_issue"]) + + await assert_procurement_item_exists( + workflow_id=workflow_id, + item=item, + expected_status="inspection_passed", + ) diff --git a/examples/demos/procurement_agent/evals/tasks/test_shipment_arrived.py b/examples/demos/procurement_agent/evals/tasks/test_shipment_arrived.py new file mode 100644 index 000000000..9d9ee293f --- /dev/null +++ b/examples/demos/procurement_agent/evals/tasks/test_shipment_arrived.py @@ -0,0 +1,119 @@ +""" +Tests for Shipment_Arrived_Site event handling. + +Verifies: +- Team notification sent +- Inspection scheduled +- Procurement item updated with arrival date +""" +from datetime import datetime + +import pytest + +from evals.conftest import ( + send_event, + get_workflow_id, + get_new_tool_calls, + wait_for_processing, + get_workflow_transcript, + get_transcript_event_count, +) +from evals.fixtures.events import ( + create_shipment_arrived, + create_shipment_departed, + create_submittal_approved, +) +from evals.graders.database import assert_procurement_item_exists +from evals.graders.tool_calls import assert_required_tools + + +async def setup_through_departed(workflow_handle, item: str, eta: datetime) -> None: + """Helper to set up item through shipment departed state.""" + # Submittal approved + await send_event(workflow_handle, create_submittal_approved(item)) + await wait_for_processing(workflow_handle, timeout_seconds=30) + + # Shipment departed + await send_event(workflow_handle, create_shipment_departed(item, eta=eta)) + await wait_for_processing(workflow_handle, timeout_seconds=30) + + +@pytest.mark.asyncio +async def test_arrived_01_steel_beams(workflow_handle): + """ + Test Shipment_Arrived_Site for Steel Beams. + + Expected: + - notify_team_shipment_arrived called + - schedule_inspection called + - update_procurement_item_activity called + - DB shows shipment_arrived status with date + """ + item = "Steel Beams" + workflow_id = get_workflow_id(workflow_handle) + arrival_date = datetime(2026, 2, 10, 15, 45) + + # Setup through departed state + eta = datetime(2026, 2, 10, 14, 30) + await setup_through_departed(workflow_handle, item, eta) + + # Get transcript count BEFORE sending arrived event + previous_count = await get_transcript_event_count(workflow_handle) + + # Send arrived event + event = create_shipment_arrived(item, date_arrived=arrival_date) + await send_event(workflow_handle, event) + await wait_for_processing(workflow_handle, timeout_seconds=30) + + # Verify tool calls for THIS EVENT ONLY + full_transcript = await get_workflow_transcript(workflow_handle) + new_tool_calls = get_new_tool_calls(full_transcript, previous_count) + assert_required_tools(new_tool_calls, [ + "notify_team_shipment_arrived", + "schedule_inspection", + "update_procurement_item_activity", + ]) + + # Verify DB state + await assert_procurement_item_exists( + workflow_id=workflow_id, + item=item, + expected_status="shipment_arrived", + ) + + +@pytest.mark.asyncio +async def test_arrived_02_windows(workflow_handle): + """ + Test Shipment_Arrived_Site for Windows. + + Same expectations as Steel Beams. + """ + item = "Windows" + workflow_id = get_workflow_id(workflow_handle) + arrival_date = datetime(2026, 3, 5, 16, 20) + + eta = datetime(2026, 3, 5, 16, 0) + await setup_through_departed(workflow_handle, item, eta) + + # Get transcript count BEFORE sending arrived event + previous_count = await get_transcript_event_count(workflow_handle) + + event = create_shipment_arrived(item, date_arrived=arrival_date) + await send_event(workflow_handle, event) + await wait_for_processing(workflow_handle, timeout_seconds=30) + + # Verify tool calls for THIS EVENT ONLY + full_transcript = await get_workflow_transcript(workflow_handle) + new_tool_calls = get_new_tool_calls(full_transcript, previous_count) + assert_required_tools(new_tool_calls, [ + "notify_team_shipment_arrived", + "schedule_inspection", + "update_procurement_item_activity", + ]) + + await assert_procurement_item_exists( + workflow_id=workflow_id, + item=item, + expected_status="shipment_arrived", + ) diff --git a/examples/demos/procurement_agent/evals/tasks/test_shipment_departed.py b/examples/demos/procurement_agent/evals/tasks/test_shipment_departed.py new file mode 100644 index 000000000..4e64172e7 --- /dev/null +++ b/examples/demos/procurement_agent/evals/tasks/test_shipment_departed.py @@ -0,0 +1,202 @@ +""" +Tests for Shipment_Departed_Factory event handling. + +CRITICAL: These tests catch the false positive issue where the agent +incorrectly flags conflicts when ETA is before the required_by date. + +Conflict logic: +- Flag if ETA >= required_by (zero/negative buffer) +- Don't flag if ETA < required_by (has buffer remaining) +""" +from datetime import datetime + +import pytest + +from evals.conftest import ( + send_event, + get_workflow_id, + get_new_tool_calls, + wait_for_processing, + get_workflow_transcript, + get_transcript_event_count, +) +from evals.fixtures.events import ( + create_shipment_departed, + create_submittal_approved, +) +from evals.graders.database import assert_procurement_item_exists +from evals.graders.tool_calls import assert_required_tools, assert_forbidden_tools + + +async def setup_submittal_approved(workflow_handle, item: str) -> None: + """Helper to set up item through submittal approved state.""" + event = create_submittal_approved(item) + await send_event(workflow_handle, event) + await wait_for_processing(workflow_handle, timeout_seconds=30) + + +# ============================================================================= +# NO FLAG CASES - ETA < required_by +# ============================================================================= + +@pytest.mark.asyncio +async def test_departed_01_no_flag_5_days_early(workflow_handle): + """ + Steel Beams: ETA 2026-02-10, Required 2026-02-15 + 5 days early - well within buffer, should NOT flag. + """ + item = "Steel Beams" + workflow_id = get_workflow_id(workflow_handle) + + # Setup: submittal approved first + await setup_submittal_approved(workflow_handle, item) + + # Get transcript count BEFORE sending departed event + previous_count = await get_transcript_event_count(workflow_handle) + + # Send shipment departed with ETA 5 days early + eta = datetime(2026, 2, 10, 14, 30) # Feb 10 + event = create_shipment_departed(item, eta=eta) + await send_event(workflow_handle, event) + await wait_for_processing(workflow_handle, timeout_seconds=30) + + # Verify tool calls for THIS EVENT ONLY + full_transcript = await get_workflow_transcript(workflow_handle) + new_tool_calls = get_new_tool_calls(full_transcript, previous_count) + assert_required_tools(new_tool_calls, ["update_procurement_item_activity"]) + assert_forbidden_tools(new_tool_calls, ["flag_potential_issue"]) # MUST NOT FLAG + + # Verify DB state + await assert_procurement_item_exists( + workflow_id=workflow_id, + item=item, + expected_status="shipment_departed", + ) + + +@pytest.mark.asyncio +async def test_departed_02_no_flag_1_day_early(workflow_handle): + """ + Steel Beams: ETA 2026-02-14, Required 2026-02-15 + 1 day early - boundary case but still OK, should NOT flag. + """ + item = "Steel Beams" + workflow_id = get_workflow_id(workflow_handle) + + await setup_submittal_approved(workflow_handle, item) + + previous_count = await get_transcript_event_count(workflow_handle) + + eta = datetime(2026, 2, 14, 14, 30) # Feb 14 - 1 day before required + event = create_shipment_departed(item, eta=eta) + await send_event(workflow_handle, event) + await wait_for_processing(workflow_handle, timeout_seconds=30) + + full_transcript = await get_workflow_transcript(workflow_handle) + new_tool_calls = get_new_tool_calls(full_transcript, previous_count) + assert_required_tools(new_tool_calls, ["update_procurement_item_activity"]) + assert_forbidden_tools(new_tool_calls, ["flag_potential_issue"]) # MUST NOT FLAG + + +@pytest.mark.asyncio +async def test_departed_05_no_flag_windows_10_days_early(workflow_handle): + """ + Windows: ETA 2026-03-05, Required 2026-03-15 + 10 days early - uses buffer but still OK, should NOT flag. + """ + item = "Windows" + workflow_id = get_workflow_id(workflow_handle) + + await setup_submittal_approved(workflow_handle, item) + + previous_count = await get_transcript_event_count(workflow_handle) + + eta = datetime(2026, 3, 5, 16, 0) # Mar 5 - 10 days before required + event = create_shipment_departed(item, eta=eta) + await send_event(workflow_handle, event) + await wait_for_processing(workflow_handle, timeout_seconds=30) + + full_transcript = await get_workflow_transcript(workflow_handle) + new_tool_calls = get_new_tool_calls(full_transcript, previous_count) + assert_required_tools(new_tool_calls, ["update_procurement_item_activity"]) + assert_forbidden_tools(new_tool_calls, ["flag_potential_issue"]) # MUST NOT FLAG + + +@pytest.mark.asyncio +async def test_departed_06_no_flag_hvac_1_day_early(workflow_handle): + """ + HVAC Units: ETA 2026-02-28, Required 2026-03-01 + 1 day early - tight boundary case, should NOT flag. + """ + item = "HVAC Units" + workflow_id = get_workflow_id(workflow_handle) + + await setup_submittal_approved(workflow_handle, item) + + previous_count = await get_transcript_event_count(workflow_handle) + + eta = datetime(2026, 2, 28, 11, 0) # Feb 28 - 1 day before Mar 1 + event = create_shipment_departed(item, eta=eta) + await send_event(workflow_handle, event) + await wait_for_processing(workflow_handle, timeout_seconds=30) + + full_transcript = await get_workflow_transcript(workflow_handle) + new_tool_calls = get_new_tool_calls(full_transcript, previous_count) + assert_required_tools(new_tool_calls, ["update_procurement_item_activity"]) + assert_forbidden_tools(new_tool_calls, ["flag_potential_issue"]) # MUST NOT FLAG + + +# ============================================================================= +# FLAG CASES - ETA >= required_by +# ============================================================================= + +@pytest.mark.asyncio +async def test_departed_03_flag_on_deadline(workflow_handle): + """ + Steel Beams: ETA 2026-02-15, Required 2026-02-15 + Arrives ON deadline - zero buffer, SHOULD FLAG. + """ + item = "Steel Beams" + workflow_id = get_workflow_id(workflow_handle) + + await setup_submittal_approved(workflow_handle, item) + + previous_count = await get_transcript_event_count(workflow_handle) + + eta = datetime(2026, 2, 15, 14, 30) # Feb 15 = required date + event = create_shipment_departed(item, eta=eta) + await send_event(workflow_handle, event) + await wait_for_processing(workflow_handle, timeout_seconds=30) + + full_transcript = await get_workflow_transcript(workflow_handle) + new_tool_calls = get_new_tool_calls(full_transcript, previous_count) + assert_required_tools(new_tool_calls, [ + "flag_potential_issue", # MUST FLAG + "update_procurement_item_activity", + ]) + + +@pytest.mark.asyncio +async def test_departed_04_flag_late(workflow_handle): + """ + Steel Beams: ETA 2026-02-20, Required 2026-02-15 + 5 days LATE - definite conflict, SHOULD FLAG. + """ + item = "Steel Beams" + workflow_id = get_workflow_id(workflow_handle) + + await setup_submittal_approved(workflow_handle, item) + + previous_count = await get_transcript_event_count(workflow_handle) + + eta = datetime(2026, 2, 20, 14, 30) # Feb 20 - 5 days after required + event = create_shipment_departed(item, eta=eta) + await send_event(workflow_handle, event) + await wait_for_processing(workflow_handle, timeout_seconds=30) + + full_transcript = await get_workflow_transcript(workflow_handle) + new_tool_calls = get_new_tool_calls(full_transcript, previous_count) + assert_required_tools(new_tool_calls, [ + "flag_potential_issue", # MUST FLAG + "update_procurement_item_activity", + ]) diff --git a/examples/demos/procurement_agent/evals/tasks/test_submittal_approved.py b/examples/demos/procurement_agent/evals/tasks/test_submittal_approved.py new file mode 100644 index 000000000..3182eab7e --- /dev/null +++ b/examples/demos/procurement_agent/evals/tasks/test_submittal_approved.py @@ -0,0 +1,87 @@ +""" +Tests for Submittal_Approved event handling. + +Verifies: +- Purchase order is issued (tool call) +- Procurement item created in DB with correct status and PO ID +""" +import pytest + +from evals.conftest import ( + send_event, + get_workflow_id, + wait_for_processing, + get_workflow_transcript, +) +from evals.fixtures.events import create_submittal_approved +from evals.graders.database import assert_procurement_item_exists +from evals.graders.tool_calls import assert_required_tools + + +@pytest.mark.asyncio +async def test_submittal_01_steel_beams(workflow_handle): + """ + Test Submittal_Approved for Steel Beams. + + Expected: + - issue_purchase_order tool called + - create_procurement_item_activity called + - DB has procurement item with status and PO ID + """ + item = "Steel Beams" + workflow_id = get_workflow_id(workflow_handle) + + # Send event + event = create_submittal_approved(item) + await send_event(workflow_handle, event) + + # Wait for processing + await wait_for_processing(workflow_handle, timeout_seconds=30) + + # Get transcript and verify tool calls + transcript = await get_workflow_transcript(workflow_handle) + assert_required_tools(transcript, [ + "issue_purchase_order", + "create_procurement_item_activity", # Activity name in Temporal + ]) + + # Verify DB state + await assert_procurement_item_exists( + workflow_id=workflow_id, + item=item, + expected_status="purchase_order_issued", + expected_po_id_not_null=True, + ) + + +@pytest.mark.asyncio +async def test_submittal_02_hvac_units(workflow_handle): + """ + Test Submittal_Approved for HVAC Units. + + Same expectations as Steel Beams - verifies consistency. + """ + item = "HVAC Units" + workflow_id = get_workflow_id(workflow_handle) + + # Send event + event = create_submittal_approved(item) + await send_event(workflow_handle, event) + + # Wait for processing + await wait_for_processing(workflow_handle, timeout_seconds=30) + + # Get transcript and verify tool calls + transcript = await get_workflow_transcript(workflow_handle) + assert_required_tools(transcript, [ + "issue_purchase_order", + "create_procurement_item_activity", + ]) + + # Verify DB state + await assert_procurement_item_exists( + workflow_id=workflow_id, + item=item, + expected_status="purchase_order_issued", + expected_po_id_not_null=True, + ) diff --git a/examples/demos/procurement_agent/manifest.yaml b/examples/demos/procurement_agent/manifest.yaml new file mode 100644 index 000000000..823a1cd72 --- /dev/null +++ b/examples/demos/procurement_agent/manifest.yaml @@ -0,0 +1,145 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../ # Keep this as the default root + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - procurement_agent + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: procurement_agent/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: procurement_agent/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + # Path to temporal worker file + # Examples: + # project/run_worker.py (standard) + # workers/temporal.py (custom structure) + # ../shared/worker.py (shared across projects) + worker: project/run_worker.py + + +# Agent Configuration +# ----------------- +agent: + # Type of agent - either sync or async + acp_type: async + + # Unique name for your agent + # Used for task routing and monitoring + name: procurement-agent + + # Description of what your agent does + # Helps with documentation and discovery + description: An Agentex agent that manages procurement for building constructions + + # Temporal workflow configuration + # This enables your agent to run as a Temporal workflow for long-running tasks + temporal: + enabled: true + workflows: + # Name of the workflow class + # Must match the @workflow.defn name in your workflow.py + - name: procurement-agent + + # Queue name for task distribution + # Used by Temporal to route tasks to your agent + # Convention: _task_queue + queue_name: procurement_agent_queue + + # Optional: Health check port for temporal worker + # Defaults to 80 if not specified + # health_check_port: 80 + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + credentials: + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + # - env_var_name: OPENAI_API_KEY + # secret_name: openai-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + env: + OPENAI_API_KEY: "" + # OPENAI_BASE_URL: "" + OPENAI_ORG_ID: "" +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: + - name: my-registry-secret # Update with your image pull secret name + + # Global deployment settings that apply to all clusters + # These can be overridden using --override-file with custom configuration files + global: + agent: + name: "procurement-agent" + description: "An Agentex agent that manages procurement for building constructions" + + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/examples/demos/procurement_agent/project/__init__.py b/examples/demos/procurement_agent/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/demos/procurement_agent/project/acp.py b/examples/demos/procurement_agent/project/acp.py new file mode 100644 index 000000000..54cac94a2 --- /dev/null +++ b/examples/demos/procurement_agent/project/acp.py @@ -0,0 +1,59 @@ +import os +import sys + +from temporalio.contrib.openai_agents import OpenAIAgentsPlugin + +from agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model import ( + TemporalStreamingModelProvider, +) +from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ContextInterceptor + +# === DEBUG SETUP (AgentEx CLI Debug Support) === +if os.getenv("AGENTEX_DEBUG_ENABLED") == "true": + try: + import debugpy + + from agentex.lib.utils.logging import make_logger + + logger = make_logger(__name__) + debug_port = int(os.getenv("AGENTEX_DEBUG_PORT", "5679")) + debug_type = os.getenv("AGENTEX_DEBUG_TYPE", "acp") + wait_for_attach = os.getenv("AGENTEX_DEBUG_WAIT_FOR_ATTACH", "false").lower() == "true" + + # Configure debugpy + debugpy.configure(subProcess=False) + debugpy.listen(debug_port) + + logger.info(f"🐛 [{debug_type.upper()}] Debug server listening on port {debug_port}") + + if wait_for_attach: + logger.info(f"⏳ [{debug_type.upper()}] Waiting for debugger to attach...") + debugpy.wait_for_client() + logger.info(f"✅ [{debug_type.upper()}] Debugger attached!") + else: + logger.info(f"📡 [{debug_type.upper()}] Ready for debugger attachment") + + except ImportError: + print("❌ debugpy not available. Install with: pip install debugpy") + sys.exit(1) + except Exception as e: + print(f"❌ Debug setup failed: {e}") + sys.exit(1) +# === END DEBUG SETUP === + +from agentex.lib.types.fastacp import TemporalACPConfig +from agentex.lib.sdk.fastacp.fastacp import FastACP + +context_interceptor = ContextInterceptor() +streaming_model_provider = TemporalStreamingModelProvider() + +# Create the ACP server +acp = FastACP.create( + acp_type="async", + config=TemporalACPConfig( + type="temporal", + temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[OpenAIAgentsPlugin(model_provider=streaming_model_provider)], + interceptors=[context_interceptor] + ) +) \ No newline at end of file diff --git a/examples/demos/procurement_agent/project/activities/__init__.py b/examples/demos/procurement_agent/project/activities/__init__.py new file mode 100644 index 000000000..8c8e7bd57 --- /dev/null +++ b/examples/demos/procurement_agent/project/activities/__init__.py @@ -0,0 +1 @@ +"""Procurement agent activities module.""" diff --git a/examples/demos/procurement_agent/project/activities/activities.py b/examples/demos/procurement_agent/project/activities/activities.py new file mode 100644 index 000000000..f2581e140 --- /dev/null +++ b/examples/demos/procurement_agent/project/activities/activities.py @@ -0,0 +1,570 @@ +from __future__ import annotations + +import json +import uuid +import asyncio +from datetime import datetime, timedelta + +from temporalio import activity +from temporalio.exceptions import ApplicationError + +from project.data.database import ( + DatabaseError, + DataCorruptionError, + create_procurement_item, + delete_procurement_item, + update_procurement_item, + get_all_procurement_items, + get_schedule_for_workflow, + create_schedule_for_workflow, + get_procurement_item_by_name, + remove_delivery_item_for_workflow, + update_project_end_date_for_workflow, + update_delivery_date_for_item_for_workflow, +) +from project.models.events import ( + SubmitalApprovalEvent, + ShipmentDepartedFactoryEvent, +) +from agentex.lib.utils.logging import make_logger + +logger = make_logger(__name__) + +@activity.defn +async def issue_purchase_order(event: SubmitalApprovalEvent) -> str: + """ + Issues a purchase order for construction materials. + + Call this when: + - A submittal is approved (Submittal_Approved event) + - Human feedback requests reissuing a purchase order + """ + uuid_purchase_order = str(uuid.uuid4()) + # wait for 5 seconds as if we were calling an API to issue a purchase order + await asyncio.sleep(5) + logger.info(f"Issuing purchase order: {event}") + logger.info(f"Purchase order ID: {uuid_purchase_order}") + + return f"Successfully issued purchase order with ID: {uuid_purchase_order}" + +@activity.defn +async def flag_potential_issue(event: ShipmentDepartedFactoryEvent) -> str: + """ + Flags a potential issue with a delivery date. + + Call this when: + - A shipment departure creates timeline concerns (Shipment_Departed_Factory event) + - When ETA = required date and there is zero buffer + - Human feedback identifies a potential delivery issue + """ + logger.info(f"Flagging potential issue: {event}") + logger.info(f"Potential issue flagged with delivery date: {event.eta}") + # imagine this is a call to an API to flag a potential issue, perhaps a notification to a team member + await asyncio.sleep(1) + return f"Potential issue flagged with delivery date: {event.eta}" + +@activity.defn +async def notify_team_shipment_arrived(event: ShipmentDepartedFactoryEvent) -> str: + """ + Notifies the team that a shipment has arrived. + + Call this when: + - A shipment arrives at the site (Shipment_Arrived_Site event) + - Human feedback requests team notification + """ + logger.info(f"Notifying team that shipment has arrived: {event.item}") + logger.info(f"Team notification sent for arrival of: {event.item}") + # imagine this is a call to an API to notify the team that a shipment has arrived, perhaps a notification to a team member + await asyncio.sleep(1) + + return f"Notifying team that shipment has arrived: {event.item}" + +@activity.defn +async def schedule_inspection(event: ShipmentDepartedFactoryEvent) -> str: + """ + Schedules an inspection for delivered materials. + + Call this when: + - A shipment arrives at the site (Shipment_Arrived_Site event) + - Human feedback requests scheduling an inspection + """ + inspection_date = datetime.now() + timedelta(days=1) + logger.info(f"Scheduling inspection for: {event.item} on {inspection_date}") + # imagine this is a call to an API to schedule an inspection + await asyncio.sleep(1) + return f"Scheduling inspection for {event.item} on {inspection_date}" + + + +@activity.defn +async def create_master_construction_schedule(workflow_id: str) -> str: + """ + Creates the master construction schedule for the workflow. + + Call this when: + - The workflow is created + + Args: + workflow_id: The Temporal workflow ID + + Raises: + ApplicationError: Non-retryable if data is invalid + DatabaseError: Retryable if database connection fails + """ + logger.info(f"Creating master construction schedule for workflow: {workflow_id}") + + try: + await create_schedule_for_workflow(workflow_id) + return "Master construction schedule created for workflow" + + except DataCorruptionError as e: + # Application error - invalid data, don't retry + logger.error(f"Data corruption error creating schedule: {e}") + raise ApplicationError( + f"Invalid data creating schedule: {e}", + type="DataCorruptionError", + non_retryable=True + ) from e + + except DatabaseError as e: + # Platform error - database connection issue, let Temporal retry + logger.warning(f"Database error creating schedule (will retry): {e}") + raise # Let Temporal retry with activity retry policy + + except Exception as e: + # Unexpected error - log and let Temporal retry + logger.error(f"Unexpected error creating schedule: {e}") + raise + +@activity.defn +async def get_master_construction_schedule(workflow_id: str) -> str: + """ + Gets the master construction schedule for the workflow. + + Call this when: + - You want to get the master construction schedule for the workflow + - Human feedback requests the master construction schedule + + Returns: + The master construction schedule for the workflow as JSON string + + Raises: + ApplicationError: Non-retryable if schedule not found or data corrupted + DatabaseError: Retryable if database connection fails + """ + try: + schedule = await get_schedule_for_workflow(workflow_id) + + if schedule is None: + # Schedule not found - this is an application error + logger.error(f"No schedule found for workflow {workflow_id}") + raise ApplicationError( + f"No master construction schedule found for workflow {workflow_id}", + type="ScheduleNotFoundError", + non_retryable=True + ) + + logger.info(f"Master construction schedule found for workflow: {workflow_id}") + return json.dumps(schedule) + + except ApplicationError: + # Re-raise application errors + raise + + except DataCorruptionError as e: + # Application error - corrupted data, don't retry + logger.error(f"Data corruption error retrieving schedule: {e}") + raise ApplicationError( + f"Schedule data corrupted: {e}", + type="DataCorruptionError", + non_retryable=True + ) from e + + except DatabaseError as e: + # Platform error - database connection issue, let Temporal retry + logger.warning(f"Database error retrieving schedule (will retry): {e}") + raise # Let Temporal retry with activity retry policy + + except Exception as e: + # Unexpected error - log and let Temporal retry + logger.error(f"Unexpected error retrieving schedule: {e}") + raise + +@activity.defn +async def update_delivery_date_for_item(workflow_id: str, item: str, new_delivery_date: str) -> str: + """ + Updates the delivery date for a specific item in the construction schedule. + + Call this when: + - You want to update the delivery date for a specific item in the construction schedule + - Human feedback requests updating the delivery date for a specific item + + Args: + workflow_id: The Temporal workflow ID + item: The item to update + new_delivery_date: The new delivery date + + Raises: + ApplicationError: Non-retryable if schedule/item not found + DatabaseError: Retryable if database connection fails + """ + logger.info(f"Updating delivery date for item: {item} to {new_delivery_date}") + + try: + await update_delivery_date_for_item_for_workflow(workflow_id, item, new_delivery_date) + return f"Delivery date updated for item: {item} to {new_delivery_date}" + + except DataCorruptionError as e: + # Application error - schedule or item not found, don't retry + logger.error(f"Data corruption error updating delivery date: {e}") + raise ApplicationError( + f"Failed to update delivery date: {e}", + type="DataCorruptionError", + non_retryable=True + ) from e + + except DatabaseError as e: + # Platform error - database connection issue, let Temporal retry + logger.warning(f"Database error updating delivery date (will retry): {e}") + raise # Let Temporal retry with activity retry policy + + except Exception as e: + # Unexpected error - log and let Temporal retry + logger.error(f"Unexpected error updating delivery date: {e}") + raise + +@activity.defn +async def remove_delivery_item(workflow_id: str, item: str) -> str: + """ + Removes a delivery item from the construction schedule. + + Call this when: + - You want to remove a delivery item from the construction schedule + - Human feedback requests removing a delivery item + + Args: + workflow_id: The Temporal workflow ID + item: The item to remove + + Raises: + ApplicationError: Non-retryable if schedule/item not found + DatabaseError: Retryable if database connection fails + """ + logger.info(f"Removing delivery item: {item}") + + try: + await remove_delivery_item_for_workflow(workflow_id, item) + return f"Delivery item removed from construction schedule: {item}" + + except DataCorruptionError as e: + # Application error - schedule or item not found, don't retry + logger.error(f"Data corruption error removing delivery item: {e}") + raise ApplicationError( + f"Failed to remove delivery item: {e}", + type="DataCorruptionError", + non_retryable=True + ) from e + + except DatabaseError as e: + # Platform error - database connection issue, let Temporal retry + logger.warning(f"Database error removing delivery item (will retry): {e}") + raise # Let Temporal retry with activity retry policy + + except Exception as e: + # Unexpected error - log and let Temporal retry + logger.error(f"Unexpected error removing delivery item: {e}") + raise + +@activity.defn +async def update_project_end_date(workflow_id: str, new_end_date: str) -> str: + """ + Updates the end date for the project in the construction schedule. + + Call this when: + - You want to update the end date for the project in the construction schedule + - Human feedback requests updating the end date for the project + + Args: + workflow_id: The Temporal workflow ID + new_end_date: The new end date for the project + + Raises: + ApplicationError: Non-retryable if schedule not found + DatabaseError: Retryable if database connection fails + """ + logger.info(f"Updating end date for project to: {new_end_date}") + + try: + await update_project_end_date_for_workflow(workflow_id, new_end_date) + return f"End date updated for project: {new_end_date}" + + except DataCorruptionError as e: + # Application error - schedule not found, don't retry + logger.error(f"Data corruption error updating project end date: {e}") + raise ApplicationError( + f"Failed to update project end date: {e}", + type="DataCorruptionError", + non_retryable=True + ) from e + + except DatabaseError as e: + # Platform error - database connection issue, let Temporal retry + logger.warning(f"Database error updating project end date (will retry): {e}") + raise # Let Temporal retry with activity retry policy + + except Exception as e: + # Unexpected error - log and let Temporal retry + logger.error(f"Unexpected error updating project end date: {e}") + raise + + +@activity.defn +async def create_procurement_item_activity( + workflow_id: str, + item: str, + status: str, + eta: str | None = None, + date_arrived: str | None = None, + purchase_order_id: str | None = None +) -> str: + """ + Creates a new procurement item for tracking through the workflow. + + Call this when: + - A submittal is approved (Submittal_Approved event) - automatically after submittal approval + - Human feedback requests creating a new procurement item + + Args: + workflow_id: The Temporal workflow ID + item: The item name (e.g., "Steel Beams") + status: Current status of the item (e.g., "submittal_approved") + eta: Optional estimated time of arrival + date_arrived: Optional date the item arrived + purchase_order_id: Optional purchase order ID + + Raises: + ApplicationError: Non-retryable if data is invalid + DatabaseError: Retryable if database connection fails + """ + logger.info(f"Creating procurement item for workflow {workflow_id}: {item} with status {status}") + + try: + await create_procurement_item( + workflow_id=workflow_id, + item=item, + status=status, + eta=eta, + date_arrived=date_arrived, + purchase_order_id=purchase_order_id + ) + return f"Procurement item created: {item} with status {status}" + + except DataCorruptionError as e: + # Application error - invalid data, don't retry + logger.error(f"Data corruption error creating procurement item: {e}") + raise ApplicationError( + f"Invalid data creating procurement item: {e}", + type="DataCorruptionError", + non_retryable=True + ) from e + + except DatabaseError as e: + # Platform error - database connection issue, let Temporal retry + logger.warning(f"Database error creating procurement item (will retry): {e}") + raise # Let Temporal retry with activity retry policy + + except Exception as e: + # Unexpected error - log and let Temporal retry + logger.error(f"Unexpected error creating procurement item: {e}") + raise + + +@activity.defn +async def update_procurement_item_activity( + workflow_id: str, + item: str, + status: str | None = None, + eta: str | None = None, + date_arrived: str | None = None, + purchase_order_id: str | None = None +) -> str: + """ + Updates a procurement item's fields. + + Call this when: + - Any event occurs that changes the item's status (e.g., shipment departed, arrived, inspection scheduled/failed/passed) + - Human feedback requests updating the procurement item + - Purchase order is issued + - ETA is updated + - Item arrives at site + + Args: + workflow_id: The Temporal workflow ID + item: The item name (e.g., "Steel Beams") + status: Optional new status + eta: Optional new estimated time of arrival + date_arrived: Optional new arrival date + purchase_order_id: Optional new purchase order ID + + Raises: + ApplicationError: Non-retryable if workflow_id invalid or item not found + DatabaseError: Retryable if database connection fails + """ + logger.info(f"Updating procurement item for workflow {workflow_id}: {item}") + + try: + await update_procurement_item( + workflow_id=workflow_id, + item=item, + status=status, + eta=eta, + date_arrived=date_arrived, + purchase_order_id=purchase_order_id + ) + return f"Procurement item updated for workflow {workflow_id}: {item}" + + except DataCorruptionError as e: + # Application error - item not found or invalid data, don't retry + logger.error(f"Data corruption error updating procurement item: {e}") + raise ApplicationError( + f"Failed to update procurement item: {e}", + type="DataCorruptionError", + non_retryable=True + ) from e + + except DatabaseError as e: + # Platform error - database connection issue, let Temporal retry + logger.warning(f"Database error updating procurement item (will retry): {e}") + raise # Let Temporal retry with activity retry policy + + except Exception as e: + # Unexpected error - log and let Temporal retry + logger.error(f"Unexpected error updating procurement item: {e}") + raise + + +@activity.defn +async def delete_procurement_item_activity(workflow_id: str, item: str) -> str: + """ + Deletes a procurement item from the database. + + Call this when: + - Human feedback explicitly requests removing/deleting an item (e.g., "remove the steel beams") + - Item is no longer needed in the project + + Args: + workflow_id: The Temporal workflow ID + item: The item name (e.g., "Steel Beams") + + Raises: + ApplicationError: Non-retryable if workflow_id invalid or item not found + DatabaseError: Retryable if database connection fails + """ + logger.info(f"Deleting procurement item for workflow {workflow_id}: {item}") + + try: + await delete_procurement_item(workflow_id, item) + return f"Procurement item deleted for workflow {workflow_id}: {item}" + + except DataCorruptionError as e: + # Application error - item not found, don't retry + logger.error(f"Data corruption error deleting procurement item: {e}") + raise ApplicationError( + f"Failed to delete procurement item: {e}", + type="DataCorruptionError", + non_retryable=True + ) from e + + except DatabaseError as e: + # Platform error - database connection issue, let Temporal retry + logger.warning(f"Database error deleting procurement item (will retry): {e}") + raise # Let Temporal retry with activity retry policy + + except Exception as e: + # Unexpected error - log and let Temporal retry + logger.error(f"Unexpected error deleting procurement item: {e}") + raise + + +@activity.defn +async def get_procurement_item_by_name_activity(workflow_id: str, item: str) -> str: + """ + Retrieves a procurement item by workflow ID and item name. + + Call this when: + - You need to check the status of a specific item + - You need context about an item before making decisions + - Human feedback requests information about a specific item + + Args: + workflow_id: The Temporal workflow ID + item: The item name (e.g., "Steel Beams") + + Returns: + JSON string of the procurement item or message if not found + + Raises: + ApplicationError: Non-retryable if input data is invalid + DatabaseError: Retryable if database connection fails + """ + logger.info(f"Getting procurement item for workflow {workflow_id}: {item}") + + try: + result = await get_procurement_item_by_name(workflow_id, item) + + if result is None: + return f"No procurement item found for workflow {workflow_id} with item name: {item}" + + return json.dumps(result) + + except DataCorruptionError as e: + # Application error - invalid input, don't retry + logger.error(f"Data corruption error getting procurement item: {e}") + raise ApplicationError( + f"Invalid input getting procurement item: {e}", + type="DataCorruptionError", + non_retryable=True + ) from e + + except DatabaseError as e: + # Platform error - database connection issue, let Temporal retry + logger.warning(f"Database error getting procurement item (will retry): {e}") + raise # Let Temporal retry with activity retry policy + + except Exception as e: + # Unexpected error - log and let Temporal retry + logger.error(f"Unexpected error getting procurement item: {e}") + raise + + +@activity.defn +async def get_all_procurement_items_activity() -> str: + """ + Retrieves all procurement items from the database. + + Call this when: + - You need an overview of all procurement items + - You need to check the status of multiple items + - Human feedback requests a summary of all items + + Returns: + JSON string of all procurement items + + Raises: + DatabaseError: Retryable if database connection fails + """ + logger.info("Getting all procurement items") + + try: + results = await get_all_procurement_items() + return json.dumps(results) + + except DatabaseError as e: + # Platform error - database connection issue, let Temporal retry + logger.warning(f"Database error getting all procurement items (will retry): {e}") + raise # Let Temporal retry with activity retry policy + + except Exception as e: + # Unexpected error - log and let Temporal retry + logger.error(f"Unexpected error getting all procurement items: {e}") + raise \ No newline at end of file diff --git a/examples/demos/procurement_agent/project/agents/__init__.py b/examples/demos/procurement_agent/project/agents/__init__.py new file mode 100644 index 000000000..08d7078bc --- /dev/null +++ b/examples/demos/procurement_agent/project/agents/__init__.py @@ -0,0 +1 @@ +"""Procurement agent agents module.""" diff --git a/examples/demos/procurement_agent/project/agents/extract_learnings_agent.py b/examples/demos/procurement_agent/project/agents/extract_learnings_agent.py new file mode 100644 index 000000000..ca6ea6809 --- /dev/null +++ b/examples/demos/procurement_agent/project/agents/extract_learnings_agent.py @@ -0,0 +1,53 @@ +"""Agent for extracting learnings from human interactions.""" + + +from agents import Agent + + +def new_extract_learnings_agent() -> Agent: + """ + Create an agent that extracts 1-2 sentence learnings from human interactions. + + This agent analyzes the full conversation context to understand how we got to + the human interaction and what key insight or decision was made. + + Returns: + Agent configured to extract a concise learning + """ + instructions = """ +You are a learning extraction agent for a procurement system. + +Your job is to analyze only the wait_for_human tool call OUTPUT and extract a concise 1-2 sentence learning that can be applied to future decisions. +We care about the output as that is what the human actually said. The input is AI generated, we are trying to extract what decision the human made. + +For example: + + Example usage from the conversation: + { + "arguments": "{\"recommended_action\":\"\"The inspection failed I recommend we re-order the item.\"\"}", + "call_id": "call_FqWa25mlCKwo8gA3zr4TwHca", + "name": "wait_for_human", + "type": "function_call", + "id": "fc_08a992817d632789006914d90bbb948194bd20eb784f33c2a5", + "status": "completed" + } + + Human response received: + { + "call_id": "call_FqWa25mlCKwo8gA3zr4TwHca", + "output": "No, we should not re-order the item. Please remove the item from the master schedule.", + "type": "function_call_output" + } +Learning: When we fail inspection, the recommended action is to remove the item from the master schedule. + +The rest of the information is just context but the focus should be on understanding what the human wanted to do and why. + +Please extract a 1-2 sentence learning from the wait_for_human tool call. +""" + + return Agent( + name="Extract Learnings Agent", + instructions=instructions, + model="gpt-4o", + tools=[], # No tools needed - just analysis + ) diff --git a/examples/demos/procurement_agent/project/agents/procurement_agent.py b/examples/demos/procurement_agent/project/agents/procurement_agent.py new file mode 100644 index 000000000..45858d45e --- /dev/null +++ b/examples/demos/procurement_agent/project/agents/procurement_agent.py @@ -0,0 +1,515 @@ +"""Event agent for processing procurement events and taking actions.""" +from __future__ import annotations + +from datetime import datetime, timedelta + +from agents import Agent, function_tool +from temporalio import workflow +from temporalio.common import RetryPolicy +from temporalio.contrib import openai_agents +from temporalio.exceptions import TimeoutError, ApplicationError + +from project.activities.activities import ( + schedule_inspection, + flag_potential_issue, + issue_purchase_order, + remove_delivery_item, + update_project_end_date, + notify_team_shipment_arrived, + update_delivery_date_for_item, + create_procurement_item_activity, + delete_procurement_item_activity, + update_procurement_item_activity, + get_all_procurement_items_activity, + get_procurement_item_by_name_activity, +) + + +@function_tool +async def wait_for_human(recommended_action: str) -> str: + """ + When the we are stuck and need to ask a human for help, call this tool. Please provide a recommended action to the human. + Until the human approves the recommended action, you will keep calling this tool (call it as many times as needed). + If the human says anything other than yes, please use this tool again and come up with a new recommended action. + If the human wants to add additional information, please use this tool again and come up with a new recommended action. + You are almost always calling this tool again unless the human approves the exact recommended action. + + For example: + + Assistant recommendation: The inspection failed I recommend we re-order the item. + Human response: No, we should not re-order the item. Please remove the item from the master schedule. + Assistant recommendation: Ok I will go ahead and remove the item from the master schedule. Do you approve? + Human response: Yes + + Assistant recommendation: The inspection failed I recommend we re-order the item. + Human response: Yes and also please update the master schedule to reflect the new delivery date. + Assistant recommendation: Ok I will go ahead and update the master schedule to reflect the new delivery date and re-order the item. Does that sound right? + Human response: Yes + """ + workflow_instance = workflow.instance() + workflow.logger.info(f"Recommended action: {recommended_action}") + + try: + # Wait for human response with 24-hour timeout (don't wait forever!) + await workflow.wait_condition( + lambda: not workflow_instance.human_queue.empty(), + timeout=timedelta(hours=24), + ) + + while not workflow_instance.human_queue.empty(): + human_input = await workflow_instance.human_queue.get() + print(f"[WORKFLOW] Processing human message from queue") + return human_input + + # If queue became empty after wait_condition succeeded, this shouldn't normally happen + workflow.logger.warning("Queue empty after wait condition succeeded") + return "No human response available" + + except TimeoutError: + # Human didn't respond within 24 hours + workflow.logger.warning("Human escalation timed out after 24 hours") + return "TIMEOUT: No human response received within 24 hours. Proceeding with best judgment." + + +@function_tool +async def update_delivery_date_tool(item: str, new_delivery_date: str) -> str: + """ + Updates the delivery date for a specific item in the construction schedule. + + Call this when: + - You want to update the delivery date for a specific item in the construction schedule + - Human feedback requests updating the delivery date for a specific item + + Args: + item: The item to update + new_delivery_date: The new delivery date + + Returns: + Confirmation message or error description + """ + workflow_id = workflow.info().workflow_id + + retry_policy = RetryPolicy( + initial_interval=timedelta(seconds=1), + backoff_coefficient=2.0, + maximum_interval=timedelta(seconds=120), + maximum_attempts=5, + non_retryable_error_types=["DataCorruptionError"], + ) + + try: + return await workflow.execute_activity( + update_delivery_date_for_item, + args=[workflow_id, item, new_delivery_date], + start_to_close_timeout=timedelta(minutes=5), + schedule_to_close_timeout=timedelta(minutes=10), + retry_policy=retry_policy, + ) + except ApplicationError as e: + # Non-retryable error (item not found, schedule missing) + workflow.logger.error(f"Failed to update delivery date for {item}: {e}") + return f"Error: Unable to update delivery date for {item}. {e.message}" + except Exception as e: + # Unexpected error + workflow.logger.error(f"Unexpected error updating delivery date: {e}") + return f"Error: System issue updating delivery date for {item}. Please try again." + + +@function_tool +async def remove_delivery_item_tool(item: str) -> str: + """ + Removes a delivery item from the construction schedule. + + Call this when: + - You want to remove a delivery item from the construction schedule + - Human feedback requests removing a delivery item + + Args: + item: The item to remove + + Returns: + Confirmation message or error description + """ + workflow_id = workflow.info().workflow_id + + retry_policy = RetryPolicy( + initial_interval=timedelta(seconds=1), + backoff_coefficient=2.0, + maximum_interval=timedelta(seconds=120), + maximum_attempts=5, + non_retryable_error_types=["DataCorruptionError"], + ) + + try: + return await workflow.execute_activity( + remove_delivery_item, + args=[workflow_id, item], + start_to_close_timeout=timedelta(minutes=5), + schedule_to_close_timeout=timedelta(minutes=10), + retry_policy=retry_policy, + ) + except ApplicationError as e: + # Non-retryable error (item not found, schedule missing) + workflow.logger.error(f"Failed to remove delivery item {item}: {e}") + return f"Error: Unable to remove item {item}. {e.message}" + except Exception as e: + # Unexpected error + workflow.logger.error(f"Unexpected error removing delivery item: {e}") + return f"Error: System issue removing item {item}. Please try again." + + +@function_tool +async def update_project_end_date_tool(new_end_date: str) -> str: + """ + Updates the end date for the project in the construction schedule. + + Call this when: + - You want to update the end date for the project in the construction schedule + - Human feedback requests updating the end date for the project + + Args: + new_end_date: The new end date for the project + + Returns: + Confirmation message or error description + """ + workflow_id = workflow.info().workflow_id + + retry_policy = RetryPolicy( + initial_interval=timedelta(seconds=1), + backoff_coefficient=2.0, + maximum_interval=timedelta(seconds=120), + maximum_attempts=5, + non_retryable_error_types=["DataCorruptionError"], + ) + + try: + return await workflow.execute_activity( + update_project_end_date, + args=[workflow_id, new_end_date], + start_to_close_timeout=timedelta(minutes=5), + schedule_to_close_timeout=timedelta(minutes=10), + retry_policy=retry_policy, + ) + except ApplicationError as e: + # Non-retryable error (schedule not found) + workflow.logger.error(f"Failed to update project end date: {e}") + return f"Error: Unable to update project end date. {e.message}" + except Exception as e: + # Unexpected error + workflow.logger.error(f"Unexpected error updating project end date: {e}") + return f"Error: System issue updating project end date. Please try again." + + +@function_tool +async def create_procurement_item_tool( + item: str, + status: str, + eta: str | None = None, + date_arrived: str | None = None, + purchase_order_id: str | None = None +) -> str: + """ + Creates a new procurement item for tracking through the workflow. + + Call this when: + - A submittal is approved (after calling issue_purchase_order) + - You need to track a new item in the procurement system + + Args: + item: The item name (e.g., "Steel Beams") + status: Current status (e.g., "submittal_approved", "purchase_order_issued") + eta: Optional estimated time of arrival (ISO format) + date_arrived: Optional date the item arrived (ISO format) + purchase_order_id: Optional purchase order ID + + Returns: + Confirmation message or error description + """ + workflow_id = workflow.info().workflow_id + + retry_policy = RetryPolicy( + initial_interval=timedelta(seconds=1), + backoff_coefficient=2.0, + maximum_interval=timedelta(seconds=120), + maximum_attempts=5, + non_retryable_error_types=["DataCorruptionError"], + ) + + try: + return await workflow.execute_activity( + create_procurement_item_activity, + args=[workflow_id, item, status, eta, date_arrived, purchase_order_id], + start_to_close_timeout=timedelta(minutes=5), + schedule_to_close_timeout=timedelta(minutes=10), + retry_policy=retry_policy, + ) + except ApplicationError as e: + # Non-retryable error (invalid data) + workflow.logger.error(f"Failed to create procurement item for {item}: {e}") + return f"Error: Unable to create procurement item for {item}. {e.message}" + except Exception as e: + # Unexpected error + workflow.logger.error(f"Unexpected error creating procurement item: {e}") + return f"Error: System issue creating procurement item for {item}. Please try again." + + +@function_tool +async def update_procurement_item_tool( + item: str, + status: str | None = None, + eta: str | None = None, + date_arrived: str | None = None, + purchase_order_id: str | None = None +) -> str: + """ + Updates a procurement item's fields in the tracking system. + + Call this when: + - An event changes the item's status (e.g., shipment departed, arrived, inspection scheduled/failed/passed) + - A purchase order is issued for the item + - The ETA is updated + - The item arrives at the site + - A potential issue is flagged + + Args: + item: The item name (e.g., "Steel Beams", "HVAC Units") - REQUIRED to identify which item to update + status: Optional new status (e.g., "purchase_order_issued", "shipment_departed", "shipment_arrived", + "potential_issue_flagged", "inspection_scheduled", "inspection_failed", "inspection_passed") + eta: Optional new estimated time of arrival (ISO format) + date_arrived: Optional new arrival date (ISO format) + purchase_order_id: Optional new purchase order ID + + Returns: + Confirmation message or error description + """ + workflow_id = workflow.info().workflow_id + + retry_policy = RetryPolicy( + initial_interval=timedelta(seconds=1), + backoff_coefficient=2.0, + maximum_interval=timedelta(seconds=120), + maximum_attempts=5, + non_retryable_error_types=["DataCorruptionError"], + ) + + try: + return await workflow.execute_activity( + update_procurement_item_activity, + args=[workflow_id, item, status, eta, date_arrived, purchase_order_id], + start_to_close_timeout=timedelta(minutes=5), + schedule_to_close_timeout=timedelta(minutes=10), + retry_policy=retry_policy, + ) + except ApplicationError as e: + # Non-retryable error (item not found) + workflow.logger.error(f"Failed to update procurement item: {e}") + return f"Error: Unable to update procurement item. {e.message}" + except Exception as e: + # Unexpected error + workflow.logger.error(f"Unexpected error updating procurement item: {e}") + return f"Error: System issue updating procurement item. Please try again." + + +@function_tool +async def delete_procurement_item_tool(item: str) -> str: + """ + Deletes a procurement item from the tracking system. + + Call this when: + - Human explicitly requests removing/deleting an item + - An item is no longer needed in the project + + Args: + item: The item name to delete (e.g., "Steel Beams", "HVAC Units") + + Returns: + Confirmation message or error description + """ + workflow_id = workflow.info().workflow_id + + retry_policy = RetryPolicy( + initial_interval=timedelta(seconds=1), + backoff_coefficient=2.0, + maximum_interval=timedelta(seconds=120), + maximum_attempts=5, + non_retryable_error_types=["DataCorruptionError"], + ) + + try: + return await workflow.execute_activity( + delete_procurement_item_activity, + args=[workflow_id, item], + start_to_close_timeout=timedelta(minutes=5), + schedule_to_close_timeout=timedelta(minutes=10), + retry_policy=retry_policy, + ) + except ApplicationError as e: + # Non-retryable error (item not found) + workflow.logger.error(f"Failed to delete procurement item: {e}") + return f"Error: Unable to delete procurement item. {e.message}" + except Exception as e: + # Unexpected error + workflow.logger.error(f"Unexpected error deleting procurement item: {e}") + return f"Error: System issue deleting procurement item. Please try again." + + +@function_tool +async def get_procurement_item_by_name_tool(item: str) -> str: + """ + Retrieves a procurement item by item name for context. + + Call this when: + - You need to check the status of a specific item before making decisions + - Human asks about the status of an item + - You need additional context about an item + + Args: + item: The item name (e.g., "Steel Beams") + + Returns: + JSON string of the procurement item or message if not found + """ + workflow_id = workflow.info().workflow_id + + retry_policy = RetryPolicy( + initial_interval=timedelta(seconds=1), + backoff_coefficient=2.0, + maximum_interval=timedelta(seconds=120), + maximum_attempts=5, + non_retryable_error_types=["DataCorruptionError"], + ) + + try: + return await workflow.execute_activity( + get_procurement_item_by_name_activity, + args=[workflow_id, item], + start_to_close_timeout=timedelta(minutes=5), + schedule_to_close_timeout=timedelta(minutes=10), + retry_policy=retry_policy, + ) + except ApplicationError as e: + # Non-retryable error (invalid input) + workflow.logger.error(f"Failed to get procurement item {item}: {e}") + return f"Error: Unable to get procurement item {item}. {e.message}" + except Exception as e: + # Unexpected error + workflow.logger.error(f"Unexpected error getting procurement item: {e}") + return f"Error: System issue getting procurement item {item}. Please try again." + + +@function_tool +async def get_all_procurement_items_tool() -> str: + """ + Retrieves all procurement items for context. + + Call this when: + - You need an overview of all procurement items + - Human asks for a summary of all items + - You need to check multiple items' statuses + + Returns: + JSON string of all procurement items + """ + retry_policy = RetryPolicy( + initial_interval=timedelta(seconds=1), + backoff_coefficient=2.0, + maximum_interval=timedelta(seconds=120), + maximum_attempts=5, + non_retryable_error_types=["DataCorruptionError"], + ) + + try: + return await workflow.execute_activity( + get_all_procurement_items_activity, + start_to_close_timeout=timedelta(minutes=5), + schedule_to_close_timeout=timedelta(minutes=10), + retry_policy=retry_policy, + ) + except ApplicationError as e: + # Non-retryable error + workflow.logger.error(f"Failed to get all procurement items: {e}") + return f"Error: Unable to get all procurement items. {e.message}" + except Exception as e: + # Unexpected error + workflow.logger.error(f"Unexpected error getting all procurement items: {e}") + return f"Error: System issue getting all procurement items. Please try again." + +def new_procurement_agent(master_construction_schedule: str, human_input_learnings: list) -> Agent: + """ + Create an agent that processes procurement events and takes actions. + + Args: + event_log: History of events that have occurred + master_construction_schedule: Current construction schedule + human_input_learnings: Past escalations and human decisions + + Returns: + Agent configured to process events and call tools + """ + instructions = f""" +You are a procurement agent for a commercial building construction project. + +Your role is to monitor procurement events, take appropriate actions, and escalate critical issues to a human with a recommended action. + +Please escalate to a human if you feel like we are facing a critical schedule delay and provide a recommended action. + +If the user says no or has feedback, please come up with another solution and call the wait_for_human tool again (you can call it as many times as needed). + +## CRITICAL: When to Flag Potential Issues (Shipment_Departed_Factory events) + +When processing a Shipment_Departed_Factory event, you MUST compare the ETA to the required_by date from the master schedule: + +- **ONLY flag_potential_issue if ETA >= required_by** (zero buffer or late - this is a problem!) +- **DO NOT flag_potential_issue if ETA < required_by** (there is still buffer remaining - no issue!) + +Example 1: Item required_by 2026-02-15, ETA is 2026-02-10 → DO NOT FLAG (5 days buffer remaining) +Example 2: Item required_by 2026-02-15, ETA is 2026-02-15 → FLAG (zero buffer - on the deadline!) +Example 3: Item required_by 2026-02-15, ETA is 2026-02-20 → FLAG (5 days late!) + +The buffer_days field in the schedule is informational only. What matters is: Does ETA arrive BEFORE the required_by date? + +## Context + +Master Construction Schedule: +{master_construction_schedule} + +Past Learnings from Escalations: +{human_input_learnings} + +Current Date: {datetime.now().isoformat()} + + + """ + + start_to_close_timeout = timedelta(days=1) + + return Agent( + name="Procurement Event Agent", + instructions=instructions, + model="gpt-4o", + tools=[ + openai_agents.workflow.activity_as_tool( + issue_purchase_order, start_to_close_timeout=start_to_close_timeout + ), + openai_agents.workflow.activity_as_tool( + flag_potential_issue, start_to_close_timeout=start_to_close_timeout + ), + openai_agents.workflow.activity_as_tool( + notify_team_shipment_arrived, + start_to_close_timeout=start_to_close_timeout, + ), + openai_agents.workflow.activity_as_tool( + schedule_inspection, start_to_close_timeout=start_to_close_timeout + ), + update_delivery_date_tool, # function_tool wrapper that injects workflow_id + remove_delivery_item_tool, # function_tool wrapper that injects workflow_id + update_project_end_date_tool, # function_tool wrapper that injects workflow_id + create_procurement_item_tool, # function_tool wrapper for creating procurement items + update_procurement_item_tool, # function_tool wrapper for updating procurement items + delete_procurement_item_tool, # function_tool wrapper for deleting procurement items + get_procurement_item_by_name_tool, # function_tool wrapper for getting a specific procurement item + get_all_procurement_items_tool, # function_tool wrapper for getting all procurement items + wait_for_human, # function_tool runs in workflow context + ], + ) \ No newline at end of file diff --git a/examples/demos/procurement_agent/project/agents/summarization_agent.py b/examples/demos/procurement_agent/project/agents/summarization_agent.py new file mode 100644 index 000000000..e74f2d46f --- /dev/null +++ b/examples/demos/procurement_agent/project/agents/summarization_agent.py @@ -0,0 +1,53 @@ +"""Agent for summarizing conversation history.""" + +from agents import Agent + + +def new_summarization_agent() -> Agent: + """ + Create an agent that summarizes conversation history for context compression. + + This agent analyzes the conversation and creates a detailed but concise summary + that captures key events, decisions, and current state for continuing the workflow. + + Returns: + Agent configured to generate conversation summaries + """ + instructions = """ +You are a summarization agent for a procurement workflow system. + +Your job is to create a detailed but concise summary of the conversation history. +Focus on information that would be helpful for continuing the conversation, including: + +- What procurement events have occurred (submittals, shipments, inspections, etc.) +- What items are being tracked and their current status +- What actions have been taken (purchase orders issued, inspections scheduled, etc.) +- Any critical issues or delays that were identified +- Any human decisions or escalations that occurred +- What is currently being worked on +- What needs to be done next + +Your summary should be comprehensive enough to provide full context but concise enough +to be quickly understood. Aim for 3-5 paragraphs organized by topic. + +Focus on the OUTCOMES and CURRENT STATE rather than listing every single tool call. + +Example format: + +**Items Tracked:** +Steel Beams have been approved, purchase order issued (ID: 6c9e401a...), shipment arrived +on 2026-02-10, inspection passed. Currently marked as complete. + +**Current Status:** +All items are on schedule with no delays. The workflow is progressing smoothly. + +**Next Steps:** +Continue monitoring upcoming deliveries for HVAC Units and Windows. +""" + + return Agent( + name="Summarization Agent", + instructions=instructions, + model="gpt-4o", + tools=[], # No tools needed - just summarization + ) diff --git a/examples/demos/procurement_agent/project/data/__init__.py b/examples/demos/procurement_agent/project/data/__init__.py new file mode 100644 index 000000000..ec504c844 --- /dev/null +++ b/examples/demos/procurement_agent/project/data/__init__.py @@ -0,0 +1 @@ +"""Procurement agent data module.""" diff --git a/examples/demos/procurement_agent/project/data/database.py b/examples/demos/procurement_agent/project/data/database.py new file mode 100644 index 000000000..a756f7efa --- /dev/null +++ b/examples/demos/procurement_agent/project/data/database.py @@ -0,0 +1,686 @@ +""" +Database initialization and management for procurement agent. +Stores master construction schedules indexed by workflow ID. +""" +from __future__ import annotations + +import json +from typing import Optional +from pathlib import Path + +import aiosqlite # type: ignore[import-untyped] + +from agentex.lib.utils.logging import make_logger + +logger = make_logger(__name__) + + +# Custom exceptions for database operations +class DatabaseError(Exception): + """Platform-level database errors (retryable by Temporal)""" + pass + + +class DataCorruptionError(Exception): + """Application-level data errors (non-retryable)""" + pass + +# Database file location (in the data directory) +DB_PATH = Path(__file__).parent / "procurement.db" + +DEFAULT_SCHEDULE = { + "project": { + "name": "Small Office Renovation", + "start_date": "2026-02-01", + "end_date": "2026-05-31" + }, + "deliveries": [ + { + "item": "Steel Beams", + "required_by": "2026-02-15", + "buffer_days": 5 + }, + { + "item": "HVAC Units", + "required_by": "2026-03-01", + "buffer_days": 7 + }, + { + "item": "Windows", + "required_by": "2026-03-15", + "buffer_days": 10 + }, + { + "item": "Flooring Materials", + "required_by": "2026-04-01", + "buffer_days": 3 + }, + { + "item": "Electrical Panels", + "required_by": "2026-04-15", + "buffer_days": 5 + } + ] +} + + +async def init_database() -> None: + """ + Initialize the SQLite database and create tables if they don't exist. + Creates the master_construction_schedule and procurement_items tables. + Safe to call multiple times - uses CREATE TABLE IF NOT EXISTS. + + Raises: + DatabaseError: If database initialization fails + """ + logger.info(f"Initializing database at {DB_PATH}") + + try: + async with aiosqlite.connect(DB_PATH) as db: + await db.execute(""" + CREATE TABLE IF NOT EXISTS master_construction_schedule ( + workflow_id TEXT PRIMARY KEY, + project_name TEXT NOT NULL, + project_start_date TEXT NOT NULL, + project_end_date TEXT NOT NULL, + schedule_json TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Create index on workflow_id for faster lookups + await db.execute(""" + CREATE INDEX IF NOT EXISTS idx_workflow_id + ON master_construction_schedule(workflow_id) + """) + + # Create procurement_items table for tracking item status through workflow + await db.execute(""" + CREATE TABLE IF NOT EXISTS procurement_items ( + workflow_id TEXT NOT NULL, + item TEXT NOT NULL, + status TEXT NOT NULL, + eta TEXT, + date_arrived TEXT, + purchase_order_id TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (workflow_id, item) + ) + """) + + # Create index on workflow_id for faster lookups + await db.execute(""" + CREATE INDEX IF NOT EXISTS idx_procurement_workflow_id + ON procurement_items(workflow_id) + """) + + await db.commit() + logger.info("Database initialized successfully") + + except aiosqlite.Error as e: + # Fatal error - can't initialize database + logger.error(f"Failed to initialize database: {e}") + raise DatabaseError(f"Failed to initialize database: {e}") from e + except Exception as e: + logger.error(f"Unexpected error during database initialization: {e}") + raise DatabaseError(f"Unexpected database initialization error: {e}") from e + + +async def create_schedule_for_workflow( + workflow_id: str, + schedule: Optional[dict] = None +) -> None: + """ + Create a new construction schedule for a specific workflow. + Uses default schedule if none provided. + + Args: + workflow_id: The Temporal workflow ID + schedule: Optional custom schedule dict. If None, uses DEFAULT_SCHEDULE + + Raises: + DatabaseError: If database operation fails (retryable by Temporal) + DataCorruptionError: If schedule data is invalid (non-retryable) + """ + # Input validation - non-retryable errors + if not workflow_id or not isinstance(workflow_id, str): + raise DataCorruptionError("Invalid workflow_id: must be a non-empty string") + + if schedule is None: + schedule = DEFAULT_SCHEDULE + + # Validate schedule structure - non-retryable errors + try: + if "project" not in schedule: + raise DataCorruptionError("Schedule missing 'project' key") + required_keys = ["name", "start_date", "end_date"] + for key in required_keys: + if key not in schedule["project"]: + raise DataCorruptionError(f"Schedule project missing required key: {key}") + except (TypeError, AttributeError) as e: + raise DataCorruptionError(f"Invalid schedule structure: {e}") from e + + try: + # Validate JSON serialization before inserting + schedule_json = json.dumps(schedule) + + async with aiosqlite.connect(DB_PATH) as db: + await db.execute(""" + INSERT OR REPLACE INTO master_construction_schedule + (workflow_id, project_name, project_start_date, project_end_date, schedule_json, updated_at) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, ( + workflow_id, + schedule["project"]["name"], + schedule["project"]["start_date"], + schedule["project"]["end_date"], + schedule_json + )) + await db.commit() + logger.info(f"Created schedule for workflow {workflow_id}") + + except (TypeError, ValueError) as e: + # Data error - can't serialize to JSON, don't retry + logger.error(f"Failed to serialize schedule to JSON: {e}") + raise DataCorruptionError(f"Schedule data cannot be serialized: {e}") from e + + except aiosqlite.IntegrityError as e: + # Data constraint violation - don't retry + logger.error(f"Data integrity error: {e}") + raise DataCorruptionError(f"Data integrity error: {e}") from e + + except aiosqlite.Error as e: + # Database connection/lock errors - retryable + logger.warning(f"Database error creating schedule (retryable): {e}") + raise DatabaseError(f"Failed to create schedule: {e}") from e + + except Exception as e: + # Unexpected error - treat as retryable + logger.error(f"Unexpected error creating schedule: {e}") + raise DatabaseError(f"Unexpected error creating schedule: {e}") from e + + +async def get_schedule_for_workflow(workflow_id: str) -> Optional[dict]: + """ + Retrieve the construction schedule for a specific workflow. + + Args: + workflow_id: The Temporal workflow ID + + Returns: + The schedule dict or None if not found + + Raises: + DatabaseError: If database operation fails (retryable by Temporal) + DataCorruptionError: If stored JSON is corrupted (non-retryable) + """ + try: + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute(""" + SELECT schedule_json FROM master_construction_schedule + WHERE workflow_id = ? + """, (workflow_id,)) as cursor: + row = await cursor.fetchone() + if row: + # Validate JSON before returning + try: + return json.loads(row["schedule_json"]) + except json.JSONDecodeError as e: + logger.error(f"Corrupted JSON in database for workflow {workflow_id}: {e}") + raise DataCorruptionError( + f"Schedule JSON corrupted for workflow {workflow_id}: {e}" + ) from e + return None + + except DataCorruptionError: + # Re-raise data corruption errors + raise + + except aiosqlite.Error as e: + # Database connection errors - retryable + logger.warning(f"Database error retrieving schedule (retryable): {e}") + raise DatabaseError(f"Failed to retrieve schedule: {e}") from e + + except Exception as e: + # Unexpected error - treat as retryable + logger.error(f"Unexpected error retrieving schedule: {e}") + raise DatabaseError(f"Unexpected error retrieving schedule: {e}") from e + +async def update_delivery_date_for_item_for_workflow(workflow_id: str, item: str, new_delivery_date: str) -> None: + """ + Update the delivery date for a specific item in the construction schedule for a specific workflow. + + Raises: + DatabaseError: If database operation fails (retryable by Temporal) + DataCorruptionError: If schedule not found or item not found (non-retryable) + """ + # Get the current schedule (may raise DatabaseError or DataCorruptionError) + schedule = await get_schedule_for_workflow(workflow_id) + if schedule is None: + logger.error(f"No schedule found for workflow {workflow_id}") + raise DataCorruptionError(f"No schedule found for workflow {workflow_id}") + + # Update the delivery item's required_by date + updated = False + for delivery in schedule.get("deliveries", []): + if delivery.get("item") == item: + delivery["required_by"] = new_delivery_date + updated = True + break + + if not updated: + logger.error(f"Item {item} not found in schedule for workflow {workflow_id}") + raise DataCorruptionError(f"Item {item} not found in schedule for workflow {workflow_id}") + + # Save the updated schedule back to the database + try: + async with aiosqlite.connect(DB_PATH) as db: + await db.execute(""" + UPDATE master_construction_schedule + SET schedule_json = ?, updated_at = CURRENT_TIMESTAMP + WHERE workflow_id = ? + """, (json.dumps(schedule), workflow_id)) + await db.commit() + logger.info(f"Updated delivery date for item {item} in workflow {workflow_id}") + + except aiosqlite.Error as e: + # Database connection errors - retryable + logger.warning(f"Database error updating delivery date (retryable): {e}") + raise DatabaseError(f"Failed to update delivery date: {e}") from e + + except Exception as e: + # Unexpected error - treat as retryable + logger.error(f"Unexpected error updating delivery date: {e}") + raise DatabaseError(f"Unexpected error updating delivery date: {e}") from e + +async def remove_delivery_item_for_workflow(workflow_id: str, item: str) -> None: + """ + Remove a delivery item from the construction schedule for a specific workflow. + + Raises: + DatabaseError: If database operation fails (retryable by Temporal) + DataCorruptionError: If schedule not found or item not found (non-retryable) + """ + # Get the current schedule (may raise DatabaseError or DataCorruptionError) + schedule = await get_schedule_for_workflow(workflow_id) + if schedule is None: + logger.error(f"No schedule found for workflow {workflow_id}") + raise DataCorruptionError(f"No schedule found for workflow {workflow_id}") + + # Remove the delivery item from the list + original_count = len(schedule.get("deliveries", [])) + schedule["deliveries"] = [ + delivery for delivery in schedule.get("deliveries", []) + if delivery.get("item") != item + ] + + if len(schedule["deliveries"]) == original_count: + logger.error(f"Item {item} not found in schedule for workflow {workflow_id}") + raise DataCorruptionError(f"Item {item} not found in schedule for workflow {workflow_id}") + + # Save the updated schedule back to the database + try: + async with aiosqlite.connect(DB_PATH) as db: + await db.execute(""" + UPDATE master_construction_schedule + SET schedule_json = ?, updated_at = CURRENT_TIMESTAMP + WHERE workflow_id = ? + """, (json.dumps(schedule), workflow_id)) + await db.commit() + logger.info(f"Removed delivery item {item} from workflow {workflow_id}") + + except aiosqlite.Error as e: + # Database connection errors - retryable + logger.warning(f"Database error removing delivery item (retryable): {e}") + raise DatabaseError(f"Failed to remove delivery item: {e}") from e + + except Exception as e: + # Unexpected error - treat as retryable + logger.error(f"Unexpected error removing delivery item: {e}") + raise DatabaseError(f"Unexpected error removing delivery item: {e}") from e + +async def update_project_end_date_for_workflow(workflow_id: str, new_end_date: str) -> None: + """ + Update the end date for the project in the construction schedule for a specific workflow. + + Raises: + DatabaseError: If database operation fails (retryable by Temporal) + DataCorruptionError: If schedule not found (non-retryable) + """ + # Get the current schedule (may raise DatabaseError or DataCorruptionError) + schedule = await get_schedule_for_workflow(workflow_id) + if schedule is None: + logger.error(f"No schedule found for workflow {workflow_id}") + raise DataCorruptionError(f"No schedule found for workflow {workflow_id}") + + # Update the project end date in both the JSON and the dedicated column + schedule["project"]["end_date"] = new_end_date + + try: + async with aiosqlite.connect(DB_PATH) as db: + await db.execute(""" + UPDATE master_construction_schedule + SET project_end_date = ?, schedule_json = ?, updated_at = CURRENT_TIMESTAMP + WHERE workflow_id = ? + """, (new_end_date, json.dumps(schedule), workflow_id)) + await db.commit() + logger.info(f"Updated end date for project in workflow {workflow_id}") + + except aiosqlite.Error as e: + # Database connection errors - retryable + logger.warning(f"Database error updating project end date (retryable): {e}") + raise DatabaseError(f"Failed to update project end date: {e}") from e + + except Exception as e: + # Unexpected error - treat as retryable + logger.error(f"Unexpected error updating project end date: {e}") + raise DatabaseError(f"Unexpected error updating project end date: {e}") from e + + +async def create_procurement_item( + workflow_id: str, + item: str, + status: str, + eta: Optional[str] = None, + date_arrived: Optional[str] = None, + purchase_order_id: Optional[str] = None +) -> None: + """ + Create a new procurement item for tracking through the workflow. + + Args: + workflow_id: The Temporal workflow ID + item: The item name (e.g., "Steel Beams") + status: Current status of the item + eta: Optional estimated time of arrival + date_arrived: Optional date the item arrived + purchase_order_id: Optional purchase order ID + + Raises: + DatabaseError: If database operation fails (retryable by Temporal) + DataCorruptionError: If input data is invalid (non-retryable) + """ + # Input validation - non-retryable errors + if not workflow_id or not isinstance(workflow_id, str): + raise DataCorruptionError("Invalid workflow_id: must be a non-empty string") + + if not item or not isinstance(item, str): + raise DataCorruptionError("Invalid item: must be a non-empty string") + + if not status or not isinstance(status, str): + raise DataCorruptionError("Invalid status: must be a non-empty string") + + try: + async with aiosqlite.connect(DB_PATH) as db: + await db.execute(""" + INSERT OR REPLACE INTO procurement_items + (workflow_id, item, status, eta, date_arrived, purchase_order_id, updated_at) + VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, ( + workflow_id, + item, + status, + eta, + date_arrived, + purchase_order_id + )) + await db.commit() + logger.info(f"Created procurement item for workflow {workflow_id}: {item} with status {status}") + + except aiosqlite.IntegrityError as e: + # Data constraint violation - don't retry + logger.error(f"Data integrity error: {e}") + raise DataCorruptionError(f"Data integrity error: {e}") from e + + except aiosqlite.Error as e: + # Database connection/lock errors - retryable + logger.warning(f"Database error creating procurement item (retryable): {e}") + raise DatabaseError(f"Failed to create procurement item: {e}") from e + + except Exception as e: + # Unexpected error - treat as retryable + logger.error(f"Unexpected error creating procurement item: {e}") + raise DatabaseError(f"Unexpected error creating procurement item: {e}") from e + + +async def update_procurement_item( + workflow_id: str, + item: str, + status: Optional[str] = None, + eta: Optional[str] = None, + date_arrived: Optional[str] = None, + purchase_order_id: Optional[str] = None +) -> None: + """ + Update a procurement item's fields. Only updates fields that are provided. + + Args: + workflow_id: The Temporal workflow ID + item: The item name (e.g., "Steel Beams") + status: Optional new status + eta: Optional new estimated time of arrival + date_arrived: Optional new arrival date + purchase_order_id: Optional new purchase order ID + + Raises: + DatabaseError: If database operation fails (retryable by Temporal) + DataCorruptionError: If workflow_id is invalid or item not found (non-retryable) + """ + # Input validation - non-retryable errors + if not workflow_id or not isinstance(workflow_id, str): + raise DataCorruptionError("Invalid workflow_id: must be a non-empty string") + + if not item or not isinstance(item, str): + raise DataCorruptionError("Invalid item: must be a non-empty string") + + # Build dynamic update query based on provided fields + update_fields = [] + params = [] + + if status is not None: + update_fields.append("status = ?") + params.append(status) + + if eta is not None: + update_fields.append("eta = ?") + params.append(eta) + + if date_arrived is not None: + update_fields.append("date_arrived = ?") + params.append(date_arrived) + + if purchase_order_id is not None: + update_fields.append("purchase_order_id = ?") + params.append(purchase_order_id) + + if not update_fields: + logger.warning(f"No fields to update for workflow {workflow_id}") + return + + # Always update the updated_at timestamp + update_fields.append("updated_at = CURRENT_TIMESTAMP") + params.extend([workflow_id, item]) + + try: + async with aiosqlite.connect(DB_PATH) as db: + query = f""" + UPDATE procurement_items + SET {', '.join(update_fields)} + WHERE workflow_id = ? AND item = ? + """ + cursor = await db.execute(query, params) + + if cursor.rowcount == 0: + logger.error(f"No procurement item found for workflow {workflow_id} with item {item}") + raise DataCorruptionError(f"No procurement item found for workflow {workflow_id} with item {item}") + + await db.commit() + logger.info(f"Updated procurement item for workflow {workflow_id}") + + except DataCorruptionError: + # Re-raise data corruption errors + raise + + except aiosqlite.Error as e: + # Database connection errors - retryable + logger.warning(f"Database error updating procurement item (retryable): {e}") + raise DatabaseError(f"Failed to update procurement item: {e}") from e + + except Exception as e: + # Unexpected error - treat as retryable + logger.error(f"Unexpected error updating procurement item: {e}") + raise DatabaseError(f"Unexpected error updating procurement item: {e}") from e + + +async def delete_procurement_item(workflow_id: str, item: str) -> None: + """ + Delete a procurement item from the database. + + Args: + workflow_id: The Temporal workflow ID + item: The item name (e.g., "Steel Beams") + + Raises: + DatabaseError: If database operation fails (retryable by Temporal) + DataCorruptionError: If workflow_id is invalid or item not found (non-retryable) + """ + # Input validation - non-retryable errors + if not workflow_id or not isinstance(workflow_id, str): + raise DataCorruptionError("Invalid workflow_id: must be a non-empty string") + + if not item or not isinstance(item, str): + raise DataCorruptionError("Invalid item: must be a non-empty string") + + try: + async with aiosqlite.connect(DB_PATH) as db: + cursor = await db.execute(""" + DELETE FROM procurement_items + WHERE workflow_id = ? AND item = ? + """, (workflow_id, item)) + + if cursor.rowcount == 0: + logger.error(f"No procurement item found for workflow {workflow_id} with item {item}") + raise DataCorruptionError(f"No procurement item found for workflow {workflow_id} with item {item}") + + await db.commit() + logger.info(f"Deleted procurement item for workflow {workflow_id}") + + except DataCorruptionError: + # Re-raise data corruption errors + raise + + except aiosqlite.Error as e: + # Database connection errors - retryable + logger.warning(f"Database error deleting procurement item (retryable): {e}") + raise DatabaseError(f"Failed to delete procurement item: {e}") from e + + except Exception as e: + # Unexpected error - treat as retryable + logger.error(f"Unexpected error deleting procurement item: {e}") + raise DatabaseError(f"Unexpected error deleting procurement item: {e}") from e + + +async def get_procurement_item_by_name(workflow_id: str, item: str) -> Optional[dict]: + """ + Retrieve a procurement item for a specific workflow and item name. + + Args: + workflow_id: The Temporal workflow ID + item: The item name (e.g., "Steel Beams") + + Returns: + The procurement item dict or None if not found + + Raises: + DatabaseError: If database operation fails (retryable by Temporal) + DataCorruptionError: If input validation fails (non-retryable) + """ + # Input validation - non-retryable errors + if not workflow_id or not isinstance(workflow_id, str): + raise DataCorruptionError("Invalid workflow_id: must be a non-empty string") + + if not item or not isinstance(item, str): + raise DataCorruptionError("Invalid item: must be a non-empty string") + + try: + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute(""" + SELECT workflow_id, item, status, eta, date_arrived, purchase_order_id, created_at, updated_at + FROM procurement_items + WHERE workflow_id = ? AND item = ? + """, (workflow_id, item)) as cursor: + row = await cursor.fetchone() + if row: + return { + "workflow_id": row["workflow_id"], + "item": row["item"], + "status": row["status"], + "eta": row["eta"], + "date_arrived": row["date_arrived"], + "purchase_order_id": row["purchase_order_id"], + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + return None + + except DataCorruptionError: + # Re-raise data corruption errors + raise + + except aiosqlite.Error as e: + # Database connection errors - retryable + logger.warning(f"Database error retrieving procurement item (retryable): {e}") + raise DatabaseError(f"Failed to retrieve procurement item: {e}") from e + + except Exception as e: + # Unexpected error - treat as retryable + logger.error(f"Unexpected error retrieving procurement item: {e}") + raise DatabaseError(f"Unexpected error retrieving procurement item: {e}") from e + + +async def get_all_procurement_items() -> list[dict]: + """ + Retrieve all procurement items from the database. + + Returns: + List of procurement item dicts + + Raises: + DatabaseError: If database operation fails (retryable by Temporal) + """ + try: + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute(""" + SELECT workflow_id, item, status, eta, date_arrived, purchase_order_id, created_at, updated_at + FROM procurement_items + ORDER BY created_at DESC + """) as cursor: + rows = await cursor.fetchall() + return [ + { + "workflow_id": row["workflow_id"], + "item": row["item"], + "status": row["status"], + "eta": row["eta"], + "date_arrived": row["date_arrived"], + "purchase_order_id": row["purchase_order_id"], + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + for row in rows + ] + + except aiosqlite.Error as e: + # Database connection errors - retryable + logger.warning(f"Database error retrieving all procurement items (retryable): {e}") + raise DatabaseError(f"Failed to retrieve all procurement items: {e}") from e + + except Exception as e: + # Unexpected error - treat as retryable + logger.error(f"Unexpected error retrieving all procurement items: {e}") + raise DatabaseError(f"Unexpected error retrieving all procurement items: {e}") from e \ No newline at end of file diff --git a/examples/demos/procurement_agent/project/models/__init__.py b/examples/demos/procurement_agent/project/models/__init__.py new file mode 100644 index 000000000..1b2da8d1c --- /dev/null +++ b/examples/demos/procurement_agent/project/models/__init__.py @@ -0,0 +1 @@ +"""Procurement agent models module.""" diff --git a/examples/demos/procurement_agent/project/models/events.py b/examples/demos/procurement_agent/project/models/events.py new file mode 100644 index 000000000..634626ec6 --- /dev/null +++ b/examples/demos/procurement_agent/project/models/events.py @@ -0,0 +1,46 @@ +from enum import Enum +from datetime import datetime + +from pydantic import Field, BaseModel + + +class EventType(Enum): + SUBMITTAL_APPROVED = "Submittal_Approved" + SHIPMENT_DEPARTED_FACTORY = "Shipment_Departed_Factory" + SHIPMENT_ARRIVED_SITE = "Shipment_Arrived_Site" + INSPECTION_FAILED = "Inspection_Failed" + INSPECTION_PASSED = "Inspection_Passed" + HUMAN_INPUT = "Human_Input" + +class SubmitalApprovalEvent(BaseModel): + event_type: EventType = Field(default=EventType.SUBMITTAL_APPROVED) + item: str + document_url: str + document_name: str + +class ShipmentDepartedFactoryEvent(BaseModel): + event_type: EventType = Field(default=EventType.SHIPMENT_DEPARTED_FACTORY) + item: str + eta: datetime + date_departed: datetime + location_address: str + +class ShipmentArrivedSiteEvent(BaseModel): + event_type: EventType = Field(default=EventType.SHIPMENT_ARRIVED_SITE) + item: str + date_arrived: datetime + location_address: str + +class InspectionFailedEvent(BaseModel): + event_type: EventType = Field(default=EventType.INSPECTION_FAILED) + item: str + inspection_date: datetime + document_url: str + document_name: str + +class InspectionPassedEvent(BaseModel): + event_type: EventType = Field(default=EventType.INSPECTION_PASSED) + item: str + inspection_date: datetime + document_url: str + document_name: str \ No newline at end of file diff --git a/examples/demos/procurement_agent/project/run_worker.py b/examples/demos/procurement_agent/project/run_worker.py new file mode 100644 index 000000000..127a810ff --- /dev/null +++ b/examples/demos/procurement_agent/project/run_worker.py @@ -0,0 +1,96 @@ +import asyncio + +from temporalio.contrib.openai_agents import OpenAIAgentsPlugin + +from project.workflow import ProcurementAgentWorkflow +from project.data.database import init_database +from agentex.lib.utils.debug import setup_debug_if_enabled +from agentex.lib.utils.logging import make_logger +from project.activities.activities import ( + schedule_inspection, + flag_potential_issue, + issue_purchase_order, + remove_delivery_item, + update_project_end_date, + notify_team_shipment_arrived, + update_delivery_date_for_item, + create_procurement_item_activity, + delete_procurement_item_activity, + get_master_construction_schedule, + update_procurement_item_activity, + get_all_procurement_items_activity, + create_master_construction_schedule, + get_procurement_item_by_name_activity, +) +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.activities import get_all_activities +from agentex.lib.core.temporal.workers.worker import AgentexWorker +from agentex.lib.core.temporal.plugins.openai_agents.hooks.activities import stream_lifecycle_content +from agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model import ( + TemporalStreamingModelProvider, +) +from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ContextInterceptor + +environment_variables = EnvironmentVariables.refresh() + +logger = make_logger(__name__) + + +async def main(): + """ + Main worker initialization and execution. + Handles database initialization and worker startup with error handling. + """ + try: + # Setup debug mode if enabled + setup_debug_if_enabled() + + task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE + if task_queue_name is None: + raise ValueError("WORKFLOW_TASK_QUEUE is not set") + + # Initialize the database with error handling + try: + await init_database() + logger.info("Database initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize database: {e}") + raise RuntimeError(f"Database initialization failed: {e}") from e + + all_activities = get_all_activities() + [stream_lifecycle_content, issue_purchase_order, flag_potential_issue, notify_team_shipment_arrived, schedule_inspection, + create_master_construction_schedule, get_master_construction_schedule, update_delivery_date_for_item, remove_delivery_item, update_project_end_date, + create_procurement_item_activity, update_procurement_item_activity, delete_procurement_item_activity, + get_procurement_item_by_name_activity, get_all_procurement_items_activity] + + context_interceptor = ContextInterceptor() + streaming_model_provider = TemporalStreamingModelProvider() + + # Create a worker with automatic tracing + worker = AgentexWorker( + task_queue=task_queue_name, + plugins=[OpenAIAgentsPlugin(model_provider=streaming_model_provider)], + interceptors=[context_interceptor], + ) + + logger.info(f"Starting worker on task queue: {task_queue_name}") + + await worker.run( + activities=all_activities, + workflow=ProcurementAgentWorkflow, + ) + + except ValueError as e: + # Configuration error + logger.error(f"Configuration error: {e}") + raise + except RuntimeError as e: + # Database or initialization error + logger.error(f"Initialization error: {e}") + raise + except Exception as e: + # Unexpected error + logger.error(f"Unexpected error in worker: {e}", exc_info=True) + raise + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/demos/procurement_agent/project/scripts/__init__.py b/examples/demos/procurement_agent/project/scripts/__init__.py new file mode 100644 index 000000000..6f84b9de5 --- /dev/null +++ b/examples/demos/procurement_agent/project/scripts/__init__.py @@ -0,0 +1 @@ +"""Procurement agent scripts module.""" diff --git a/examples/demos/procurement_agent/project/scripts/happy_path.py b/examples/demos/procurement_agent/project/scripts/happy_path.py new file mode 100644 index 000000000..44ed6247c --- /dev/null +++ b/examples/demos/procurement_agent/project/scripts/happy_path.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python +""" +Happy path demo script - shows two items going through successfully. +Both items pass inspection and arrive within time buffers. +""" + +import os +import sys +import asyncio +from datetime import datetime + +from temporalio.client import Client + +from project.models.events import ( + EventType, + InspectionPassedEvent, + SubmitalApprovalEvent, + ShipmentArrivedSiteEvent, + ShipmentDepartedFactoryEvent, +) +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables + +# Set defaults for local development +os.environ.setdefault("AGENT_NAME", "procurement-agent") +os.environ.setdefault("ACP_URL", "http://localhost:8000") +os.environ.setdefault("WORKFLOW_NAME", "procurement-agent") +os.environ.setdefault("WORKFLOW_TASK_QUEUE", "procurement_agent_queue") +os.environ.setdefault("TEMPORAL_ADDRESS", "localhost:7233") + +logger = make_logger(__name__) +environment_variables = EnvironmentVariables.refresh() + +# Delay between events (seconds) +EVENT_DELAY = 15 + + +async def send_happy_path_events(workflow_id: str): + """Send happy path events: two items, both pass inspection.""" + + # Connect to Temporal + temporal_url = environment_variables.TEMPORAL_ADDRESS or "localhost:7233" + client = await Client.connect(temporal_url) + + # Get handle to the workflow + handle = client.get_workflow_handle(workflow_id) + + # Item 1: Steel Beams - will PASS inspection + # Required by: 2026-02-15, Buffer: 5 days + # Arriving on 2026-02-10 (5 days early - within buffer) + steel_events = [ + SubmitalApprovalEvent( + event_type=EventType.SUBMITTAL_APPROVED, + item="Steel Beams", + document_name="Steel Beams Submittal.pdf", + document_url="/submittal_approval.pdf" + ), + ShipmentDepartedFactoryEvent( + event_type=EventType.SHIPMENT_DEPARTED_FACTORY, + item="Steel Beams", + eta=datetime(2026, 2, 10, 14, 30), + date_departed=datetime(2026, 2, 3, 9, 15), + location_address="218 W 18th St, New York, NY 10011" + ), + ShipmentArrivedSiteEvent( + event_type=EventType.SHIPMENT_ARRIVED_SITE, + item="Steel Beams", + date_arrived=datetime(2026, 2, 10, 15, 45), + location_address="650 Townsend St, San Francisco, CA 94103" + ), + InspectionPassedEvent( + event_type=EventType.INSPECTION_PASSED, + item="Steel Beams", + inspection_date=datetime(2026, 2, 11, 10, 20), + document_name="Steel Beams Inspection Report.pdf", + document_url="/inspection_passed.pdf" + ) + ] + + # Item 2: Windows - will PASS inspection + # Required by: 2026-03-15, Buffer: 10 days + # Arriving on 2026-03-05 (10 days early - within buffer) + windows_events = [ + SubmitalApprovalEvent( + event_type=EventType.SUBMITTAL_APPROVED, + item="Windows", + document_name="Windows Submittal.pdf", + document_url="/submittal_approval.pdf" + ), + ShipmentDepartedFactoryEvent( + event_type=EventType.SHIPMENT_DEPARTED_FACTORY, + item="Windows", + eta=datetime(2026, 3, 5, 16, 0), + date_departed=datetime(2026, 2, 20, 8, 30), + location_address="218 W 18th St, New York, NY 10011" + ), + ShipmentArrivedSiteEvent( + event_type=EventType.SHIPMENT_ARRIVED_SITE, + item="Windows", + date_arrived=datetime(2026, 3, 5, 16, 20), + location_address="650 Townsend St, San Francisco, CA 94103" + ), + InspectionPassedEvent( + event_type=EventType.INSPECTION_PASSED, + item="Windows", + inspection_date=datetime(2026, 3, 6, 9, 45), + document_name="Windows Inspection Report.pdf", + document_url="/inspection_passed.pdf" + ) + ] + + all_events = [ + ("Steel Beams", steel_events), + ("Windows", windows_events), + ] + + print(f"Connected to workflow: {workflow_id}") + print("=" * 60) + print("HAPPY PATH DEMO: Two items, both pass inspection") + print(f"Event delay: {EVENT_DELAY}s") + print("=" * 60) + + for item_name, events in all_events: + print(f"\n{'=' * 60}") + print(f"Processing: {item_name}") + print("=" * 60) + + for i, event in enumerate(events, 1): + print(f"\n[{i}/4] Sending: {event.event_type.value}") + print(f" Item: {event.item}") + + if hasattr(event, 'eta'): + print(f" ETA: {event.eta}") + if hasattr(event, 'date_arrived'): + print(f" Date Arrived: {event.date_arrived}") + if hasattr(event, 'inspection_date'): + print(f" Inspection Date: {event.inspection_date}") + + try: + event_data = event.model_dump_json() + await handle.signal("send_event", event_data) + print(f" ✓ Sent!") + + await asyncio.sleep(EVENT_DELAY) + + except Exception as e: + print(f" ✗ Error: {e}") + logger.error(f"Failed to send event: {e}") + + print("\n" + "=" * 60) + print("Happy path demo complete! Both items passed inspection.") + print("Check the UI to see processed events.") + print("=" * 60) + + +async def main(): + """Main entry point.""" + + if len(sys.argv) > 1: + workflow_id = sys.argv[1] + else: + print("Enter Workflow ID:") + workflow_id = input("Workflow ID: ").strip() + + if not workflow_id: + print("Error: Workflow ID required!") + print("\nUsage: python happy_path.py [workflow_id]") + return + + try: + await send_happy_path_events(workflow_id) + except KeyboardInterrupt: + print("\n\nInterrupted. Goodbye!") + except Exception as e: + logger.error(f"Unexpected error: {e}") + print(f"Error: {e}") + print("\nMake sure:") + print("1. The workflow is running") + print("2. The workflow ID is correct") + print("3. Temporal is accessible at", environment_variables.TEMPORAL_ADDRESS) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/demos/procurement_agent/project/scripts/human_in_the_loop.py b/examples/demos/procurement_agent/project/scripts/human_in_the_loop.py new file mode 100644 index 000000000..c2e2ebc53 --- /dev/null +++ b/examples/demos/procurement_agent/project/scripts/human_in_the_loop.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python +""" +Human-in-the-loop demo script - shows an item that fails inspection. +Demonstrates the need for human intervention when inspection fails. +""" + +import os +import sys +import asyncio +from datetime import datetime + +from temporalio.client import Client + +from project.models.events import ( + EventType, + InspectionFailedEvent, + SubmitalApprovalEvent, + ShipmentArrivedSiteEvent, + ShipmentDepartedFactoryEvent, +) +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables + +# Set defaults for local development +os.environ.setdefault("AGENT_NAME", "procurement-agent") +os.environ.setdefault("ACP_URL", "http://localhost:8000") +os.environ.setdefault("WORKFLOW_NAME", "procurement-agent") +os.environ.setdefault("WORKFLOW_TASK_QUEUE", "procurement_agent_queue") +os.environ.setdefault("TEMPORAL_ADDRESS", "localhost:7233") + +logger = make_logger(__name__) +environment_variables = EnvironmentVariables.refresh() + +# Delay between events (seconds) +EVENT_DELAY = 3 +# Longer delay after inspection failure to observe the failure handling +POST_FAILURE_DELAY = 30 + + +async def send_human_in_the_loop_events(workflow_id: str): + """Send events for one item that fails inspection.""" + + # Connect to Temporal + temporal_url = environment_variables.TEMPORAL_ADDRESS or "localhost:7233" + client = await Client.connect(temporal_url) + + # Get handle to the workflow + handle = client.get_workflow_handle(workflow_id) + + # HVAC Units - will FAIL inspection + # Required by: 2026-03-01, Buffer: 7 days + # Arriving on 2026-02-15 (14 days early - well within buffer) + hvac_events = [ + SubmitalApprovalEvent( + event_type=EventType.SUBMITTAL_APPROVED, + item="HVAC Units", + document_name="HVAC Units Submittal.pdf", + document_url="/submittal_approval.pdf" + ), + ShipmentDepartedFactoryEvent( + event_type=EventType.SHIPMENT_DEPARTED_FACTORY, + item="HVAC Units", + eta=datetime(2026, 2, 15, 11, 0), + date_departed=datetime(2026, 2, 8, 13, 45), + location_address="218 W 18th St, New York, NY 10011" + ), + ShipmentArrivedSiteEvent( + event_type=EventType.SHIPMENT_ARRIVED_SITE, + item="HVAC Units", + date_arrived=datetime(2026, 2, 15, 10, 30), + location_address="650 Townsend St, San Francisco, CA 94103" + ), + InspectionFailedEvent( + event_type=EventType.INSPECTION_FAILED, + item="HVAC Units", + inspection_date=datetime(2026, 2, 16, 14, 15), + document_name="HVAC Units Inspection Report.pdf", + document_url="/inspection_failed.pdf" + ) + ] + + print(f"Connected to workflow: {workflow_id}") + print("=" * 60) + print("HUMAN-IN-THE-LOOP DEMO: Item fails inspection") + print(f"Event delay: {EVENT_DELAY}s") + print("=" * 60) + + print(f"\n{'=' * 60}") + print("Processing: HVAC Units (will FAIL inspection)") + print("=" * 60) + + for i, event in enumerate(hvac_events, 1): + print(f"\n[{i}/4] Sending: {event.event_type.value}") + print(f" Item: {event.item}") + + if hasattr(event, 'eta'): + print(f" ETA: {event.eta}") + if hasattr(event, 'date_arrived'): + print(f" Date Arrived: {event.date_arrived}") + if hasattr(event, 'inspection_date'): + print(f" Inspection Date: {event.inspection_date}") + + try: + event_data = event.model_dump_json() + await handle.signal("send_event", event_data) + print(f" ✓ Sent!") + + # Use longer delay after inspection failure + is_last_event = (i == len(hvac_events)) + if is_last_event: + print(f"\n ⚠️ INSPECTION FAILED!") + print(f" ⏳ Waiting {POST_FAILURE_DELAY}s to observe failure handling...") + print(f" 💡 Check the UI - agent should request human input") + await asyncio.sleep(POST_FAILURE_DELAY) + else: + await asyncio.sleep(EVENT_DELAY) + + except Exception as e: + print(f" ✗ Error: {e}") + logger.error(f"Failed to send event: {e}") + + print("\n" + "=" * 60) + print("Human-in-the-loop demo complete!") + print("The agent should now be waiting for human input to resolve") + print("the inspection failure. Check the UI to provide input.") + print("=" * 60) + + +async def main(): + """Main entry point.""" + + if len(sys.argv) > 1: + workflow_id = sys.argv[1] + else: + print("Enter Workflow ID:") + workflow_id = input("Workflow ID: ").strip() + + if not workflow_id: + print("Error: Workflow ID required!") + print("\nUsage: python human_in_the_loop.py [workflow_id]") + return + + try: + await send_human_in_the_loop_events(workflow_id) + except KeyboardInterrupt: + print("\n\nInterrupted. Goodbye!") + except Exception as e: + logger.error(f"Unexpected error: {e}") + print(f"Error: {e}") + print("\nMake sure:") + print("1. The workflow is running") + print("2. The workflow ID is correct") + print("3. Temporal is accessible at", environment_variables.TEMPORAL_ADDRESS) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/demos/procurement_agent/project/scripts/out_of_order.py b/examples/demos/procurement_agent/project/scripts/out_of_order.py new file mode 100644 index 000000000..164c4a9e5 --- /dev/null +++ b/examples/demos/procurement_agent/project/scripts/out_of_order.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python +""" +Out-of-order events demo script - tests agent's ability to handle duplicate/out-of-order signals. +Sends a submittal approval event again after shipment arrives but before inspection, +to verify the agent recognizes it already happened and ignores the duplicate. +""" + +import os +import sys +import asyncio +from datetime import datetime + +from temporalio.client import Client + +from project.models.events import ( + EventType, + InspectionPassedEvent, + SubmitalApprovalEvent, + ShipmentArrivedSiteEvent, + ShipmentDepartedFactoryEvent, +) +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables + +# Set defaults for local development +os.environ.setdefault("AGENT_NAME", "procurement-agent") +os.environ.setdefault("ACP_URL", "http://localhost:8000") +os.environ.setdefault("WORKFLOW_NAME", "procurement-agent") +os.environ.setdefault("WORKFLOW_TASK_QUEUE", "procurement_agent_queue") +os.environ.setdefault("TEMPORAL_ADDRESS", "localhost:7233") + +logger = make_logger(__name__) +environment_variables = EnvironmentVariables.refresh() + +# Delay between events (seconds) +EVENT_DELAY = 3 +# Longer delay after duplicate to observe how agent handles it +POST_DUPLICATE_DELAY = 10 + + +async def send_out_of_order_events(workflow_id: str): + """Send events with a duplicate submittal approval after shipment arrives.""" + + # Connect to Temporal + temporal_url = environment_variables.TEMPORAL_ADDRESS or "localhost:7233" + client = await Client.connect(temporal_url) + + # Get handle to the workflow + handle = client.get_workflow_handle(workflow_id) + + # Flooring Materials - will PASS inspection, but with duplicate submittal event + # Required by: 2026-04-01, Buffer: 3 days (so buffer deadline is 2026-03-29) + # Arriving on 2026-03-20 (12 days early - well within buffer, no warnings) + events = [ + # 1. Normal: Submittal approved + SubmitalApprovalEvent( + event_type=EventType.SUBMITTAL_APPROVED, + item="Flooring Materials", + document_name="Flooring Materials Submittal.pdf", + document_url="/submittal_approval.pdf" + ), + # 2. Normal: Shipment departs + ShipmentDepartedFactoryEvent( + event_type=EventType.SHIPMENT_DEPARTED_FACTORY, + item="Flooring Materials", + eta=datetime(2026, 3, 20, 13, 15), + date_departed=datetime(2026, 3, 13, 11, 30), + location_address="218 W 18th St, New York, NY 10011" + ), + # 3. Normal: Shipment arrives + ShipmentArrivedSiteEvent( + event_type=EventType.SHIPMENT_ARRIVED_SITE, + item="Flooring Materials", + date_arrived=datetime(2026, 3, 20, 12, 45), + location_address="650 Townsend St, San Francisco, CA 94103" + ), + # 4. OUT OF ORDER: Duplicate submittal approval (should be ignored) + SubmitalApprovalEvent( + event_type=EventType.SUBMITTAL_APPROVED, + item="Flooring Materials", + document_name="Flooring Materials Submittal.pdf", + document_url="/submittal_approval.pdf" + ), + # 5. Normal: Inspection passes + InspectionPassedEvent( + event_type=EventType.INSPECTION_PASSED, + item="Flooring Materials", + inspection_date=datetime(2026, 3, 21, 15, 30), + document_name="Flooring Materials Inspection Report.pdf", + document_url="/inspection_passed.pdf" + ) + ] + + event_labels = [ + "Submittal Approved (initial)", + "Shipment Departed", + "Shipment Arrived", + "Submittal Approved (DUPLICATE - should be ignored)", + "Inspection Passed" + ] + + print(f"Connected to workflow: {workflow_id}") + print("=" * 60) + print("OUT-OF-ORDER DEMO: Testing duplicate event handling") + print(f"Event delay: {EVENT_DELAY}s") + print("=" * 60) + + print(f"\n{'=' * 60}") + print("Processing: Flooring Materials (with duplicate submittal)") + print("=" * 60) + + for i, (event, label) in enumerate(zip(events, event_labels), 1): + is_duplicate = (i == 4) + + print(f"\n[{i}/5] Sending: {label}") + print(f" Event Type: {event.event_type.value}") + print(f" Item: {event.item}") + + if is_duplicate: + print(f" ⚠️ This is a DUPLICATE event - agent should recognize and ignore") + + if hasattr(event, 'eta'): + print(f" ETA: {event.eta}") + if hasattr(event, 'date_arrived'): + print(f" Date Arrived: {event.date_arrived}") + if hasattr(event, 'inspection_date'): + print(f" Inspection Date: {event.inspection_date}") + + try: + event_data = event.model_dump_json() + await handle.signal("send_event", event_data) + print(f" ✓ Sent!") + + # Use longer delay after duplicate to observe handling + if is_duplicate: + print(f" ⏳ Waiting {POST_DUPLICATE_DELAY}s to observe duplicate handling...") + await asyncio.sleep(POST_DUPLICATE_DELAY) + else: + await asyncio.sleep(EVENT_DELAY) + + except Exception as e: + print(f" ✗ Error: {e}") + logger.error(f"Failed to send event: {e}") + + print("\n" + "=" * 60) + print("Out-of-order demo complete!") + print("The agent should have recognized the duplicate submittal") + print("approval and ignored it. Check the UI to verify.") + print("=" * 60) + + +async def main(): + """Main entry point.""" + + if len(sys.argv) > 1: + workflow_id = sys.argv[1] + else: + print("Enter Workflow ID:") + workflow_id = input("Workflow ID: ").strip() + + if not workflow_id: + print("Error: Workflow ID required!") + print("\nUsage: python out_of_order.py [workflow_id]") + return + + try: + await send_out_of_order_events(workflow_id) + except KeyboardInterrupt: + print("\n\nInterrupted. Goodbye!") + except Exception as e: + logger.error(f"Unexpected error: {e}") + print(f"Error: {e}") + print("\nMake sure:") + print("1. The workflow is running") + print("2. The workflow ID is correct") + print("3. Temporal is accessible at", environment_variables.TEMPORAL_ADDRESS) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/demos/procurement_agent/project/scripts/send_test_events.py b/examples/demos/procurement_agent/project/scripts/send_test_events.py new file mode 100644 index 000000000..e85b75c4d --- /dev/null +++ b/examples/demos/procurement_agent/project/scripts/send_test_events.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python +""" +Simple script to automatically send fake events to the workflow. +Just run this script and it will send a few test events to demonstrate the event handling. +""" + +import os +import sys +import asyncio +from datetime import datetime + +from temporalio.client import Client + +from project.models.events import ( + EventType, + InspectionFailedEvent, + InspectionPassedEvent, + SubmitalApprovalEvent, + ShipmentArrivedSiteEvent, + ShipmentDepartedFactoryEvent, +) +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables + +# Set defaults for local development +os.environ.setdefault("AGENT_NAME", "procurement-agent") +os.environ.setdefault("ACP_URL", "http://localhost:8000") +os.environ.setdefault("WORKFLOW_NAME", "procurement-agent") +os.environ.setdefault("WORKFLOW_TASK_QUEUE", "procurement_agent_queue") +os.environ.setdefault("TEMPORAL_ADDRESS", "localhost:7233") + +logger = make_logger(__name__) +environment_variables = EnvironmentVariables.refresh() + + +async def send_fake_events(workflow_id: str): + """Send a series of fake events to the workflow.""" + + # Connect to Temporal + temporal_url = environment_variables.TEMPORAL_ADDRESS or "localhost:7233" + client = await Client.connect(temporal_url) + + # Get handle to the workflow + handle = client.get_workflow_handle(workflow_id) + + # Define the procurement event flow for Steel Beams (passes inspection) + # Required by: 2026-02-15, Buffer: 5 days + # Arriving on 2026-02-10 (5 days early - within buffer) + steel_beams_events = [ + SubmitalApprovalEvent( + event_type=EventType.SUBMITTAL_APPROVED, + item="Steel Beams", + document_name="Steel Beams Submittal.pdf", + document_url="/submittal_approval.pdf" + ), + ShipmentDepartedFactoryEvent( + event_type=EventType.SHIPMENT_DEPARTED_FACTORY, + item="Steel Beams", + eta=datetime(2026, 2, 10, 14, 30), + date_departed=datetime(2026, 2, 3, 9, 15), + location_address="218 W 18th St, New York, NY 10011" + ), + ShipmentArrivedSiteEvent( + event_type=EventType.SHIPMENT_ARRIVED_SITE, + item="Steel Beams", + date_arrived=datetime(2026, 2, 10, 15, 45), + location_address="650 Townsend St, San Francisco, CA 94103" + ), + InspectionPassedEvent( + event_type=EventType.INSPECTION_PASSED, + item="Steel Beams", + inspection_date=datetime(2026, 2, 11, 10, 20), + document_name="Steel Beams Inspection Report.pdf", + document_url="/inspection_passed.pdf" + ) + ] + + # Define the procurement event flow for HVAC Units (fails inspection) + # Required by: 2026-03-01, Buffer: 7 days + # Arriving on 2026-02-22 (7 days early - within buffer) + hvac_events = [ + SubmitalApprovalEvent( + event_type=EventType.SUBMITTAL_APPROVED, + item="HVAC Units", + document_name="HVAC Units Submittal.pdf", + document_url="/submittal_approval.pdf" + ), + ShipmentDepartedFactoryEvent( + event_type=EventType.SHIPMENT_DEPARTED_FACTORY, + item="HVAC Units", + eta=datetime(2026, 2, 22, 11, 0), + date_departed=datetime(2026, 2, 15, 13, 45), + location_address="218 W 18th St, New York, NY 10011" + ), + ShipmentArrivedSiteEvent( + event_type=EventType.SHIPMENT_ARRIVED_SITE, + item="HVAC Units", + date_arrived=datetime(2026, 2, 22, 10, 30), + location_address="650 Townsend St, San Francisco, CA 94103" + ), + InspectionFailedEvent( + event_type=EventType.INSPECTION_FAILED, + item="HVAC Units", + inspection_date=datetime(2026, 2, 23, 14, 15), + document_name="HVAC Units Inspection Report.pdf", + document_url="/inspection_failed.pdf" + ) + ] + + # Define the procurement event flow for Windows (passes inspection - everything smooth) + # Required by: 2026-03-15, Buffer: 10 days + # Arriving on 2026-03-05 (10 days early - within buffer) + windows_events = [ + SubmitalApprovalEvent( + event_type=EventType.SUBMITTAL_APPROVED, + item="Windows", + document_name="Windows Submittal.pdf", + document_url="/submittal_approval.pdf" + ), + ShipmentDepartedFactoryEvent( + event_type=EventType.SHIPMENT_DEPARTED_FACTORY, + item="Windows", + eta=datetime(2026, 3, 5, 16, 0), + date_departed=datetime(2026, 2, 20, 8, 30), + location_address="218 W 18th St, New York, NY 10011" + ), + ShipmentArrivedSiteEvent( + event_type=EventType.SHIPMENT_ARRIVED_SITE, + item="Windows", + date_arrived=datetime(2026, 3, 5, 16, 20), + location_address="650 Townsend St, San Francisco, CA 94103" + ), + InspectionPassedEvent( + event_type=EventType.INSPECTION_PASSED, + item="Windows", + inspection_date=datetime(2026, 3, 6, 9, 45), + document_name="Windows Inspection Report.pdf", + document_url="/inspection_passed.pdf" + ), + # Duplicate arrival event to test agent doesn't double-process + ShipmentArrivedSiteEvent( + event_type=EventType.SHIPMENT_ARRIVED_SITE, + item="Windows", + date_arrived=datetime(2026, 3, 5, 16, 20), + location_address="650 Townsend St, San Francisco, CA 94103" + ) + ] + + # Define the procurement event flow for Flooring Materials (passes inspection - everything smooth) + # Required by: 2026-04-01, Buffer: 3 days + # Arriving on 2026-03-29 (3 days early - within buffer) + flooring_events = [ + SubmitalApprovalEvent( + event_type=EventType.SUBMITTAL_APPROVED, + item="Flooring Materials", + document_name="Flooring Materials Submittal.pdf", + document_url="/submittal_approval.pdf" + ), + ShipmentDepartedFactoryEvent( + event_type=EventType.SHIPMENT_DEPARTED_FACTORY, + item="Flooring Materials", + eta=datetime(2026, 3, 29, 13, 15), + date_departed=datetime(2026, 3, 22, 11, 30), + location_address="218 W 18th St, New York, NY 10011" + ), + ShipmentArrivedSiteEvent( + event_type=EventType.SHIPMENT_ARRIVED_SITE, + item="Flooring Materials", + date_arrived=datetime(2026, 3, 29, 12, 45), + location_address="650 Townsend St, San Francisco, CA 94103" + ), + InspectionPassedEvent( + event_type=EventType.INSPECTION_PASSED, + item="Flooring Materials", + inspection_date=datetime(2026, 3, 30, 15, 30), + document_name="Flooring Materials Inspection Report.pdf", + document_url="/inspection_passed.pdf" + ) + ] + + # Define the procurement event flow for Electrical Panels (fails inspection) + # Required by: 2026-04-15, Buffer: 5 days + # Arriving on 2026-04-10 (5 days early - within buffer) + # Agent should apply learnings from HVAC Units failure + electrical_events = [ + SubmitalApprovalEvent( + event_type=EventType.SUBMITTAL_APPROVED, + item="Electrical Panels", + document_name="Electrical Panels Submittal.pdf", + document_url="/submittal_approval.pdf" + ), + ShipmentDepartedFactoryEvent( + event_type=EventType.SHIPMENT_DEPARTED_FACTORY, + item="Electrical Panels", + eta=datetime(2026, 4, 10, 10, 45), + date_departed=datetime(2026, 4, 1, 14, 0), + location_address="218 W 18th St, New York, NY 10011" + ), + ShipmentArrivedSiteEvent( + event_type=EventType.SHIPMENT_ARRIVED_SITE, + item="Electrical Panels", + date_arrived=datetime(2026, 4, 10, 11, 15), + location_address="650 Townsend St, San Francisco, CA 94103" + ), + InspectionFailedEvent( + event_type=EventType.INSPECTION_FAILED, + item="Electrical Panels", + inspection_date=datetime(2026, 4, 11, 13, 0), + document_name="Electrical Panels Inspection Report.pdf", + document_url="/inspection_failed.pdf" + ) + ] + + # Combine all events + all_events = [ + ("Steel Beams", steel_beams_events), + ("HVAC Units", hvac_events), + ("Windows", windows_events), + ("Flooring Materials", flooring_events), + ("Electrical Panels", electrical_events) + ] + + print(f"Connected to workflow: {workflow_id}") + print("=" * 60) + print("Sending procurement events...") + print("=" * 60) + + for item_name, events in all_events: + print(f"\n{'=' * 60}") + print(f"Processing: {item_name}") + print("=" * 60) + + for i, event in enumerate(events, 1): + print(f"\n[Event {i}] Sending: {event.event_type.value}") + print(f" Item: {event.item}") + + # Show additional details based on event type + if hasattr(event, 'eta'): + print(f" ETA: {event.eta}") + if hasattr(event, 'date_arrived'): + print(f" Date Arrived: {event.date_arrived}") + if hasattr(event, 'inspection_date'): + print(f" Inspection Date: {event.inspection_date}") + + try: + # Send the event using the send_event signal + # Convert event to JSON string + event_data = event.model_dump_json() + await handle.signal("send_event", event_data) + print(f"✓ Event sent successfully!") + + # Wait a bit between events so you can see them being processed + await asyncio.sleep(10) + + except Exception as e: + print(f"✗ Error sending event: {e}") + logger.error(f"Failed to send event: {e}") + + print("\n" + "=" * 60) + print("All events have been sent!") + print("Check your workflow in the UI to see the processed events.") + print("=" * 60) + + +async def main(): + """Main entry point.""" + + # Get workflow ID from command line or prompt user + if len(sys.argv) > 1: + workflow_id = sys.argv[1] + else: + print("Enter the Workflow ID to send events to:") + print("(You can find this in the AgentEx UI or Temporal dashboard)") + workflow_id = input("Workflow ID: ").strip() + + if not workflow_id: + print("Error: Workflow ID is required!") + print("\nUsage: python send_simple_events.py [workflow_id]") + return + + try: + await send_fake_events(workflow_id) + except KeyboardInterrupt: + print("\n\nInterrupted. Goodbye!") + except Exception as e: + logger.error(f"Unexpected error: {e}") + print(f"Error: {e}") + print("\nMake sure:") + print("1. The workflow is running") + print("2. The workflow ID is correct") + print("3. Temporal is accessible at", environment_variables.TEMPORAL_ADDRESS) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/demos/procurement_agent/project/scripts/send_test_events_lite.py b/examples/demos/procurement_agent/project/scripts/send_test_events_lite.py new file mode 100644 index 000000000..cab515374 --- /dev/null +++ b/examples/demos/procurement_agent/project/scripts/send_test_events_lite.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python +""" +Quick demo script - shows failure then success within ~1 minute. +First item fails inspection, second item passes. +""" + +import os +import sys +import asyncio +from datetime import datetime + +from temporalio.client import Client + +from project.models.events import ( + EventType, + InspectionFailedEvent, + InspectionPassedEvent, + SubmitalApprovalEvent, + ShipmentArrivedSiteEvent, + ShipmentDepartedFactoryEvent, +) +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables + +# Set defaults for local development +os.environ.setdefault("AGENT_NAME", "procurement-agent") +os.environ.setdefault("ACP_URL", "http://localhost:8000") +os.environ.setdefault("WORKFLOW_NAME", "procurement-agent") +os.environ.setdefault("WORKFLOW_TASK_QUEUE", "procurement_agent_queue") +os.environ.setdefault("TEMPORAL_ADDRESS", "localhost:7233") + +logger = make_logger(__name__) +environment_variables = EnvironmentVariables.refresh() + +# Delay between events (seconds) - keep short for demo +EVENT_DELAY = 3 +# Longer delay after inspection failure to let user see the failure handling +POST_FAILURE_DELAY = 20 + + +async def send_demo_events(workflow_id: str): + """Send demo events: one failure cycle, one success cycle.""" + + # Connect to Temporal + temporal_url = environment_variables.TEMPORAL_ADDRESS or "localhost:7233" + client = await Client.connect(temporal_url) + + # Get handle to the workflow + handle = client.get_workflow_handle(workflow_id) + + # Item 1: HVAC Units - will FAIL inspection + # Required by: 2026-03-01, Buffer: 7 days + # Arriving on 2026-02-15 (14 days early - well within buffer, no issue flagged) + hvac_events = [ + SubmitalApprovalEvent( + event_type=EventType.SUBMITTAL_APPROVED, + item="HVAC Units", + document_name="HVAC Units Submittal.pdf", + document_url="/submittal_approval.pdf" + ), + ShipmentDepartedFactoryEvent( + event_type=EventType.SHIPMENT_DEPARTED_FACTORY, + item="HVAC Units", + eta=datetime(2026, 2, 15, 11, 0), + date_departed=datetime(2026, 2, 8, 13, 45), + location_address="218 W 18th St, New York, NY 10011" + ), + ShipmentArrivedSiteEvent( + event_type=EventType.SHIPMENT_ARRIVED_SITE, + item="HVAC Units", + date_arrived=datetime(2026, 2, 15, 10, 30), + location_address="650 Townsend St, San Francisco, CA 94103" + ), + InspectionFailedEvent( + event_type=EventType.INSPECTION_FAILED, + item="HVAC Units", + inspection_date=datetime(2026, 2, 16, 14, 15), + document_name="HVAC Units Inspection Report.pdf", + document_url="/inspection_failed.pdf" + ) + ] + + # Item 2: Steel Beams - will PASS inspection + # Required by: 2026-02-15, Buffer: 5 days + # Arriving on 2026-02-10 (5 days early - within buffer) + steel_events = [ + SubmitalApprovalEvent( + event_type=EventType.SUBMITTAL_APPROVED, + item="Steel Beams", + document_name="Steel Beams Submittal.pdf", + document_url="/submittal_approval.pdf" + ), + ShipmentDepartedFactoryEvent( + event_type=EventType.SHIPMENT_DEPARTED_FACTORY, + item="Steel Beams", + eta=datetime(2026, 2, 10, 14, 30), + date_departed=datetime(2026, 2, 3, 9, 15), + location_address="218 W 18th St, New York, NY 10011" + ), + ShipmentArrivedSiteEvent( + event_type=EventType.SHIPMENT_ARRIVED_SITE, + item="Steel Beams", + date_arrived=datetime(2026, 2, 10, 15, 45), + location_address="650 Townsend St, San Francisco, CA 94103" + ), + InspectionPassedEvent( + event_type=EventType.INSPECTION_PASSED, + item="Steel Beams", + inspection_date=datetime(2026, 2, 11, 10, 20), + document_name="Steel Beams Inspection Report.pdf", + document_url="/inspection_passed.pdf" + ) + ] + + all_events = [ + ("HVAC Units (will FAIL)", hvac_events, True), # True = has failure, wait longer after + ("Steel Beams (will PASS)", steel_events, False), + ] + + print(f"Connected to workflow: {workflow_id}") + print("=" * 60) + print("QUICK DEMO: Failure → Success") + print(f"Event delay: {EVENT_DELAY}s, Post-failure delay: {POST_FAILURE_DELAY}s") + print("=" * 60) + + for item_name, events, has_failure in all_events: + print(f"\n{'=' * 60}") + print(f"Processing: {item_name}") + print("=" * 60) + + for i, event in enumerate(events, 1): + print(f"\n[{i}/4] Sending: {event.event_type.value}") + print(f" Item: {event.item}") + + if hasattr(event, 'eta'): + print(f" ETA: {event.eta}") + if hasattr(event, 'date_arrived'): + print(f" Date Arrived: {event.date_arrived}") + if hasattr(event, 'inspection_date'): + print(f" Inspection Date: {event.inspection_date}") + + try: + event_data = event.model_dump_json() + await handle.signal("send_event", event_data) + print(f" ✓ Sent!") + + # Use longer delay after inspection failure + is_last_event = (i == len(events)) + if is_last_event and has_failure: + print(f" ⏳ Waiting {POST_FAILURE_DELAY}s for failure handling...") + await asyncio.sleep(POST_FAILURE_DELAY) + else: + await asyncio.sleep(EVENT_DELAY) + + except Exception as e: + print(f" ✗ Error: {e}") + logger.error(f"Failed to send event: {e}") + + print("\n" + "=" * 60) + print("Demo complete! Check the UI to see processed events.") + print("=" * 60) + + +async def main(): + """Main entry point.""" + + if len(sys.argv) > 1: + workflow_id = sys.argv[1] + else: + print("Enter Workflow ID:") + workflow_id = input("Workflow ID: ").strip() + + if not workflow_id: + print("Error: Workflow ID required!") + print("\nUsage: python send_test_events_lite.py [workflow_id]") + return + + try: + await send_demo_events(workflow_id) + except KeyboardInterrupt: + print("\n\nInterrupted. Goodbye!") + except Exception as e: + logger.error(f"Unexpected error: {e}") + print(f"Error: {e}") + print("\nMake sure:") + print("1. The workflow is running") + print("2. The workflow ID is correct") + print("3. Temporal is accessible at", environment_variables.TEMPORAL_ADDRESS) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/demos/procurement_agent/project/utils/__init__.py b/examples/demos/procurement_agent/project/utils/__init__.py new file mode 100644 index 000000000..be8d6ac87 --- /dev/null +++ b/examples/demos/procurement_agent/project/utils/__init__.py @@ -0,0 +1,5 @@ +"""Utility functions for the procurement agent.""" + +from project.utils.learning_extraction import get_new_wait_for_human_context + +__all__ = ["get_new_wait_for_human_context"] diff --git a/examples/demos/procurement_agent/project/utils/learning_extraction.py b/examples/demos/procurement_agent/project/utils/learning_extraction.py new file mode 100644 index 000000000..e6cb61b3a --- /dev/null +++ b/examples/demos/procurement_agent/project/utils/learning_extraction.py @@ -0,0 +1,69 @@ +"""Utility for extracting new context from human interactions using a "going backwards" approach. + +This module prevents re-processing old wait_for_human calls by: +1. Iterating backwards through the conversation +2. Stopping when we hit a previously-processed wait_for_human call +3. Returning only the NEW portion of the conversation +""" + +from typing import Any, Set, Dict, List, Tuple, Optional + +from agentex.lib.utils.logging import make_logger + +logger = make_logger(__name__) + + +def get_new_wait_for_human_context( + full_conversation: List[Dict[str, Any]], + extracted_learning_call_ids: Set[str], +) -> Optional[Tuple[List[Dict[str, Any]], str]]: + """ + Extract NEW context since the last processed wait_for_human call. + + Similar to OpenCode's filterCompacted() pattern, this function: + - Iterates backwards through the full conversation history + - Stops when it finds a wait_for_human call we've already processed + - Returns only the NEW context + + Args: + full_conversation: The complete conversation history (self._state.input_list) + extracted_learning_call_ids: Set of call_ids we've already extracted learnings from + + Returns: + Tuple of (new_context_messages, call_id) if a new wait_for_human was found, None otherwise + """ + # Go backwards through the conversation to find new wait_for_human calls + new_context = [] + found_new_wait_for_human = False + new_wait_for_human_call_id = None + + for item in reversed(full_conversation): + # Always collect items as we go backwards + new_context.append(item) + + # Check if this is a wait_for_human function call + if isinstance(item, dict) and item.get("type") == "function_call": + if item.get("name") == "wait_for_human": + call_id = item.get("call_id") + + # If we've already extracted learning for this call_id, STOP + if call_id in extracted_learning_call_ids: + logger.info(f"Found already-processed wait_for_human call_id: {call_id}, stopping") + break + + # This is a NEW wait_for_human call + if not found_new_wait_for_human: + found_new_wait_for_human = True + new_wait_for_human_call_id = call_id + logger.info(f"Found NEW wait_for_human call_id: {call_id}") + + # If we found a new wait_for_human call, return the new context + if found_new_wait_for_human: + # Reverse back to chronological order + new_context.reverse() + logger.info(f"Extracted {len(new_context)} messages of new context") + assert new_wait_for_human_call_id is not None, "call_id should be set when found_new_wait_for_human is True" + return (new_context, new_wait_for_human_call_id) + else: + logger.info("No new wait_for_human calls found") + return None diff --git a/examples/demos/procurement_agent/project/utils/summarization.py b/examples/demos/procurement_agent/project/utils/summarization.py new file mode 100644 index 000000000..b74ad1e37 --- /dev/null +++ b/examples/demos/procurement_agent/project/utils/summarization.py @@ -0,0 +1,205 @@ +""" +Summarization utility for managing conversation context. + +This module provides functionality to detect when conversation history exceeds +token limits and should be summarized. Follows OpenCode's approach of stopping +at previous summaries to avoid re-summarizing already condensed content. +""" +from typing import Any, Dict, List, Tuple, Optional + +import tiktoken + +from agentex.lib.utils.logging import make_logger + +logger = make_logger(__name__) + +# Configuration constants +SUMMARIZATION_TOKEN_THRESHOLD = 40000 # Trigger summarization at 40k tokens +PRESERVE_LAST_N_TURNS = 10 # Always keep last 10 user turns in full + + +def estimate_tokens(text: str) -> int: + """ + Estimate the number of tokens in a text string using tiktoken. + + Args: + text: The text to estimate tokens for + + Returns: + Estimated token count + """ + try: + encoding = tiktoken.encoding_for_model("gpt-4o") + return len(encoding.encode(text)) + except Exception as e: + # Fallback to rough estimation if tiktoken fails + logger.warning(f"Token estimation failed, using fallback: {e}") + return len(text) // 4 # Rough approximation + + +def should_summarize(input_list: List[Dict[str, Any]]) -> bool: + """ + Check if the conversation history exceeds the token threshold and needs summarization. + + Args: + input_list: The conversation history + + Returns: + True if summarization should be triggered + """ + total_tokens = 0 + + for item in input_list: + if isinstance(item, dict): + # Estimate tokens for the entire item (JSON serialized) + item_str = str(item) + total_tokens += estimate_tokens(item_str) + + logger.info(f"Total conversation tokens: {total_tokens}") + + if total_tokens > SUMMARIZATION_TOKEN_THRESHOLD: + logger.info(f"Token threshold exceeded ({total_tokens} > {SUMMARIZATION_TOKEN_THRESHOLD}), summarization needed") + return True + + return False + + +def get_messages_to_summarize( + input_list: List[Dict[str, Any]], + last_summary_index: Optional[int] +) -> Tuple[List[Dict[str, Any]], int, int]: + """ + Get the portion of conversation that should be summarized, following OpenCode's approach. + + Strategy: + - If there's a previous summary, start from AFTER it (never re-summarize summaries) + - Find last N user turns and preserve them + - Return everything in between for summarization + + Args: + input_list: The full conversation history + last_summary_index: Index of the last summary message (None if no prior summary) + + Returns: + Tuple of (messages_to_summarize, start_index, end_index) + - messages_to_summarize: The slice of conversation to summarize + - start_index: Where the summarization range starts + - end_index: Where the summarization range ends (exclusive) + """ + # Find all user turn indices + user_turn_indices = [] + for i, item in enumerate(input_list): + if isinstance(item, dict) and item.get("role") == "user": + user_turn_indices.append(i) + + # Determine the start index (after last summary, or from beginning) + if last_summary_index is not None: + start_index = last_summary_index + 1 # Start AFTER the summary + logger.info(f"Starting summarization after previous summary at index {last_summary_index}") + else: + start_index = 0 + logger.info("No previous summary found, starting from beginning") + + # Determine the end index (preserve last N turns) + if len(user_turn_indices) >= PRESERVE_LAST_N_TURNS: + # Find the Nth-from-last user turn + preserve_from_index = user_turn_indices[-PRESERVE_LAST_N_TURNS] + end_index = preserve_from_index + logger.info(f"Preserving last {PRESERVE_LAST_N_TURNS} turns from index {preserve_from_index}") + else: + # Not enough turns to preserve, summarize nothing + end_index = len(input_list) + logger.warning(f"Only {len(user_turn_indices)} user turns, not enough to summarize (need more than {PRESERVE_LAST_N_TURNS})") + + # Extract the messages to summarize + if end_index <= start_index: + logger.info("No messages to summarize (end_index <= start_index)") + return [], start_index, end_index + + messages_to_summarize = input_list[start_index:end_index] + logger.info(f"Summarizing {len(messages_to_summarize)} messages from index {start_index} to {end_index}") + + return messages_to_summarize, start_index, end_index + + +def create_summary_message(summary_text: str) -> Dict[str, Any]: + """ + Create a summary message in the input_list format. + + Args: + summary_text: The AI-generated summary text + + Returns: + A dictionary representing the summary message + """ + return { + "role": "assistant", + "content": summary_text, + "_summary": True, # Mark this as a summary message + } + + +def create_resume_message() -> Dict[str, Any]: + """ + Create a resume message that instructs the AI to continue from the summary. + + Returns: + A dictionary representing the resume instruction + """ + return { + "role": "user", + "content": "Use the above summary to continue from where we left off.", + "_synthetic": True, # Mark as system-generated + } + + +def apply_summary_to_input_list( + input_list: List[Dict[str, Any]], + summary_text: str, + start_index: int, + end_index: int +) -> List[Dict[str, Any]]: + """ + Replace the summarized portion of input_list with the summary message. + + Args: + input_list: The original conversation history + summary_text: The AI-generated summary + start_index: Start of summarized range + end_index: End of summarized range + + Returns: + New input_list with summary applied + """ + # Build new input list: [before summary] + [summary] + [resume] + [after summary] + before_summary = input_list[:start_index] if start_index > 0 else [] + after_summary = input_list[end_index:] + + summary_msg = create_summary_message(summary_text) + resume_msg = create_resume_message() + + new_input_list = before_summary + [summary_msg, resume_msg] + after_summary + + logger.info(f"Applied summary: reduced from {len(input_list)} to {len(new_input_list)} messages") + + return new_input_list + + +def find_last_summary_index(input_list: List[Dict[str, Any]]) -> Optional[int]: + """ + Find the index of the last summary message in the conversation. + + Args: + input_list: The conversation history + + Returns: + Index of the last summary message, or None if no summary exists + """ + for i in range(len(input_list) - 1, -1, -1): + item = input_list[i] + if isinstance(item, dict) and item.get("_summary") is True: + logger.info(f"Found last summary at index {i}") + return i + + logger.info("No previous summary found") + return None diff --git a/examples/demos/procurement_agent/project/workflow.py b/examples/demos/procurement_agent/project/workflow.py new file mode 100644 index 000000000..115c2d2f6 --- /dev/null +++ b/examples/demos/procurement_agent/project/workflow.py @@ -0,0 +1,454 @@ +import os +import json +import asyncio +from typing import Any, Dict, List, override +from datetime import timedelta + +from agents import Runner +from pydantic import BaseModel +from temporalio import workflow +from temporalio.common import RetryPolicy +from temporalio.exceptions import ApplicationError + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CreateTaskParams +from project.models.events import ( + EventType, + InspectionFailedEvent, + InspectionPassedEvent, + SubmitalApprovalEvent, + ShipmentArrivedSiteEvent, + ShipmentDepartedFactoryEvent, +) +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.data_content import DataContent +from agentex.types.text_content import TextContent +from project.utils.summarization import ( + should_summarize, + find_last_summary_index, + get_messages_to_summarize, + apply_summary_to_input_list, +) +from project.activities.activities import get_master_construction_schedule, create_master_construction_schedule +from project.agents.procurement_agent import new_procurement_agent +from agentex.lib.environment_variables import EnvironmentVariables +from project.utils.learning_extraction import get_new_wait_for_human_context +from project.agents.summarization_agent import new_summarization_agent +from project.agents.extract_learnings_agent import new_extract_learnings_agent +from agentex.lib.core.temporal.types.workflow import SignalName +from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow +from agentex.lib.core.tracing.tracing_processor_manager import ( + add_tracing_processor_config, +) +from agentex.lib.core.temporal.plugins.openai_agents.hooks.hooks import TemporalStreamingHooks + +environment_variables = EnvironmentVariables.refresh() + +if environment_variables.WORKFLOW_NAME is None: + raise ValueError("Environment variable WORKFLOW_NAME is not set") + +if environment_variables.AGENT_NAME is None: + raise ValueError("Environment variable AGENT_NAME is not set") + +logger = make_logger(__name__) + +# Setup tracing for SGP (Scale GenAI Platform) +# This enables visibility into your agent's execution in the SGP dashboard +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_BASE_URL"), + ) +) + + +class TurnInput(BaseModel): + """Input model for tracing spans.""" + input_list: List[Dict[str, Any]] + + +class TurnOutput(BaseModel): + """Output model for tracing spans.""" + final_output: Any + + +class StateModel(BaseModel): + """ + State model for preserving conversation history. + + This allows the agent to maintain context throughout the conversation, + making it possible to reference previous messages and build on the discussion. + + Attributes: + input_list: The conversation history in OpenAI message format. + turn_number: Counter for tracking conversation turns (useful for tracing). + """ + input_list: List[Dict[str, Any]] + turn_number: int + + +@workflow.defn(name=environment_variables.WORKFLOW_NAME) +class ProcurementAgentWorkflow(BaseWorkflow): + """ + Minimal async workflow template for AgentEx Temporal agents. + """ + def __init__(self): + super().__init__(display_name=environment_variables.AGENT_NAME) + self._complete_task = False + self._task_id = None + self._trace_id = None + self._parent_span_id = None + self._state = None + self._workflow_started = False # Track if agent workflow loop has started + self.event_queue: asyncio.Queue = asyncio.Queue() # Events + self.human_queue: asyncio.Queue = asyncio.Queue() # Human input + self.human_input_learnings: list = [] + self.extracted_learning_call_ids: set = set() # Track which wait_for_human calls we've extracted learnings from + + # Define activity retry policy with exponential backoff + # Based on Temporal best practices from blog post + self.activity_retry_policy = RetryPolicy( + initial_interval=timedelta(seconds=1), + backoff_coefficient=2.0, # Exponential backoff + maximum_interval=timedelta(seconds=120), # Cap at 2 minutes + maximum_attempts=5, + non_retryable_error_types=[ + "DataCorruptionError", + "ScheduleNotFoundError", + ] + ) + + @workflow.signal(name=SignalName.RECEIVE_EVENT) + @override + async def on_task_event_send(self, params: SendEventParams) -> None: + """ + Handle incoming events from the frontend. + + First event: Triggers the initial agent workflow execution. + Subsequent events: Feed the wait_for_human tool's human_queue. + """ + if self._state is None: + raise ValueError("State is not initialized") + + if params.event.content is None: + workflow.logger.warning("Received event with no content") + return + + # Display the user's message in the UI + await adk.messages.create(task_id=params.task.id, content=params.event.content) + + # After the first event, all subsequent events are human responses to wait_for_human + if self._workflow_started: + # Extract text content and put it in the human_queue for wait_for_human tool + if isinstance(params.event.content, TextContent): + await self.human_queue.put(params.event.content.content) + + @workflow.run + @override + async def on_task_create(self, params: CreateTaskParams) -> str: + logger.info(f"Received task create params: {params}") + + self._state = StateModel(input_list=[], turn_number=0) + + self._task_id = params.task.id + self._trace_id = params.task.id + self._parent_span_id = params.task.id + + workflow_id = workflow.info().workflow_id + + # Create the master construction schedule with error handling + try: + await workflow.execute_activity( + create_master_construction_schedule, + workflow_id, + start_to_close_timeout=timedelta(minutes=5), # Changed from 10s to 5min + schedule_to_close_timeout=timedelta(minutes=10), + retry_policy=self.activity_retry_policy, + ) + logger.info("Master construction schedule created successfully") + + except ApplicationError as e: + # Non-retryable application error (invalid data) + logger.error(f"Failed to create schedule: {e}") + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="Failed to initialize project schedule. Please contact support.", + ), + ) + raise # Fail the workflow + + except Exception as e: + # Unexpected error + logger.error(f"Unexpected error creating schedule: {e}") + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="System error during initialization. Please try creating a new task.", + ), + ) + raise + + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="Welcome to the Procurement Agent! I'll help you manage construction deliveries and schedules. Send events to get started.", + ), + ) + + # Mark workflow as started - subsequent events will feed the human_queue + self._workflow_started = True + + while True: + await workflow.wait_condition( + lambda: not self.event_queue.empty(), + timeout=None, + ) + + if not self.event_queue.empty(): + event = await self.event_queue.get() + + await adk.messages.create(task_id=params.task.id, content=DataContent( + author="user", + data=json.loads(event), + )) + + self._state.input_list.append({ + "role": "user", + "content": event, + }) + + # Get master construction schedule with error handling + try: + master_construction_schedule = await workflow.execute_activity( + get_master_construction_schedule, + workflow_id, + start_to_close_timeout=timedelta(minutes=2), # Changed from 10s to 2min + schedule_to_close_timeout=timedelta(minutes=5), + retry_policy=self.activity_retry_policy, + ) + except ApplicationError as e: + # Non-retryable error (schedule not found or corrupted) + logger.error(f"Failed to retrieve schedule for event processing: {e}") + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="Unable to access project schedule. Please reinitialize the workflow.", + ), + ) + continue # Skip this event, wait for next one + + except Exception as e: + # Unexpected error retrieving schedule + logger.error(f"Unexpected error retrieving schedule: {e}") + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="Temporary system issue. Retrying event processing...", + ), + ) + continue # Skip this event, wait for next one + + # Increment turn number for tracing + self._state.turn_number += 1 + + # Create a span to track this turn of the conversation + turn_input = TurnInput( + input_list=self._state.input_list, + ) + + # Create agent and execute with error handling + try: + async with adk.tracing.span( + trace_id=params.task.id, + name=f"Turn {self._state.turn_number}", + input=turn_input.model_dump(), + ) as span: + self._parent_span_id = span.id if span else None + + procurement_agent = new_procurement_agent( + master_construction_schedule=master_construction_schedule, + human_input_learnings=self.human_input_learnings + ) + + hooks = TemporalStreamingHooks(task_id=params.task.id) + + # Execute agent with graceful degradation pattern (from temporal-community demos) + result = await Runner.run(procurement_agent, self._state.input_list, hooks=hooks) # type: ignore[arg-type] + + # Update state with result + self._state.input_list = result.to_input_list() # type: ignore[assignment] + logger.info("Successfully processed event") + + # Set span output for tracing + if span: + turn_output = TurnOutput(final_output=result.final_output) + span.output = turn_output.model_dump() + # Extract learnings from NEW wait_for_human calls only (using going backwards approach) + try: + result_context = get_new_wait_for_human_context( + full_conversation=self._state.input_list, + extracted_learning_call_ids=self.extracted_learning_call_ids, + ) + + if result_context is not None: + new_context, call_id = result_context + logger.info("Found new wait_for_human call, extracting learning...") + + # Create extraction agent and run with only the NEW context + extract_agent = new_extract_learnings_agent() + extraction_result = await Runner.run(extract_agent, new_context, hooks=hooks) # type: ignore[arg-type] + + logger.info(f"About to extract learning: {extraction_result.final_output}") + # Append the learning and track the call_id + learning = extraction_result.final_output + if learning: + self.human_input_learnings.append(learning) + self.extracted_learning_call_ids.add(call_id) + logger.info(f"Extracted learning: {learning}") + + except Exception as e: + logger.error(f"Failed to extract learning: {e}") + + # Check if summarization is needed (after learning extraction) + try: + if should_summarize(self._state.input_list): + logger.info("Token threshold exceeded, starting summarization...") + + # Find the last summary index + last_summary_index = find_last_summary_index(self._state.input_list) + + # Get messages to summarize (excludes last 10 turns, starts after previous summary) + messages_to_summarize, start_index, end_index = get_messages_to_summarize( + self._state.input_list, + last_summary_index + ) + + if messages_to_summarize: + logger.info(f"Summarizing {len(messages_to_summarize)} messages...") + + # Create summarization agent and run + summary_agent = new_summarization_agent() + summary_result = await Runner.run(summary_agent, messages_to_summarize, hooks=hooks) # type: ignore[arg-type] + + summary_text = summary_result.final_output + if summary_text: + # Apply summary to input_list + self._state.input_list = apply_summary_to_input_list( + self._state.input_list, + summary_text, + start_index, + end_index + ) + logger.info(f"Summarization complete, new input_list length: {len(self._state.input_list)}") + else: + logger.warning("Summarization produced no output") + else: + logger.info("No messages to summarize (not enough turns yet)") + + except Exception as e: + logger.error(f"Failed to summarize conversation: {e}") + + except Exception as e: + # Agent execution failed - graceful degradation + logger.error(f"Agent execution failed processing event: {e}") + + # Notify that event couldn't be processed + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="Unable to process this event. The issue has been logged. Please try sending another event.", + ), + ) + + # Don't crash workflow - continue and wait for next event + continue + + if self._complete_task: + return "Task completed" + + @workflow.signal + async def complete_task_signal(self) -> None: + logger.info("Received signal to complete the agent conversation") + self._complete_task = True + + @workflow.signal + async def send_event(self, event: str) -> None: + """ + Receives event strings from external systems with validation. + Events should be JSON strings with event_type and required fields. + Example: {"event_type":"Submittal_Approved","item":"Steel Beams"} + """ + # Validate event is not None or empty + if not event: + logger.error("Received empty or None event") + raise ValueError("Event cannot be empty or None") + + # Validate event is a string + if not isinstance(event, str): + logger.error(f"Event must be string, got {type(event)}") + raise ValueError(f"Event must be a string, received {type(event).__name__}") + + # Validate event length (prevent DoS) + if len(event) > 50000: # 50KB limit + logger.error(f"Event too large: {len(event)} characters") + raise ValueError(f"Event exceeds maximum size (50KB)") + + # Validate event is valid JSON + try: + event_data = json.loads(event) + except json.JSONDecodeError as e: + logger.error(f"Event is not valid JSON: {e}") + raise ValueError(f"Event must be valid JSON: {e}") from e + + # Validate event has required structure + if not isinstance(event_data, dict): + logger.error(f"Event JSON must be an object, got {type(event_data)}") + raise ValueError("Event must be a JSON object") + + # Validate event_type field exists + if "event_type" not in event_data: + logger.error("Event missing 'event_type' field") + raise ValueError("Event must contain 'event_type' field") + + # Validate event_type is one of the allowed types + event_type_str = event_data["event_type"] + valid_event_types = [e.value for e in EventType] + + if event_type_str not in valid_event_types: + logger.error(f"Invalid event_type: {event_type_str}. Valid types: {valid_event_types}") + raise ValueError( + f"Invalid event_type '{event_type_str}'. " + f"Must be one of: {', '.join(valid_event_types)}" + ) + + # Validate event structure based on type using Pydantic models + try: + if event_type_str == EventType.SUBMITTAL_APPROVED.value: + SubmitalApprovalEvent(**event_data) + elif event_type_str == EventType.SHIPMENT_DEPARTED_FACTORY.value: + ShipmentDepartedFactoryEvent(**event_data) + elif event_type_str == EventType.SHIPMENT_ARRIVED_SITE.value: + ShipmentArrivedSiteEvent(**event_data) + elif event_type_str == EventType.INSPECTION_FAILED.value: + InspectionFailedEvent(**event_data) + elif event_type_str == EventType.INSPECTION_PASSED.value: + InspectionPassedEvent(**event_data) + elif event_type_str == EventType.HUMAN_INPUT.value: + # HUMAN_INPUT doesn't have a specific model, just needs event_type + pass + + except Exception as e: + logger.error(f"Event validation failed for {event_type_str}: {e}") + raise ValueError(f"Invalid event structure for {event_type_str}: {e}") from e + + logger.info(f"Validated event type: {event_type_str}") + await self.event_queue.put(event) \ No newline at end of file diff --git a/examples/demos/procurement_agent/pyproject.toml b/examples/demos/procurement_agent/pyproject.toml new file mode 100644 index 000000000..555819a5d --- /dev/null +++ b/examples/demos/procurement_agent/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "procurement_agent" +version = "0.1.0" +description = "An Agentex agent that manages procurement for building constructions" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk>=0.6.5", + "openai-agents>=0.4.2", + "temporalio>=1.18.2", + "scale-gp", + "aiosqlite", + "pytest-html>=4.2.0", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", + "debugpy>=1.8.15", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/examples/launch-tutorials.sh b/examples/launch-tutorials.sh new file mode 100755 index 000000000..024d9ac17 --- /dev/null +++ b/examples/launch-tutorials.sh @@ -0,0 +1,341 @@ +#!/bin/bash + +# AgentEx Tutorial Launcher +# This script helps you easily launch and test all tutorials in the repository +# +# Usage: +# ./launch-tutorials.sh # Show interactive menu +# ./launch-tutorials.sh 1 # Launch tutorial #1 directly +# ./launch-tutorials.sh a # Launch all tutorials with confirmations +# ./launch-tutorials.sh c # Clean up orphaned tutorial processes +# +# Note: Excludes 90_multi_agent_non_temporal (use its own start-agents.sh) + +# Simple cleanup function for orphaned processes +cleanup() { + # Kill any remaining agentex or uvicorn processes from tutorials + local agentex_pids=$(pgrep -f "agentex agents run.*tutorials" 2>/dev/null || true) + if [[ -n "$agentex_pids" ]]; then + echo "$agentex_pids" | xargs kill -TERM 2>/dev/null || true + sleep 1 + echo "$agentex_pids" | xargs kill -KILL 2>/dev/null || true + fi + + local uvicorn_pids=$(pgrep -f "uvicorn.*project\." 2>/dev/null || true) + if [[ -n "$uvicorn_pids" ]]; then + echo "$uvicorn_pids" | xargs kill -TERM 2>/dev/null || true + sleep 1 + echo "$uvicorn_pids" | xargs kill -KILL 2>/dev/null || true + fi +} + +# Color codes for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Tutorial definitions +declare -a TUTORIALS=( + "tutorials/00_sync/000_hello_acp|Basic Hello ACP (Sync)" + "tutorials/00_sync/010_multiturn|Multi-turn Chat (Sync)" + "tutorials/00_sync/020_streaming|Streaming Response (Sync)" + "tutorials/10_async/00_base/000_hello_acp|Basic Hello ACP (Async)" + "tutorials/10_async/00_base/010_multiturn|Multi-turn Chat (Async)" + "tutorials/10_async/00_base/020_streaming|Streaming Response (Async)" + "tutorials/10_async/00_base/030_tracing|Tracing Example (Async)" + "tutorials/10_async/00_base/040_other_sdks|Other SDKs Integration (Async)" + "tutorials/10_async/00_base/080_batch_events|Batch Events (Async)" + "tutorials/10_async/10_temporal/000_hello_acp|Basic Hello ACP (Temporal)" + "tutorials/10_async/10_temporal/010_agent_chat|Agent Chat (Temporal)" + "tutorials/10_async/10_temporal/020_state_machine|State Machine (Temporal)" +) + +# Function to print colored output +print_colored() { + local color=$1 + local message=$2 + # Check if terminal supports colors + if [[ -t 1 ]] && command -v tput >/dev/null 2>&1; then + printf "${color}%s${NC}\n" "$message" + else + printf "%s\n" "$message" + fi +} + +# Function to display the menu +show_menu() { + print_colored $BLUE "╔════════════════════════════════════════════════════════════════╗" + print_colored $BLUE "║ AgentEx Tutorial Launcher ║" + print_colored $BLUE "╚════════════════════════════════════════════════════════════════╝" + echo "" + print_colored $YELLOW "Available tutorials:" + echo "" + + local index=1 + for tutorial in "${TUTORIALS[@]}"; do + IFS='|' read -r path description <<< "$tutorial" + if [[ -t 1 ]] && command -v tput >/dev/null 2>&1; then + printf "${GREEN}%2d.${NC} %s\n" $index "$description" + else + printf "%2d. %s\n" $index "$description" + fi + index=$((index + 1)) + done + + echo "" + print_colored $BLUE "Other options:" + print_colored $GREEN " a. Run all tutorials sequentially (with confirmations)" + print_colored $GREEN " c. Clean up any orphaned tutorial processes" + print_colored $GREEN " q. Quit" + echo "" + print_colored $YELLOW "📌 Note: The multi-agent system tutorial (tutorials/10_async/90_multi_agent_non_temporal) is excluded" + print_colored $YELLOW " as it has a special launch process. Use its own start-agents.sh script." + echo "" +} + +# Function to run a specific tutorial +run_tutorial() { + local tutorial_index=$1 + local tutorial_info="${TUTORIALS[$((tutorial_index - 1))]}" + IFS='|' read -r path description <<< "$tutorial_info" + + local manifest_path="${path}/manifest.yaml" + + print_colored $BLUE "╔════════════════════════════════════════════════════════════════╗" + printf "║ Running: %-54s ║\n" "$description" + print_colored $BLUE "╚════════════════════════════════════════════════════════════════╝" + + if [[ ! -f "$manifest_path" ]]; then + print_colored $RED "❌ Error: Manifest file not found at $manifest_path" + return 1 + fi + + print_colored $YELLOW "📂 Tutorial path: $path" + print_colored $YELLOW "📄 Manifest: $manifest_path" + echo "" + print_colored $GREEN "🚀 Executing: cd .. && uv run agentex agents run --manifest examples/$manifest_path" + print_colored $YELLOW "💡 Press Ctrl+C to stop the tutorial" + echo "" + + # Run the tutorial directly (need to go to parent dir where uv project is) + # Load .env file if it exists and pass variables to the subshell + if [[ -f "../.env" ]]; then + (cd .. && set -a && source .env && set +a && uv run agentex agents run --manifest "examples/$manifest_path") + else + (cd .. && uv run agentex agents run --manifest "examples/$manifest_path") + fi + + local exit_code=$? + if [[ $exit_code -eq 0 ]]; then + print_colored $GREEN "✅ Tutorial completed successfully!" + elif [[ $exit_code -eq 130 ]]; then + print_colored $YELLOW "🛑 Tutorial was interrupted by user" + else + print_colored $RED "❌ Tutorial failed with exit code: $exit_code" + fi + + return $exit_code +} + +# Function to run all tutorials +run_all_tutorials() { + print_colored $BLUE "🎯 Running all tutorials sequentially..." + echo "" + + local success_count=0 + local total_count=${#TUTORIALS[@]} + + for i in $(seq 1 $total_count); do + local tutorial_info="${TUTORIALS[$((i - 1))]}" + IFS='|' read -r path description <<< "$tutorial_info" + + print_colored $YELLOW "┌─ Tutorial $i/$total_count: $description" + echo "" + + # Ask for confirmation + while true; do + print_colored $BLUE "Run this tutorial? (y/n/q to quit): " + read -r response + case $response in + [Yy]* ) + if run_tutorial $i; then + success_count=$((success_count + 1)) + fi + break + ;; + [Nn]* ) + print_colored $YELLOW "⏭️ Skipping tutorial $i" + break + ;; + [Qq]* ) + print_colored $YELLOW "🛑 Stopping tutorial run" + echo "" + print_colored $BLUE "📊 Summary: $success_count/$((i-1)) tutorials completed successfully" + return 0 + ;; + * ) + print_colored $RED "Please answer y, n, or q." + ;; + esac + done + + if [[ $i -lt $total_count ]]; then + echo "" + print_colored $BLUE "────────────────────────────────────────────────────────────────" + echo "" + fi + done + + echo "" + print_colored $BLUE "🎉 All tutorials completed!" + print_colored $BLUE "📊 Summary: $success_count/$total_count tutorials completed successfully" +} + +# Function to manually clean up tutorial processes +manual_cleanup() { + print_colored $BLUE "🧹 Manual cleanup of tutorial processes..." + echo "" + + # Check for running tutorial processes + local found_processes=false + + # Check for agentex processes + local agentex_pids=$(pgrep -f "agentex agents run.*tutorials" 2>/dev/null || true) + if [[ -n "$agentex_pids" ]]; then + found_processes=true + print_colored $YELLOW "🔍 Found agentex tutorial processes:" + ps -p $agentex_pids -o pid,command 2>/dev/null || true + echo "" + fi + + # Check for uvicorn processes + local uvicorn_pids=$(pgrep -f "uvicorn.*project\." 2>/dev/null || true) + if [[ -n "$uvicorn_pids" ]]; then + found_processes=true + print_colored $YELLOW "🔍 Found uvicorn tutorial processes:" + ps -p $uvicorn_pids -o pid,command 2>/dev/null || true + echo "" + fi + + # Check for occupied ports + print_colored $YELLOW "🔍 Checking common tutorial ports (8000-8003)..." + local port_check=$(lsof -i :8000 -i :8001 -i :8002 -i :8003 2>/dev/null || true) + if [[ -n "$port_check" ]]; then + found_processes=true + echo "$port_check" + echo "" + fi + + if [[ "$found_processes" == "false" ]]; then + print_colored $GREEN "✅ No tutorial processes found - system is clean!" + return 0 + fi + + # Ask for confirmation before cleaning + while true; do + print_colored $BLUE "Kill these processes? (y/n): " + read -r response + case $response in + [Yy]* ) + print_colored $YELLOW "🧹 Cleaning up..." + cleanup + print_colored $GREEN "✅ Manual cleanup completed!" + break + ;; + [Nn]* ) + print_colored $YELLOW "⏭️ Cleanup cancelled" + break + ;; + * ) + print_colored $RED "Please answer y or n." + ;; + esac + done +} + +# Function to validate tutorial number +validate_tutorial_number() { + local num=$1 + if [[ ! "$num" =~ ^[0-9]+$ ]] || [[ $num -lt 1 ]] || [[ $num -gt ${#TUTORIALS[@]} ]]; then + return 1 + fi + return 0 +} + +# Main script logic +main() { + # Check if we're in the right directory + if [[ ! -f "../pyproject.toml" ]] || [[ ! -d "tutorials" ]]; then + print_colored $RED "❌ Error: This script must be run from the examples directory" + print_colored $YELLOW "💡 Current directory: $(pwd)" + print_colored $YELLOW "💡 Expected files: ../pyproject.toml, tutorials/" + exit 1 + fi + + # If a tutorial number is provided as argument + if [[ $# -eq 1 ]]; then + local tutorial_num=$1 + + if [[ "$tutorial_num" == "a" ]] || [[ "$tutorial_num" == "all" ]]; then + run_all_tutorials + exit 0 + elif [[ "$tutorial_num" == "c" ]] || [[ "$tutorial_num" == "cleanup" ]]; then + manual_cleanup + exit 0 + fi + + if validate_tutorial_number "$tutorial_num"; then + run_tutorial "$tutorial_num" + exit $? + else + print_colored $RED "❌ Error: Invalid tutorial number '$tutorial_num'" + print_colored $YELLOW "💡 Valid range: 1-${#TUTORIALS[@]}" + exit 1 + fi + fi + + # Interactive mode + while true; do + show_menu + print_colored $BLUE "Enter your choice (1-${#TUTORIALS[@]}, a, c, or q): " + read -r choice + + case $choice in + [Qq]* ) + print_colored $YELLOW "👋 Goodbye!" + exit 0 + ;; + [Aa]* ) + echo "" + run_all_tutorials + echo "" + ;; + [Cc]* ) + echo "" + manual_cleanup + echo "" + print_colored $BLUE "Press Enter to continue..." + read -r + ;; + * ) + if validate_tutorial_number "$choice"; then + echo "" + run_tutorial "$choice" + echo "" + print_colored $BLUE "Press Enter to continue..." + read -r + else + print_colored $RED "❌ Invalid choice: '$choice'" + print_colored $YELLOW "💡 Please enter a number between 1 and ${#TUTORIALS[@]}, 'a' for all, 'c' for cleanup, or 'q' to quit" + fi + ;; + esac + + echo "" + done +} + +# Run the main function +main "$@" \ No newline at end of file diff --git a/examples/tutorials/00_sync/000_hello_acp/.dockerignore b/examples/tutorials/00_sync/000_hello_acp/.dockerignore new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/examples/tutorials/00_sync/000_hello_acp/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/00_sync/000_hello_acp/Dockerfile b/examples/tutorials/00_sync/000_hello_acp/Dockerfile new file mode 100644 index 000000000..b91d13397 --- /dev/null +++ b/examples/tutorials/00_sync/000_hello_acp/Dockerfile @@ -0,0 +1,51 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + + +# Copy pyproject.toml and README.md to install dependencies +COPY 00_sync/000_hello_acp/pyproject.toml /app/000_hello_acp/pyproject.toml +COPY 00_sync/000_hello_acp/README.md /app/000_hello_acp/README.md + +WORKDIR /app/000_hello_acp + +# Copy the project code +COPY 00_sync/000_hello_acp/project /app/000_hello_acp/project + +# Copy the test files +COPY 00_sync/000_hello_acp/tests /app/000_hello_acp/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies +RUN uv pip install --system .[dev] + +# Set environment variables +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=000-hello-acp + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/00_sync/000_hello_acp/README.md b/examples/tutorials/00_sync/000_hello_acp/README.md new file mode 100644 index 000000000..b007cc56f --- /dev/null +++ b/examples/tutorials/00_sync/000_hello_acp/README.md @@ -0,0 +1,44 @@ +# [Sync] Hello ACP + +This is a simple AgentEx agent that just says hello and acknowledges the user's message to show which ACP methods need to be implemented for the sync ACP type. +The simplest agent type: synchronous request/response pattern with a single `@acp.on_message_send` handler. Best for stateless operations that complete immediately. + +## What You'll Learn +- Building a basic synchronous agent +- The `@acp.on_message_send` handler pattern +- When to use sync vs async agents + +## Prerequisites +- Development environment set up (see [main repo README](https://github.com/scaleapi/scale-agentex)) +- Backend services running: `make dev` from repository (agentex) root + +## Quick Start + +```bash +cd examples/tutorials/00_sync/000_hello_acp +uv run agentex agents run --manifest manifest.yaml +``` + +## Key Code + +```python +@acp.on_message_send +async def handle_message_send(params: SendMessageParams): + return TextContent( + author="agent", + content=f"Echo: {params.content.content}" + ) +``` + +That's it - one handler, immediate response. No task creation, no state management. + +## When to Use +- Simple chatbots with no memory requirements +- Quick Q&A or information lookup agents +- Prototyping and testing agent responses +- Operations that complete in under a second + +## Why This Matters +Sync agents are the simplest way to get started with AgentEx. They're perfect for learning the basics and building stateless agents. Once you need conversation memory or task tracking, you'll graduate to async agents. + +**Next:** [010_multiturn](../010_multiturn/) - Add conversation memory to your agent diff --git a/examples/tutorials/00_sync/000_hello_acp/dev.ipynb b/examples/tutorials/00_sync/000_hello_acp/dev.ipynb new file mode 100644 index 000000000..a50a29f35 --- /dev/null +++ b/examples/tutorials/00_sync/000_hello_acp/dev.ipynb @@ -0,0 +1,158 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "0", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"s000-hello-acp\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "# # (Optional) Create a new task. If you don't create a new task, each message will be sent to a new task. The server will create the task for you.\n", + "\n", + "# import uuid\n", + "\n", + "# TASK_ID = str(uuid.uuid4())[:8]\n", + "\n", + "# rpc_response = client.agents.rpc_by_name(\n", + "# agent_name=AGENT_NAME,\n", + "# method=\"task/create\",\n", + "# params={\n", + "# \"name\": f\"{TASK_ID}-task\",\n", + "# \"params\": {}\n", + "# }\n", + "# )\n", + "\n", + "# task = rpc_response.result\n", + "# print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "# Test non streaming response\n", + "from agentex.types import TextContent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_message(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": False\n", + " }\n", + ")\n", + "\n", + "if not rpc_response or not rpc_response.result:\n", + " raise ValueError(\"No result in response\")\n", + "\n", + "# Extract and print just the text content from the response\n", + "for task_message in rpc_response.result:\n", + " content = task_message.content\n", + " if isinstance(content, TextContent):\n", + " text = content.content\n", + " print(text)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "# Test streaming response\n", + "from agentex.types.text_delta import TextDelta\n", + "from agentex.types.task_message_update import StreamTaskMessageFull, StreamTaskMessageDelta\n", + "\n", + "# The result object of message/send will be a TaskMessageUpdate which is a union of the following types:\n", + "# - StreamTaskMessageStart: \n", + "# - An indicator that a streaming message was started, doesn't contain any useful content\n", + "# - StreamTaskMessageDelta: \n", + "# - A delta of a streaming message, contains the text delta to aggregate\n", + "# - StreamTaskMessageDone: \n", + "# - An indicator that a streaming message was done, doesn't contain any useful content\n", + "# - StreamTaskMessageFull: \n", + "# - A non-streaming message, there is nothing to aggregate, since this contains the full message, not deltas\n", + "\n", + "# Whenn processing StreamTaskMessageDelta, if you are expecting more than TextDeltas, such as DataDelta, ToolRequestDelta, or ToolResponseDelta, you can process them as well\n", + "# Whenn processing StreamTaskMessageFull, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "for agent_rpc_response_chunk in client.agents.send_message_stream(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": True\n", + " }\n", + "):\n", + " # We know that the result of the message/send when stream is set to True will be a TaskMessageUpdate\n", + " task_message_update = agent_rpc_response_chunk.result\n", + " # Print oly the text deltas as they arrive or any full messages\n", + " if isinstance(task_message_update, StreamTaskMessageDelta):\n", + " delta = task_message_update.delta\n", + " if isinstance(delta, TextDelta):\n", + " print(delta.text_delta, end=\"\", flush=True)\n", + " else:\n", + " print(f\"Found non-text {type(task_message)} object in streaming message.\")\n", + " elif isinstance(task_message_update, StreamTaskMessageFull):\n", + " content = task_message_update.content\n", + " if isinstance(content, TextContent):\n", + " print(content.content)\n", + " else:\n", + " print(f\"Found non-text {type(task_message)} object in full message.\")\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/tutorials/00_sync/000_hello_acp/manifest.yaml b/examples/tutorials/00_sync/000_hello_acp/manifest.yaml new file mode 100644 index 000000000..37214b06e --- /dev/null +++ b/examples/tutorials/00_sync/000_hello_acp/manifest.yaml @@ -0,0 +1,120 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../../ # Up to tutorials level to include test_utils + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - 00_sync/000_hello_acp + - test_utils + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: 00_sync/000_hello_acp/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: 00_sync/000_hello_acp/.dockerignore + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + +# Agent Configuration +# ----------------- +agent: + # Unique name for your agent + # Used for task routing and monitoring + name: s000-hello-acp + + # Type of ACP to use + # sync: Simple synchronous ACP implementation + # async: Asynchronous, non-blocking ACP implementation + acp_type: sync + + # Description of what your agent does + # Helps with documentation and discovery + description: An AgentEx agent that just says hello and acknowledges the user's message + + # Temporal workflow configuration + # Set enabled: true to use Temporal workflows for long-running tasks + temporal: + enabled: false + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + # credentials: + # - env_var_name: OPENAI_API_KEY + # secret_name: openai-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + # env: + # - name: OPENAI_BASE_URL + # value: "https://api.openai.com/v1" + # - name: ACCOUNT_ID + # value: "your_account_id_here" + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific files (deploy/*.yaml) + global: + agent: + name: "s000-hello-acp" + description: "An AgentEx agent that just says hello and acknowledges the user's message" + + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + diff --git a/examples/tutorials/00_sync/000_hello_acp/project/__init__.py b/examples/tutorials/00_sync/000_hello_acp/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/00_sync/000_hello_acp/project/acp.py b/examples/tutorials/00_sync/000_hello_acp/project/acp.py new file mode 100644 index 000000000..63346574b --- /dev/null +++ b/examples/tutorials/00_sync/000_hello_acp/project/acp.py @@ -0,0 +1,35 @@ +from typing import Union, AsyncGenerator + +from agentex.lib.types.acp import SendMessageParams +from agentex.lib.utils.logging import make_logger +from agentex.types.task_message import TaskMessageContent +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.types.task_message_update import TaskMessageUpdate +from agentex.types.task_message_content import TextContent + +logger = make_logger(__name__) + +# Create an ACP server +acp = FastACP.create( + acp_type="sync", +) + + +@acp.on_message_send +async def handle_message_send( + params: SendMessageParams, +) -> Union[TaskMessageContent, AsyncGenerator[TaskMessageUpdate, None]]: + """Default message handler with streaming support""" + # Extract content safely from the message + + message_text = "" + print(message_text, message_text) + if hasattr(params.content, "content"): + content_val = getattr(params.content, "content", "") + if isinstance(content_val, str): + message_text = content_val + + return TextContent( + author="agent", + content=f"Hello! I've received your message. Here's a generic response, but in future tutorials we'll see how you can get me to intelligently respond to your message. This is what I heard you say: {message_text}", + ) diff --git a/examples/tutorials/00_sync/000_hello_acp/pyproject.toml b/examples/tutorials/00_sync/000_hello_acp/pyproject.toml new file mode 100644 index 000000000..71110739a --- /dev/null +++ b/examples/tutorials/00_sync/000_hello_acp/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "000-hello-acp" +version = "0.1.0" +description = "An AgentEx agent that just says hello and acknowledges the user's message" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "pytest-xdist", + "httpx", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/examples/tutorials/00_sync/000_hello_acp/tests/test_agent.py b/examples/tutorials/00_sync/000_hello_acp/tests/test_agent.py new file mode 100644 index 000000000..ad82771f6 --- /dev/null +++ b/examples/tutorials/00_sync/000_hello_acp/tests/test_agent.py @@ -0,0 +1,129 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming message sending +- Streaming message sending +- Task creation via RPC + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: hello-acp) +""" + +import os + +import pytest + +from agentex import Agentex +from agentex.types import TextDelta, TextContent, TextContentParam +from agentex.types.agent_rpc_params import ParamsSendMessageRequest +from agentex.types.task_message_update import StreamTaskMessageFull, StreamTaskMessageDelta + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "s000-hello-acp") + + +@pytest.fixture +def client(): + """Create an AgentEx client instance for testing.""" + client = Agentex(base_url=AGENTEX_API_BASE_URL) + yield client + # Clean up: close the client connection + client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +class TestNonStreamingMessages: + """Test non-streaming message sending.""" + + def test_send_simple_message(self, client: Agentex, agent_name: str): + """Test sending a simple message and receiving a response.""" + + message_content = "Hello, Agent! How are you?" + response = client.agents.send_message( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content=message_content, + type="text", + ) + ), + ) + result = response.result + assert result is not None + assert len(result) == 1 + message = result[0] + assert isinstance(message.content, TextContent) + assert ( + message.content.content + == f"Hello! I've received your message. Here's a generic response, but in future tutorials we'll see how you can get me to intelligently respond to your message. This is what I heard you say: {message_content}" + ) + + +class TestStreamingMessages: + """Test streaming message sending.""" + + def test_stream_simple_message(self, client: Agentex, agent_name: str): + """Test streaming a simple message and aggregating deltas.""" + + message_content = "Hello, Agent! Can you stream your response?" + aggregated_content = "" + full_content = "" + received_chunks = False + + for chunk in client.agents.send_message_stream( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content=message_content, + type="text", + ) + ), + ): + received_chunks = True + task_message_update = chunk.result + # Collect text deltas as they arrive or check full messages + if isinstance(task_message_update, StreamTaskMessageDelta) and task_message_update.delta is not None: + delta = task_message_update.delta + if isinstance(delta, TextDelta) and delta.text_delta is not None: + aggregated_content += delta.text_delta + + elif isinstance(task_message_update, StreamTaskMessageFull): + content = task_message_update.content + if isinstance(content, TextContent): + full_content = content.content + + if not full_content and not aggregated_content: + raise AssertionError("No content was received in the streaming response.") + if not received_chunks: + raise AssertionError("No streaming chunks were received, when at least 1 was expected.") + + if full_content: + assert ( + full_content + == f"Hello! I've received your message. Here's a generic response, but in future tutorials we'll see how you can get me to intelligently respond to your message. This is what I heard you say: {message_content}" + ) + + if aggregated_content: + assert ( + aggregated_content + == f"Hello! I've received your message. Here's a generic response, but in future tutorials we'll see how you can get me to intelligently respond to your message. This is what I heard you say: {message_content}" + ) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/00_sync/010_multiturn/.dockerignore b/examples/tutorials/00_sync/010_multiturn/.dockerignore new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/examples/tutorials/00_sync/010_multiturn/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/00_sync/010_multiturn/.ipynb_checkpoints/dev-checkpoint.ipynb b/examples/tutorials/00_sync/010_multiturn/.ipynb_checkpoints/dev-checkpoint.ipynb new file mode 100644 index 000000000..d82cf5775 --- /dev/null +++ b/examples/tutorials/00_sync/010_multiturn/.ipynb_checkpoints/dev-checkpoint.ipynb @@ -0,0 +1,166 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "0", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"s010-multiturn\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "# # (Optional) Create a new task. If you don't create a new task, each message will be sent to a new task. The server will create the task for you.\n", + "\n", + "# import uuid\n", + "\n", + "# TASK_ID = str(uuid.uuid4())[:8]\n", + "\n", + "# rpc_response = client.agents.rpc_by_name(\n", + "# agent_name=AGENT_NAME,\n", + "# method=\"task/create\",\n", + "# params={\n", + "# \"name\": f\"{TASK_ID}-task\",\n", + "# \"params\": {}\n", + "# }\n", + "# )\n", + "\n", + "# task = rpc_response.result\n", + "# print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "# Test non streaming response\n", + "from agentex.types import TextContent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_message(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": False\n", + " }\n", + ")\n", + "\n", + "if not rpc_response or not rpc_response.result:\n", + " raise ValueError(\"No result in response\")\n", + "\n", + "# Extract and print just the text content from the response\n", + "for task_message in rpc_response.result:\n", + " content = task_message.content\n", + " if isinstance(content, TextContent):\n", + " text = content.content\n", + " print(text)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "# Test streaming response\n", + "from agentex.types.text_delta import TextDelta\n", + "from agentex.types.task_message_update import StreamTaskMessageFull, StreamTaskMessageDelta\n", + "\n", + "# The result object of message/send will be a TaskMessageUpdate which is a union of the following types:\n", + "# - StreamTaskMessageStart: \n", + "# - An indicator that a streaming message was started, doesn't contain any useful content\n", + "# - StreamTaskMessageDelta: \n", + "# - A delta of a streaming message, contains the text delta to aggregate\n", + "# - StreamTaskMessageDone: \n", + "# - An indicator that a streaming message was done, doesn't contain any useful content\n", + "# - StreamTaskMessageFull: \n", + "# - A non-streaming message, there is nothing to aggregate, since this contains the full message, not deltas\n", + "\n", + "# Whenn processing StreamTaskMessageDelta, if you are expecting more than TextDeltas, such as DataDelta, ToolRequestDelta, or ToolResponseDelta, you can process them as well\n", + "# Whenn processing StreamTaskMessageFull, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "for agent_rpc_response_chunk in client.agents.send_message_stream(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": True\n", + " }\n", + "):\n", + " # We know that the result of the message/send when stream is set to True will be a TaskMessageUpdate\n", + " task_message_update = agent_rpc_response_chunk.result\n", + " # Print oly the text deltas as they arrive or any full messages\n", + " if isinstance(task_message_update, StreamTaskMessageDelta):\n", + " delta = task_message_update.delta\n", + " if isinstance(delta, TextDelta):\n", + " print(delta.text_delta, end=\"\", flush=True)\n", + " else:\n", + " print(f\"Found non-text {type(task_message)} object in streaming message.\")\n", + " elif isinstance(task_message_update, StreamTaskMessageFull):\n", + " content = task_message_update.content\n", + " if isinstance(content, TextContent):\n", + " print(content.content)\n", + " else:\n", + " print(f\"Found non-text {type(task_message)} object in full message.\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/tutorials/00_sync/010_multiturn/Dockerfile b/examples/tutorials/00_sync/010_multiturn/Dockerfile new file mode 100644 index 000000000..71ccbaf53 --- /dev/null +++ b/examples/tutorials/00_sync/010_multiturn/Dockerfile @@ -0,0 +1,51 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 00_sync/010_multiturn/pyproject.toml /app/010_multiturn/pyproject.toml +COPY 00_sync/010_multiturn/README.md /app/010_multiturn/README.md + +WORKDIR /app/010_multiturn + +# Copy the project code +COPY 00_sync/010_multiturn/project /app/010_multiturn/project + +# Copy the test files +COPY 00_sync/010_multiturn/tests /app/010_multiturn/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies +RUN uv pip install --system .[dev] + +WORKDIR /app/010_multiturn +# Set environment variables +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=010-multiturn + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/00_sync/010_multiturn/README.md b/examples/tutorials/00_sync/010_multiturn/README.md new file mode 100644 index 000000000..6f585cbbe --- /dev/null +++ b/examples/tutorials/00_sync/010_multiturn/README.md @@ -0,0 +1,54 @@ +# [Sync] Multiturn + +Handle multi-turn conversations in synchronous agents by manually maintaining conversation history and context between messages. + +## What You'll Learn +- How to handle conversation history in sync agents +- Building context from previous messages +- The limitations of stateless multiturn patterns + +## Prerequisites +- Development environment set up (see [main repo README](https://github.com/scaleapi/scale-agentex)) +- Backend services running: `make dev` from repository root +- Understanding of basic sync agents (see [000_hello_acp](../000_hello_acp/)) + +## Quick Start + +```bash +cd examples/tutorials/00_sync/010_multiturn +uv run agentex agents run --manifest manifest.yaml +``` + +## Key Pattern + +Sync agents are stateless by default. To handle multi-turn conversations, you need to: +1. Accept conversation history in the request +2. Maintain context across messages +3. Return responses that build on previous exchanges + +```python +@acp.on_message_send +async def handle_message_send(params: SendMessageParams): + # Accept conversation history from client + history = params.conversation_history + + # Build context from history + context = build_context(history) + + # Generate response considering full context + response = generate_response(params.content, context) + + return TextContent(author="agent", content=response) +``` + +The handler accepts history, builds context, and returns responses that reference previous exchanges. + +## When to Use +- Simple chatbots that need conversation memory +- When client can maintain and send conversation history +- Quick prototypes before building full async agents + +## Why This Matters +While sync agents can handle conversations, you're responsible for managing state on the client side. This becomes complex quickly. For production conversational agents, consider async agents ([10_async/00_base/010_multiturn](../../10_async/00_base/010_multiturn/)) where the platform manages state automatically. + +**Next:** [020_streaming](../020_streaming/) - Stream responses in real-time diff --git a/examples/tutorials/00_sync/010_multiturn/dev.ipynb b/examples/tutorials/00_sync/010_multiturn/dev.ipynb new file mode 100644 index 000000000..c7c50532f --- /dev/null +++ b/examples/tutorials/00_sync/010_multiturn/dev.ipynb @@ -0,0 +1,166 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "0", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"s010-multiturn\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "# # (Optional) Create a new task. If you don't create a new task, each message will be sent to a new task. The server will create the task for you.\n", + "\n", + "# import uuid\n", + "\n", + "# TASK_ID = str(uuid.uuid4())[:8]\n", + "\n", + "# rpc_response = client.agents.rpc_by_name(\n", + "# agent_name=AGENT_NAME,\n", + "# method=\"task/create\",\n", + "# params={\n", + "# \"name\": f\"{TASK_ID}-task\",\n", + "# \"params\": {}\n", + "# }\n", + "# )\n", + "\n", + "# task = rpc_response.result\n", + "# print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "# Test non streaming response\n", + "from agentex.types import TextContent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_message(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": False\n", + " }\n", + ")\n", + "\n", + "if not rpc_response or not rpc_response.result:\n", + " raise ValueError(\"No result in response\")\n", + "\n", + "# Extract and print just the text content from the response\n", + "for task_message in rpc_response.result:\n", + " content = task_message.content\n", + " if isinstance(content, TextContent):\n", + " text = content.content\n", + " print(text)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "# Test streaming response\n", + "from agentex.types.text_delta import TextDelta\n", + "from agentex.types.task_message_update import StreamTaskMessageFull, StreamTaskMessageDelta\n", + "\n", + "# The result object of message/send will be a TaskMessageUpdate which is a union of the following types:\n", + "# - StreamTaskMessageStart: \n", + "# - An indicator that a streaming message was started, doesn't contain any useful content\n", + "# - StreamTaskMessageDelta: \n", + "# - A delta of a streaming message, contains the text delta to aggregate\n", + "# - StreamTaskMessageDone: \n", + "# - An indicator that a streaming message was done, doesn't contain any useful content\n", + "# - StreamTaskMessageFull: \n", + "# - A non-streaming message, there is nothing to aggregate, since this contains the full message, not deltas\n", + "\n", + "# Whenn processing StreamTaskMessageDelta, if you are expecting more than TextDeltas, such as DataDelta, ToolRequestDelta, or ToolResponseDelta, you can process them as well\n", + "# Whenn processing StreamTaskMessageFull, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "for agent_rpc_response_chunk in client.agents.send_message_stream(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": True\n", + " }\n", + "):\n", + " # We know that the result of the message/send when stream is set to True will be a TaskMessageUpdate\n", + " task_message_update = agent_rpc_response_chunk.result\n", + " # Print oly the text deltas as they arrive or any full messages\n", + " if isinstance(task_message_update, StreamTaskMessageDelta):\n", + " delta = task_message_update.delta\n", + " if isinstance(delta, TextDelta):\n", + " print(delta.text_delta, end=\"\", flush=True)\n", + " else:\n", + " print(f\"Found non-text {type(task_message)} object in streaming message.\")\n", + " elif isinstance(task_message_update, StreamTaskMessageFull):\n", + " content = task_message_update.content\n", + " if isinstance(content, TextContent):\n", + " print(content.content)\n", + " else:\n", + " print(f\"Found non-text {type(task_message)} object in full message.\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/tutorials/00_sync/010_multiturn/manifest.yaml b/examples/tutorials/00_sync/010_multiturn/manifest.yaml new file mode 100644 index 000000000..c7e094aa6 --- /dev/null +++ b/examples/tutorials/00_sync/010_multiturn/manifest.yaml @@ -0,0 +1,118 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../../ # Up to tutorials level to include test_utils + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - 00_sync/010_multiturn + - test_utils + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: 00_sync/010_multiturn/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: 00_sync/010_multiturn/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + +# Agent Configuration +# ----------------- +agent: + acp_type: sync + # Unique name for your agent + # Used for task routing and monitoring + name: s010-multiturn + + # Description of what your agent does + # Helps with documentation and discovery + description: An AgentEx agent + + # Temporal workflow configuration + # Set enabled: true to use Temporal workflows for long-running tasks + temporal: + enabled: false + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + # credentials: + # - env_var_name: OPENAI_API_KEY + # secret_name: openai-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + # env: + # - name: OPENAI_BASE_URL + # value: "https://api.openai.com/v1" + # - name: ACCOUNT_ID + # value: "your_account_id_here" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific files (deploy/*.yaml) + global: + agent: + name: "s010-multiturn" + description: "An AgentEx agent" + + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/examples/tutorials/00_sync/010_multiturn/project/__init__.py b/examples/tutorials/00_sync/010_multiturn/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/00_sync/010_multiturn/project/acp.py b/examples/tutorials/00_sync/010_multiturn/project/acp.py new file mode 100644 index 000000000..b0d2098fb --- /dev/null +++ b/examples/tutorials/00_sync/010_multiturn/project/acp.py @@ -0,0 +1,110 @@ +import os +from typing import Union, AsyncGenerator + +from agents import Agent, Runner, RunConfig + +from agentex.lib import adk +from agentex.types import TextContent +from agentex.lib.types.acp import SendMessageParams +from agentex.lib.types.converters import convert_task_messages_to_oai_agents_inputs +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.types.task_message_update import TaskMessageUpdate +from agentex.types.task_message_content import TaskMessageContent +from agentex.lib.adk.providers._modules.sync_provider import SyncStreamingProvider + +# Create an ACP server +acp = FastACP.create( + acp_type="sync", +) + + +class StateModel(BaseModel): + system_prompt: str + model: str + + +# Note: The return of this handler is required to be persisted by the Agentex Server +@acp.on_message_send +async def handle_message_send( + params: SendMessageParams, +) -> Union[TaskMessageContent, AsyncGenerator[TaskMessageUpdate, None]]: + """ + In this tutorial, we'll see how to handle a basic multi-turn conversation without streaming. + """ + ######################################################### + # 0. Validate the message. + ######################################################### + + if not hasattr(params.content, "type") or params.content.type != "text": + raise ValueError(f"Expected text message, got {getattr(params.content, 'type', 'unknown')}") + + if not hasattr(params.content, "author") or params.content.author != "user": + raise ValueError(f"Expected user message, got {getattr(params.content, 'author', 'unknown')}") + + if not os.environ.get("OPENAI_API_KEY"): + return TextContent( + author="agent", + content="Hey, sorry I'm unable to respond to your message because you're running this example without an OpenAI API key. Please set the OPENAI_API_KEY environment variable to run this example. Do this by either by adding a .env file to the project/ directory or by setting the environment variable in your terminal.", + ) + + ######################################################### + # 1. Initialize the state. Using state is optional, but it's a good way to store information between turns. + ######################################################### + + # Try to retrieve the state. If it doesn't exist, create it. + task_state = await adk.state.get_by_task_and_agent(task_id=params.task.id, agent_id=params.agent.id) + + if not task_state: + # If the state doesn't exist, create it. + state = StateModel(system_prompt="You are a helpful assistant that can answer questions.", model="gpt-4o-mini") + task_state = await adk.state.create(task_id=params.task.id, agent_id=params.agent.id, state=state) + else: + state = StateModel.model_validate(task_state.state) + + ######################################################### + # 2. Fetch our message history. + ######################################################### + + task_messages = await adk.messages.list(task_id=params.task.id) + task_messages = list(reversed(task_messages)) # API returns newest first, reverse to chronological order + + ######################################################### + # 3. Run the agent with OpenAI Agents SDK + ######################################################### + + # Initialize the provider and run config to allow for tracing + provider = SyncStreamingProvider( + trace_id=params.task.id, + ) + + run_config = RunConfig( + model_provider=provider, + ) + + # Initialize the agent + test_agent = Agent(name="assistant", instructions=state.system_prompt, model=state.model) + + # Convert task messages to OpenAI Agents SDK format + input_list = convert_task_messages_to_oai_agents_inputs(task_messages) + + # Run the agent + result = await Runner.run(test_agent, input_list, run_config=run_config) + + + # TaskMessages are messages that are sent between an Agent and a Client. They are fundamentally decoupled from messages sent to the LLM. This is because you may want to send additional metadata to allow the client to render the message on the UI differently. + + # LLMMessages are OpenAI-compatible messages that are sent to the LLM, and are used to track the state of a conversation with a model. + + # In simple scenarios your conversion logic will just look like this. However, in complex scenarios where you are leveraging the flexibility of the TaskMessage type to send non-LLM-specific metadata, you should write custom conversion logic. + + # Some complex scenarios include: + # - Taking a markdown document output by an LLM, postprocessing it into a JSON object to clearly denote title, content, and footers. This can be sent as a DataContent TaskMessage to the client and converted back to markdown here to send back to the LLM. + # - If using multiple LLMs (like in an actor-critic framework), you may want to send DataContent that denotes which LLM generated which part of the output and write conversion logic to split the TaskMessagehistory into multiple LLM conversations. + # - If using multiple LLMs, but one LLM's output should not be sent to the user (i.e. a critic model), you can leverage the State as an internal storage mechanism to store the critic model's conversation history. This i s a powerful and flexible way to handle complex scenarios. + + ######################################################### + # 4. Return the agent response to the client. + ######################################################### + + return TextContent(author="agent", content=result.final_output) diff --git a/examples/tutorials/00_sync/010_multiturn/pyproject.toml b/examples/tutorials/00_sync/010_multiturn/pyproject.toml new file mode 100644 index 000000000..d6ec48d20 --- /dev/null +++ b/examples/tutorials/00_sync/010_multiturn/pyproject.toml @@ -0,0 +1,35 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "010-multiturn" +version = "0.1.0" +description = "An AgentEx agent" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 \ No newline at end of file diff --git a/examples/tutorials/00_sync/010_multiturn/tests/test_agent.py b/examples/tutorials/00_sync/010_multiturn/tests/test_agent.py new file mode 100644 index 000000000..510e9159d --- /dev/null +++ b/examples/tutorials/00_sync/010_multiturn/tests/test_agent.py @@ -0,0 +1,172 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming message sending +- Streaming message sending +- Task creation via RPC + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: s010-multiturn) +""" + +import os + +import pytest +from test_utils.sync import validate_text_in_string, collect_streaming_response + +from agentex import Agentex +from agentex.types import TextContent, TextContentParam +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest, ParamsSendMessageRequest +from agentex.lib.sdk.fastacp.base.base_acp_server import uuid + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "s010-multiturn") + + +@pytest.fixture +def client(): + """Create an AgentEx client instance for testing.""" + return Agentex(base_url=AGENTEX_API_BASE_URL) + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest.fixture +def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingMessages: + """Test non-streaming message sending.""" + + def test_send_message(self, client: Agentex, agent_name: str, agent_id: str): + """ + Test message ordering by sending messages about distinct topics. + + This validates that the agent receives messages in chronological order. + If messages are reversed (newest first), the agent would respond about + the wrong topic. + """ + task_response = client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + + assert task is not None + + # Each message asks about a distinct topic with a required keyword in response + # This validates message ordering: if order is wrong, agent responds about wrong topic + messages_and_expected_keywords = [ + ("Tell me about tennis. You must include the word 'tennis' in your response.", "tennis"), + ("Now tell me about basketball. You must include the word 'basketball' in your response. Do not mention tennis.", "basketball"), + ("Now tell me about soccer. You must include the word 'soccer' in your response. Do not mention tennis or basketball.", "soccer"), + ] + + for i, (msg, expected_keyword) in enumerate(messages_and_expected_keywords): + response = client.agents.send_message( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content=msg, + type="text", + ), + task_id=task.id, + ), + ) + assert response is not None and response.result is not None + result = response.result + + for message in result: + content = message.content + assert content is not None + assert isinstance(content, TextContent) and isinstance(content.content, str) + # Validate response contains the expected keyword for THIS message's topic + validate_text_in_string(expected_keyword, content.content.lower()) + + states = client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + + state = states[0] + assert state.state is not None + assert state.state.get("system_prompt", None) == "You are a helpful assistant that can answer questions." + + message_history = client.messages.list( + task_id=task.id, + ) + assert len(message_history) == (i + 1) * 2 # user + agent messages + + +class TestStreamingMessages: + """Test streaming message sending.""" + + def test_stream_message(self, client: Agentex, agent_name: str, agent_id: str): + """ + Test message ordering with streaming by sending messages about distinct topics. + + This validates that the agent receives messages in chronological order. + If messages are reversed (newest first), the agent would respond about + the wrong topic. + """ + + # create a task for this specific conversation + task_response = client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + + assert task is not None + + # Each message asks about a distinct topic with a required keyword in response + # This validates message ordering: if order is wrong, agent responds about wrong topic + messages_and_expected_keywords = [ + ("Tell me about tennis. You must include the word 'tennis' in your response.", "tennis"), + ("Now tell me about basketball. You must include the word 'basketball' in your response. Do not mention tennis.", "basketball"), + ("Now tell me about soccer. You must include the word 'soccer' in your response. Do not mention tennis or basketball.", "soccer"), + ] + + for i, (msg, expected_keyword) in enumerate(messages_and_expected_keywords): + stream = client.agents.send_message_stream( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content=msg, + type="text", + ), + task_id=task.id, + ), + ) + + # Collect the streaming response + aggregated_content, chunks = collect_streaming_response(stream) + + assert len(chunks) == 1 + + # Validate response contains the expected keyword for THIS message's topic + validate_text_in_string(expected_keyword, aggregated_content.lower()) + + states = client.states.list(task_id=task.id) + assert len(states) == 1 + + message_history = client.messages.list( + task_id=task.id, + ) + assert len(message_history) == (i + 1) * 2 # user + agent messages + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/00_sync/020_streaming/.dockerignore b/examples/tutorials/00_sync/020_streaming/.dockerignore new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/examples/tutorials/00_sync/020_streaming/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/00_sync/020_streaming/Dockerfile b/examples/tutorials/00_sync/020_streaming/Dockerfile new file mode 100644 index 000000000..00137d7f9 --- /dev/null +++ b/examples/tutorials/00_sync/020_streaming/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 00_sync/020_streaming/pyproject.toml /app/020_streaming/pyproject.toml +COPY 00_sync/020_streaming/README.md /app/020_streaming/README.md + +WORKDIR /app/020_streaming + +# Copy the project code +COPY 00_sync/020_streaming/project /app/020_streaming/project + +# Copy the test files +COPY 00_sync/020_streaming/tests /app/020_streaming/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies +RUN uv pip install --system .[dev] + +# Set environment variables +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=020-streaming + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/examples/tutorials/00_sync/020_streaming/README.md b/examples/tutorials/00_sync/020_streaming/README.md new file mode 100644 index 000000000..a4f6f4765 --- /dev/null +++ b/examples/tutorials/00_sync/020_streaming/README.md @@ -0,0 +1,45 @@ +# [Sync] Streaming + +Stream responses progressively using async generators instead of returning a single message. Enables showing partial results as they're generated. + +## What You'll Learn +- How to stream responses using async generators +- The `yield` pattern for progressive updates +- When streaming improves user experience + +## Prerequisites +- Development environment set up (see [main repo README](https://github.com/scaleapi/scale-agentex)) +- Backend services running: `make dev` from repository root +- Understanding of basic sync agents (see [000_hello_acp](../000_hello_acp/)) + +## Quick Start + +```bash +cd examples/tutorials/00_sync/020_streaming +uv run agentex agents run --manifest manifest.yaml +``` + +## Key Code + +```python +@acp.on_message_send +async def handle_message_send(params: SendMessageParams): + async def stream_response(): + for chunk in response_chunks: + yield TaskMessageUpdate(content=TextContent(...)) + + return stream_response() +``` + +Return an async generator instead of a single response - each `yield` sends an update to the client. + +## When to Use +- Streaming LLM responses (OpenAI, Anthropic, etc.) +- Large data processing with progress updates +- Any operation that takes >1 second to complete +- Improving perceived responsiveness + +## Why This Matters +Streaming dramatically improves user experience for longer operations. Instead of waiting 10 seconds for a complete response, users see results immediately as they're generated. This is essential for modern AI agents. + +**Next:** Ready for task management? → [10_async/00_base/000_hello_acp](../../10_async/00_base/000_hello_acp/) diff --git a/examples/tutorials/00_sync/020_streaming/dev.ipynb b/examples/tutorials/00_sync/020_streaming/dev.ipynb new file mode 100644 index 000000000..b4e517c3f --- /dev/null +++ b/examples/tutorials/00_sync/020_streaming/dev.ipynb @@ -0,0 +1,158 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "0", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"s020-streaming\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "# # (Optional) Create a new task. If you don't create a new task, each message will be sent to a new task. The server will create the task for you.\n", + "\n", + "# import uuid\n", + "\n", + "# TASK_ID = str(uuid.uuid4())[:8]\n", + "\n", + "# rpc_response = client.agents.rpc_by_name(\n", + "# agent_name=AGENT_NAME,\n", + "# method=\"task/create\",\n", + "# params={\n", + "# \"name\": f\"{TASK_ID}-task\",\n", + "# \"params\": {}\n", + "# }\n", + "# )\n", + "\n", + "# task = rpc_response.result\n", + "# print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "# Test non streaming response\n", + "from agentex.types import TextContent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_message(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": False\n", + " }\n", + ")\n", + "\n", + "if not rpc_response or not rpc_response.result:\n", + " raise ValueError(\"No result in response\")\n", + "\n", + "# Extract and print just the text content from the response\n", + "for task_message in rpc_response.result:\n", + " content = task_message.content\n", + " if isinstance(content, TextContent):\n", + " text = content.content\n", + " print(text)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "# Test streaming response\n", + "from agentex.types.text_delta import TextDelta\n", + "from agentex.types.task_message_update import StreamTaskMessageFull, StreamTaskMessageDelta\n", + "\n", + "# The result object of message/send will be a TaskMessageUpdate which is a union of the following types:\n", + "# - StreamTaskMessageStart: \n", + "# - An indicator that a streaming message was started, doesn't contain any useful content\n", + "# - StreamTaskMessageDelta: \n", + "# - A delta of a streaming message, contains the text delta to aggregate\n", + "# - StreamTaskMessageDone: \n", + "# - An indicator that a streaming message was done, doesn't contain any useful content\n", + "# - StreamTaskMessageFull: \n", + "# - A non-streaming message, there is nothing to aggregate, since this contains the full message, not deltas\n", + "\n", + "# Whenn processing StreamTaskMessageDelta, if you are expecting more than TextDeltas, such as DataDelta, ToolRequestDelta, or ToolResponseDelta, you can process them as well\n", + "# Whenn processing StreamTaskMessageFull, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "for agent_rpc_response_chunk in client.agents.send_message_stream(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": True\n", + " }\n", + "):\n", + " # We know that the result of the message/send when stream is set to True will be a TaskMessageUpdate\n", + " task_message_update = agent_rpc_response_chunk.result\n", + " # Print oly the text deltas as they arrive or any full messages\n", + " if isinstance(task_message_update, StreamTaskMessageDelta):\n", + " delta = task_message_update.delta\n", + " if isinstance(delta, TextDelta):\n", + " print(delta.text_delta, end=\"\", flush=True)\n", + " else:\n", + " print(f\"Found non-text {type(task_message)} object in streaming message.\")\n", + " elif isinstance(task_message_update, StreamTaskMessageFull):\n", + " content = task_message_update.content\n", + " if isinstance(content, TextContent):\n", + " print(content.content)\n", + " else:\n", + " print(f\"Found non-text {type(task_message)} object in full message.\")\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/tutorials/00_sync/020_streaming/manifest.yaml b/examples/tutorials/00_sync/020_streaming/manifest.yaml new file mode 100644 index 000000000..39a04d0f8 --- /dev/null +++ b/examples/tutorials/00_sync/020_streaming/manifest.yaml @@ -0,0 +1,119 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../../ # Up to tutorials level to include test_utils + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + + include_paths: + - 00_sync/020_streaming + - test_utils + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: 00_sync/020_streaming/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: 00_sync/020_streaming/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + +# Agent Configuration +# ----------------- +agent: + acp_type: sync + # Unique name for your agent + # Used for task routing and monitoring + name: s020-streaming + + # Description of what your agent does + # Helps with documentation and discovery + description: An AgentEx agent that does multiturn streaming chat + + # Temporal workflow configuration + # Set enabled: true to use Temporal workflows for long-running tasks + temporal: + enabled: false + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + # credentials: + # - env_var_name: OPENAI_API_KEY + # secret_name: openai-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + # env: + # - name: OPENAI_BASE_URL + # value: "https://api.openai.com/v1" + # - name: ACCOUNT_ID + # value: "your_account_id_here" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific files (deploy/*.yaml) + global: + agent: + name: "s020-streaming" + description: "An AgentEx agent that does multiturn streaming chat" + + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/examples/tutorials/00_sync/020_streaming/project/__init__.py b/examples/tutorials/00_sync/020_streaming/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/00_sync/020_streaming/project/acp.py b/examples/tutorials/00_sync/020_streaming/project/acp.py new file mode 100644 index 000000000..80d1cb8bd --- /dev/null +++ b/examples/tutorials/00_sync/020_streaming/project/acp.py @@ -0,0 +1,105 @@ +import os +from typing import Union, AsyncGenerator + +from agents import Agent, Runner, RunConfig + +from agentex.lib import adk +from agentex.lib.types.acp import SendMessageParams +from agentex.types.text_content import TextContent +from agentex.lib.types.converters import convert_task_messages_to_oai_agents_inputs +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.types.task_message_update import TaskMessageUpdate, StreamTaskMessageFull +from agentex.types.task_message_content import TaskMessageContent +from agentex.lib.adk.providers._modules.sync_provider import ( + SyncStreamingProvider, + convert_openai_to_agentex_events, +) + +# Create an ACP server +acp = FastACP.create( + acp_type="sync", +) + + +class StateModel(BaseModel): + system_prompt: str + model: str + + +# Note: The return of this handler is required to be persisted by the Agentex Server +@acp.on_message_send +async def handle_message_send( + params: SendMessageParams, +) -> Union[TaskMessageContent, AsyncGenerator[TaskMessageUpdate, None]]: + """ + In this tutorial, we'll see how to handle a basic multi-turn conversation without streaming. + """ + ######################################################### + # 1-3. These steps are all the same as the hello acp tutorial. + ######################################################### + + if not params.content: + return + + if not hasattr(params.content, "type") or params.content.type != "text": + raise ValueError(f"Expected text message, got {getattr(params.content, 'type', 'unknown')}") + + if not hasattr(params.content, "author") or params.content.author != "user": + raise ValueError(f"Expected user message, got {getattr(params.content, 'author', 'unknown')}") + + if not os.environ.get("OPENAI_API_KEY"): + yield StreamTaskMessageFull( + index=0, + type="full", + content=TextContent( + author="agent", + content="Hey, sorry I'm unable to respond to your message because you're running this example without an OpenAI API key. Please set the OPENAI_API_KEY environment variable to run this example. Do this by either by adding a .env file to the project/ directory or by setting the environment variable in your terminal.", + ), + ) + return + + # Try to retrieve the state. If it doesn't exist, create it. + task_state = await adk.state.get_by_task_and_agent(task_id=params.task.id, agent_id=params.agent.id) + + if not task_state: + # If the state doesn't exist, create it. + state = StateModel(system_prompt="You are a helpful assistant that can answer questions.", model="gpt-4o-mini") + task_state = await adk.state.create(task_id=params.task.id, agent_id=params.agent.id, state=state) + else: + state = StateModel.model_validate(task_state.state) + + task_messages = await adk.messages.list(task_id=params.task.id) + task_messages = list(reversed(task_messages)) # API returns newest first, reverse to chronological order + + # Initialize the provider and run config to allow for tracing + provider = SyncStreamingProvider( + trace_id=params.task.id, + ) + + # Initialize the run config to allow for tracing and streaming + run_config = RunConfig( + model_provider=provider, + ) + + + test_agent = Agent(name="assistant", instructions=state.system_prompt, model=state.model) + + # Convert task messages to OpenAI Agents SDK format + input_list = convert_task_messages_to_oai_agents_inputs(task_messages) + + # Run the agent and stream the events + result = Runner.run_streamed(test_agent, input_list, run_config=run_config) + + + ######################################################### + # 4. Stream the events to the client. + ######################################################### + # Convert the OpenAI events to Agentex events + # This is done by converting the OpenAI events to Agentex events and yielding them to the client + stream = result.stream_events() + + # Yield the Agentex events to the client + async for agentex_event in convert_openai_to_agentex_events(stream): + yield agentex_event + diff --git a/examples/tutorials/00_sync/020_streaming/pyproject.toml b/examples/tutorials/00_sync/020_streaming/pyproject.toml new file mode 100644 index 000000000..b215db076 --- /dev/null +++ b/examples/tutorials/00_sync/020_streaming/pyproject.toml @@ -0,0 +1,35 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "020-streaming" +version = "0.1.0" +description = "An AgentEx agent that does multiturn streaming chat" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 \ No newline at end of file diff --git a/examples/tutorials/00_sync/020_streaming/tests/test_agent.py b/examples/tutorials/00_sync/020_streaming/tests/test_agent.py new file mode 100644 index 000000000..b4ff65ff5 --- /dev/null +++ b/examples/tutorials/00_sync/020_streaming/tests/test_agent.py @@ -0,0 +1,175 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming message sending +- Streaming message sending +- Task creation via RPC + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: s020-streaming) +""" + +import os + +import pytest +from test_utils.sync import validate_text_in_string, collect_streaming_response + +from agentex import Agentex +from agentex.types import TextContent, TextContentParam +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest, ParamsSendMessageRequest +from agentex.lib.sdk.fastacp.base.base_acp_server import uuid + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "s020-streaming") + + +@pytest.fixture +def client(): + """Create an AgentEx client instance for testing.""" + return Agentex(base_url=AGENTEX_API_BASE_URL) + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest.fixture +def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingMessages: + """Test non-streaming message sending.""" + + def test_send_message(self, client: Agentex, agent_name: str, agent_id: str): + """ + Test message ordering by sending messages about distinct topics. + + This validates that the agent receives messages in chronological order. + If messages are reversed (newest first), the agent would respond about + the wrong topic. + """ + task_response = client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + + assert task is not None + + # Each message asks about a distinct topic with a required keyword in response + # This validates message ordering: if order is wrong, agent responds about wrong topic + messages_and_expected_keywords = [ + ("Tell me about tennis. You must include the word 'tennis' in your response.", "tennis"), + ("Now tell me about basketball. You must include the word 'basketball' in your response. Do not mention tennis.", "basketball"), + ("Now tell me about soccer. You must include the word 'soccer' in your response. Do not mention tennis or basketball.", "soccer"), + ] + + for i, (msg, expected_keyword) in enumerate(messages_and_expected_keywords): + response = client.agents.send_message( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content=msg, + type="text", + ), + task_id=task.id, + ), + ) + assert response is not None and response.result is not None + result = response.result + + for message in result: + content = message.content + assert content is not None + assert isinstance(content, TextContent) and isinstance(content.content, str) + # Validate response contains the expected keyword for THIS message's topic + validate_text_in_string(expected_keyword, content.content.lower()) + + states = client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + + state = states[0] + assert state.state is not None + assert state.state.get("system_prompt", None) == "You are a helpful assistant that can answer questions." + message_history = client.messages.list( + task_id=task.id, + ) + assert len(message_history) == (i + 1) * 2 # user + agent messages + + +class TestStreamingMessages: + """Test streaming message sending.""" + + def test_send_stream_message(self, client: Agentex, agent_name: str, agent_id: str): + """ + Test message ordering with streaming by sending messages about distinct topics. + + This validates that the agent receives messages in chronological order. + If messages are reversed (newest first), the agent would respond about + the wrong topic. + """ + # create a task for this specific conversation + task_response = client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + + assert task is not None + + # Each message asks about a distinct topic with a required keyword in response + # This validates message ordering: if order is wrong, agent responds about wrong topic + messages_and_expected_keywords = [ + ("Tell me about tennis. You must include the word 'tennis' in your response.", "tennis"), + ("Now tell me about basketball. You must include the word 'basketball' in your response. Do not mention tennis.", "basketball"), + ("Now tell me about soccer. You must include the word 'soccer' in your response. Do not mention tennis or basketball.", "soccer"), + ] + + for i, (msg, expected_keyword) in enumerate(messages_and_expected_keywords): + stream = client.agents.send_message_stream( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content=msg, + type="text", + ), + task_id=task.id, + ), + ) + + # Collect the streaming response + aggregated_content, chunks = collect_streaming_response(stream) + + assert aggregated_content is not None + # this is using the chat_completion_stream, so we will be getting chunks of data + assert len(chunks) > 1, "No chunks received in streaming response." + + # Validate response contains the expected keyword for THIS message's topic + validate_text_in_string(expected_keyword, aggregated_content.lower()) + + states = client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + + state = states[0] + assert state.state is not None + assert state.state.get("system_prompt", None) == "You are a helpful assistant that can answer questions." + message_history = client.messages.list( + task_id=task.id, + ) + assert len(message_history) == (i + 1) * 2 # user + agent messages + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/00_sync/030_langgraph/.dockerignore b/examples/tutorials/00_sync/030_langgraph/.dockerignore new file mode 100644 index 000000000..c49489471 --- /dev/null +++ b/examples/tutorials/00_sync/030_langgraph/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/00_sync/030_langgraph/Dockerfile b/examples/tutorials/00_sync/030_langgraph/Dockerfile new file mode 100644 index 000000000..ed7172f0d --- /dev/null +++ b/examples/tutorials/00_sync/030_langgraph/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 00_sync/030_langgraph/pyproject.toml /app/030_langgraph/pyproject.toml +COPY 00_sync/030_langgraph/README.md /app/030_langgraph/README.md + +WORKDIR /app/030_langgraph + +# Copy the project code +COPY 00_sync/030_langgraph/project /app/030_langgraph/project + +# Copy the test files +COPY 00_sync/030_langgraph/tests /app/030_langgraph/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies +RUN uv pip install --system .[dev] + +# Set environment variables +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=s030-langgraph + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/00_sync/030_langgraph/README.md b/examples/tutorials/00_sync/030_langgraph/README.md new file mode 100644 index 000000000..5a68792cc --- /dev/null +++ b/examples/tutorials/00_sync/030_langgraph/README.md @@ -0,0 +1,55 @@ +# Tutorial: Sync LangGraph Agent + +This tutorial demonstrates how to build a **synchronous** LangGraph agent on AgentEx +using the **unified harness surface**: + +```python +turn = LangGraphTurn(stream, model=None) +emitter = UnifiedEmitter(task_id=task_id, trace_id=task_id, ...) +async for event in emitter.yield_turn(turn): + yield event +``` + +The `LangGraphTurn` + `UnifiedEmitter` path replaces calling the lower-level +``convert_langgraph_to_agentex_events`` helper directly. + +## Key Concepts + +### Unified Harness + +`LangGraphTurn` implements the `HarnessTurn` protocol: it wraps the raw +LangGraph `astream()` generator and exposes `events` (an async generator of +`TaskMessageUpdate`) and `usage()` (token counts captured from the final +`AIMessage`). + +`UnifiedEmitter.yield_turn(turn)` iterates the turn's events and yields them +to the sync ACP handler unchanged. The same `LangGraphTurn` object can also be +passed to `UnifiedEmitter.auto_send_turn` in the async/temporal channels. + +### AGX1-377 Note + +LangGraph emits tool requests as `StreamTaskMessageFull` events (from "updates" +node outputs). The `SpanDeriver` does not open tool spans from Full events +today; that gap is tracked in AGX1-373. + +## Files + +| File | Description | +|------|-------------| +| `project/acp.py` | ACP server using unified harness (LangGraphTurn + yield_turn) | +| `project/graph.py` | LangGraph state graph (weather example) | +| `project/tools.py` | Tool definitions (weather example) | +| `tests/test_agent.py` | Integration tests | +| `manifest.yaml` | Agent configuration (name: s030-langgraph) | + +## Running Locally + +```bash +agentex agents run +``` + +## Running Tests + +```bash +pytest tests/test_agent.py -v +``` diff --git a/examples/tutorials/00_sync/030_langgraph/manifest.yaml b/examples/tutorials/00_sync/030_langgraph/manifest.yaml new file mode 100644 index 000000000..9a52a3dce --- /dev/null +++ b/examples/tutorials/00_sync/030_langgraph/manifest.yaml @@ -0,0 +1,58 @@ +build: + context: + root: ../../ + include_paths: + - 00_sync/030_langgraph + - test_utils + dockerfile: 00_sync/030_langgraph/Dockerfile + dockerignore: 00_sync/030_langgraph/.dockerignore + +local_development: + agent: + port: 8000 + host_address: host.docker.internal + paths: + acp: project/acp.py + +agent: + acp_type: sync + name: s030-langgraph + description: A sync LangGraph agent using the unified harness surface (LangGraphTurn + UnifiedEmitter.yield_turn) + + temporal: + enabled: false + + credentials: + - env_var_name: OPENAI_API_KEY + secret_name: openai-api-key + secret_key: api-key + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + - env_var_name: SGP_ACCOUNT_ID + secret_name: sgp-account-id + secret_key: account-id + - env_var_name: SGP_CLIENT_BASE_URL + secret_name: sgp-client-base-url + secret_key: url + +deployment: + image: + repository: "" + tag: "latest" + + global: + agent: + name: "s030-langgraph" + description: "A sync LangGraph agent using the unified harness surface" + replicaCount: 1 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/00_sync/030_langgraph/project/__init__.py b/examples/tutorials/00_sync/030_langgraph/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/00_sync/030_langgraph/project/acp.py b/examples/tutorials/00_sync/030_langgraph/project/acp.py new file mode 100644 index 000000000..e42b0f4ea --- /dev/null +++ b/examples/tutorials/00_sync/030_langgraph/project/acp.py @@ -0,0 +1,107 @@ +"""ACP handler for the sync LangGraph agent. + +Uses the unified harness surface: ``LangGraphTurn`` wraps the LangGraph +``astream()`` generator, and ``UnifiedEmitter.yield_turn`` converts it into +the AgentEx ``TaskMessageUpdate`` event stream expected by the sync ACP. + +Properties of the unified surface: +- Tracing is wired through the tracing manager (no bespoke handler boilerplate). +- No manual text-delta accumulation for the span output. +- Tool calls are emitted as ``StreamTaskMessageFull`` (not Start+Delta+Done) + via the same code path as the async/temporal channels. +- Usage data (token counts) is captured on the ``LangGraphTurn`` object and + can be read after the turn completes. + +AGX1-377 note: LangGraph emits tool requests as ``StreamTaskMessageFull`` +events (from "updates"). The ``SpanDeriver`` does not open tool spans from +Full events today; that gap is tracked in AGX1-373. +""" + +from __future__ import annotations + +import os +from typing import AsyncGenerator + +from dotenv import load_dotenv + +load_dotenv() + +import agentex.lib.adk as adk +from project.graph import create_graph +from agentex.lib.types.acp import SendMessageParams +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.types.task_message_delta import TextDelta +from agentex.types.task_message_update import TaskMessageUpdate +from agentex.types.task_message_content import TaskMessageContent +from agentex.lib.adk._modules._langgraph_turn import LangGraphTurn +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config + +logger = make_logger(__name__) + +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) +) + +acp = FastACP.create(acp_type="sync") + +_graph = None + + +async def get_graph(): + """Get or create the compiled graph instance.""" + global _graph + if _graph is None: + _graph = await create_graph() + return _graph + + +@acp.on_message_send +async def handle_message_send( + params: SendMessageParams, +) -> TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]: + """Handle incoming messages, streaming tokens and tool calls via unified harness.""" + graph = await get_graph() + + task_id = params.task.id + user_message = params.content.content + + logger.info(f"Processing message for task {task_id}") + + async with adk.tracing.span( + trace_id=task_id, + task_id=task_id, + name="message", + input={"message": user_message}, + data={"__span_type__": "AGENT_WORKFLOW"}, + ) as turn_span: + stream = graph.astream( + {"messages": [{"role": "user", "content": user_message}]}, + config={"configurable": {"thread_id": task_id}}, + stream_mode=["messages", "updates"], + ) + + turn = LangGraphTurn(stream, model=None) + emitter = UnifiedEmitter( + task_id=task_id, + trace_id=task_id, + parent_span_id=turn_span.id if turn_span else None, + ) + + final_text = "" + async for event in emitter.yield_turn(turn): + # Accumulate text deltas so the span's final_output is the assistant + # text (matching the async tutorial), not the usage metrics. + delta = getattr(event, "delta", None) + if isinstance(delta, TextDelta) and delta.text_delta: + final_text += delta.text_delta + yield event + + if turn_span: + turn_span.output = {"final_output": final_text, "usage": turn.usage().model_dump()} diff --git a/examples/tutorials/00_sync/030_langgraph/project/graph.py b/examples/tutorials/00_sync/030_langgraph/project/graph.py new file mode 100644 index 000000000..6709719e5 --- /dev/null +++ b/examples/tutorials/00_sync/030_langgraph/project/graph.py @@ -0,0 +1,67 @@ +"""LangGraph graph definition for the 030_langgraph sync agent. + +Identical to ``030_langgraph/project/graph.py`` — the graph definition is not +affected by the harness migration. Only ``acp.py`` changes. +""" + +from __future__ import annotations + +from typing import Any, Annotated +from datetime import datetime +from typing_extensions import TypedDict + +from langgraph.graph import START, StateGraph +from langchain_openai import ChatOpenAI +from langgraph.prebuilt import ToolNode, tools_condition +from langchain_core.messages import SystemMessage +from langgraph.graph.message import add_messages + +from project.tools import TOOLS +from agentex.lib.adk import create_checkpointer + +MODEL_NAME = "gpt-5" +SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. + +Current date and time: {timestamp} + +Guidelines: +- Be concise and helpful +- Use tools when they would help answer the user's question +- If you're unsure, ask clarifying questions +- Always provide accurate information +""" + + +class AgentState(TypedDict): + """State schema for the agent graph.""" + + messages: Annotated[list[Any], add_messages] + + +async def create_graph(): + """Create and compile the agent graph with checkpointer.""" + llm = ChatOpenAI( + model=MODEL_NAME, + reasoning={"effort": "high", "summary": "auto"}, + ) + llm_with_tools = llm.bind_tools(TOOLS) + + checkpointer = await create_checkpointer() + + def agent_node(state: AgentState) -> dict[str, Any]: + """Process the current state and generate a response.""" + messages = state["messages"] + if not messages or not isinstance(messages[0], SystemMessage): + system_content = SYSTEM_PROMPT.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")) + messages = [SystemMessage(content=system_content)] + messages + response = llm_with_tools.invoke(messages) + return {"messages": [response]} + + builder = StateGraph(AgentState) + builder.add_node("agent", agent_node) + builder.add_node("tools", ToolNode(tools=TOOLS)) + builder.add_edge(START, "agent") + builder.add_conditional_edges("agent", tools_condition, "tools") + builder.add_edge("tools", "agent") + + return builder.compile(checkpointer=checkpointer) diff --git a/examples/tutorials/00_sync/030_langgraph/project/tools.py b/examples/tutorials/00_sync/030_langgraph/project/tools.py new file mode 100644 index 000000000..b3e5dba34 --- /dev/null +++ b/examples/tutorials/00_sync/030_langgraph/project/tools.py @@ -0,0 +1,24 @@ +"""Tool definitions for the 030_langgraph sync agent.""" + +from langchain_core.tools import Tool + + +def get_weather(city: str) -> str: + """Get the current weather for a city. + + Args: + city: The name of the city to get weather for. + + Returns: + A string describing the weather conditions. + """ + return f"The weather in {city} is sunny and 72°F" + + +weather_tool = Tool( + name="get_weather", + func=get_weather, + description="Get the current weather for a city. Input should be a city name.", +) + +TOOLS = [weather_tool] diff --git a/examples/tutorials/00_sync/030_langgraph/pyproject.toml b/examples/tutorials/00_sync/030_langgraph/pyproject.toml new file mode 100644 index 000000000..33bea16b5 --- /dev/null +++ b/examples/tutorials/00_sync/030_langgraph/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "s030-langgraph" +version = "0.1.0" +description = "A sync LangGraph agent using the unified harness surface" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "langgraph", + "langchain-openai", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/examples/tutorials/00_sync/030_langgraph/tests/test_agent.py b/examples/tutorials/00_sync/030_langgraph/tests/test_agent.py new file mode 100644 index 000000000..dabd83e76 --- /dev/null +++ b/examples/tutorials/00_sync/030_langgraph/tests/test_agent.py @@ -0,0 +1,144 @@ +""" +Tests for the sync harness LangGraph agent. + +Validates the unified harness surface (LangGraphTurn + UnifiedEmitter.yield_turn) +end-to-end against a live AgentEx server. + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: s030-langgraph) +""" + +import os + +import pytest +from test_utils.sync import validate_text_in_string, collect_streaming_response + +from agentex import Agentex +from agentex.types import TextContent, TextContentParam +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest, ParamsSendMessageRequest +from agentex.lib.sdk.fastacp.base.base_acp_server import uuid + +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "s030-langgraph") + + +@pytest.fixture +def client(): + return Agentex(base_url=AGENTEX_API_BASE_URL) + + +@pytest.fixture +def agent_name(): + return AGENT_NAME + + +@pytest.fixture +def agent_id(client, agent_name): + agents = client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingMessages: + def test_send_simple_message(self, client: Agentex, agent_name: str): + response = client.agents.send_message( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content="Hello! What can you help me with?", + type="text", + ) + ), + ) + result = response.result + assert result is not None + assert len(result) >= 1 + + def test_tool_calling(self, client: Agentex, agent_name: str): + response = client.agents.send_message( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content="What's the weather in San Francisco?", + type="text", + ) + ), + ) + result = response.result + assert result is not None + assert len(result) >= 1 + + def test_multiturn_conversation(self, client: Agentex, agent_name: str, agent_id: str): + task_response = client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + response1 = client.agents.send_message( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content="My name is Alice. Remember that.", + type="text", + ), + task_id=task.id, + ), + ) + assert response1.result is not None + + response2 = client.agents.send_message( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content="What is my name?", + type="text", + ), + task_id=task.id, + ), + ) + assert response2.result is not None + for message in response2.result: + if isinstance(message.content, TextContent): + validate_text_in_string("alice", message.content.content.lower()) + + +class TestStreamingMessages: + def test_stream_simple_message(self, client: Agentex, agent_name: str): + stream = client.agents.send_message_stream( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content="Tell me a short joke.", + type="text", + ) + ), + ) + aggregated_content, chunks = collect_streaming_response(stream) + assert aggregated_content is not None + assert len(chunks) > 1, "No chunks received in streaming response." + + def test_stream_tool_calling(self, client: Agentex, agent_name: str): + stream = client.agents.send_message_stream( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content="What's the weather in New York?", + type="text", + ) + ), + ) + aggregated_content, chunks = collect_streaming_response(stream) + assert aggregated_content is not None + assert len(chunks) > 0, "No chunks received in streaming response." + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/00_sync/040_pydantic_ai/.dockerignore b/examples/tutorials/00_sync/040_pydantic_ai/.dockerignore new file mode 100644 index 000000000..c49489471 --- /dev/null +++ b/examples/tutorials/00_sync/040_pydantic_ai/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/00_sync/040_pydantic_ai/Dockerfile b/examples/tutorials/00_sync/040_pydantic_ai/Dockerfile new file mode 100644 index 000000000..ba2f17d19 --- /dev/null +++ b/examples/tutorials/00_sync/040_pydantic_ai/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 00_sync/040_pydantic_ai/pyproject.toml /app/040_pydantic_ai/pyproject.toml +COPY 00_sync/040_pydantic_ai/README.md /app/040_pydantic_ai/README.md + +WORKDIR /app/040_pydantic_ai + +# Copy the project code +COPY 00_sync/040_pydantic_ai/project /app/040_pydantic_ai/project + +# Copy the test files +COPY 00_sync/040_pydantic_ai/tests /app/040_pydantic_ai/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies +RUN uv pip install --system .[dev] + +# Set environment variables +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=s040-pydantic-ai + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/00_sync/040_pydantic_ai/README.md b/examples/tutorials/00_sync/040_pydantic_ai/README.md new file mode 100644 index 000000000..ef52c7c77 --- /dev/null +++ b/examples/tutorials/00_sync/040_pydantic_ai/README.md @@ -0,0 +1,52 @@ +# Sync Pydantic AI Agent + +A minimal **synchronous** Pydantic AI agent that drives the **unified harness +surface** (`UnifiedEmitter.yield_turn` + `PydanticAITurn`) on the sync +(HTTP-yield) channel. + +## Why this agent exists + +This agent is the sync coverage for the unified surface: it shows an agent +author wiring the sync channel through `UnifiedEmitter.yield_turn` and getting +automatic span derivation (tool spans nested under the per-turn span) for free, +exactly like the async/temporal channels. + +## How it wires the unified surface + +In `project/acp.py`: + +```python +emitter = UnifiedEmitter( + task_id=task_id, + trace_id=task_id, + parent_span_id=turn_span.id if turn_span else None, +) +async with agent.run_stream_events(user_message) as stream: + turn = PydanticAITurn(stream, model=MODEL_NAME) # coalesce off: stream tool-call arg tokens + async for ev in emitter.yield_turn(turn): + yield ev +``` + +- `coalesce_tool_requests=False` (the default) preserves token-by-token + tool-call argument streaming on the sync channel. +- The `UnifiedEmitter` is constructed from the ACP/streaming context + (`task_id` + `trace_id` + `parent_span_id`) so tool spans nest under the + per-turn `AGENT_WORKFLOW` span automatically. + +## Files + +- `project/acp.py` — sync ACP handler using `emitter.yield_turn(...)`. +- `project/agent.py` — builds the `pydantic_ai.Agent` with one tool. +- `project/tools.py` — `get_weather(city)` returning a constant. +- `tests/test_agent.py` — live integration test (requires a running agent). + +## Tools + +- `get_weather(city: str) -> str`: returns a fixed "sunny and 72°F" string so a + run deterministically exercises text + a tool call + a tool response. + +## Offline coverage + +Offline integration tests for the same wiring (pydantic-ai `TestModel` + fake +streaming/tracing, no network) live in the SDK repo under +`tests/lib/core/harness/` (the pydantic-ai sync suite). diff --git a/examples/tutorials/00_sync/040_pydantic_ai/manifest.yaml b/examples/tutorials/00_sync/040_pydantic_ai/manifest.yaml new file mode 100644 index 000000000..9563de39c --- /dev/null +++ b/examples/tutorials/00_sync/040_pydantic_ai/manifest.yaml @@ -0,0 +1,58 @@ +build: + context: + root: ../../ + include_paths: + - 00_sync/040_pydantic_ai + - test_utils + dockerfile: 00_sync/040_pydantic_ai/Dockerfile + dockerignore: 00_sync/040_pydantic_ai/.dockerignore + +local_development: + agent: + port: 8000 + host_address: host.docker.internal + paths: + acp: project/acp.py + +agent: + acp_type: sync + name: s040-pydantic-ai + description: A sync Pydantic AI harness test agent using the unified emitter surface + + temporal: + enabled: false + + credentials: + - env_var_name: OPENAI_API_KEY + secret_name: openai-api-key + secret_key: api-key + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + - env_var_name: SGP_ACCOUNT_ID + secret_name: sgp-account-id + secret_key: account-id + - env_var_name: SGP_CLIENT_BASE_URL + secret_name: sgp-client-base-url + secret_key: url + +deployment: + image: + repository: "" + tag: "latest" + + global: + agent: + name: "s040-pydantic-ai" + description: "A sync Pydantic AI harness test agent using the unified emitter surface" + replicaCount: 1 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/00_sync/040_pydantic_ai/project/__init__.py b/examples/tutorials/00_sync/040_pydantic_ai/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/00_sync/040_pydantic_ai/project/acp.py b/examples/tutorials/00_sync/040_pydantic_ai/project/acp.py new file mode 100644 index 000000000..f23cd7960 --- /dev/null +++ b/examples/tutorials/00_sync/040_pydantic_ai/project/acp.py @@ -0,0 +1,92 @@ +"""ACP handler for the sync harness Pydantic AI test agent. + +This agent exercises the UNIFIED HARNESS SURFACE on the sync (HTTP-yield) +channel — ``UnifiedEmitter.yield_turn(PydanticAITurn(...))`` — rather than the +bare ``convert_pydantic_ai_to_agentex_events`` converter used by the +``040_pydantic_ai`` tutorial. The unified surface gives the sync channel the +same tracing (span derivation) the async/temporal channels get for free. + +Flow: +1. Open a per-turn AGENT_WORKFLOW span via ``adk.tracing.span``. +2. Construct a ``UnifiedEmitter`` from the ACP/streaming context (task_id + + trace_id + parent_span_id) so tool spans nest under the turn span. +3. Wrap ``agent.run_stream_events(...)`` in a ``PydanticAITurn`` and forward + events with ``emitter.yield_turn(turn)`` — yielding each to the client. +""" + +from __future__ import annotations + +import os +from typing import AsyncGenerator + +from dotenv import load_dotenv + +load_dotenv() + +import agentex.lib.adk as adk +from project.agent import MODEL_NAME, create_agent +from agentex.lib.types.acp import SendMessageParams +from agentex.lib.core.harness import UnifiedEmitter +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.types.task_message_update import TaskMessageUpdate +from agentex.types.task_message_content import TaskMessageContent +from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config + +logger = make_logger(__name__) + +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) +) + +acp = FastACP.create(acp_type="sync") + +_agent = None + + +def get_agent(): + """Get or create the Pydantic AI agent instance.""" + global _agent + if _agent is None: + _agent = create_agent() + return _agent + + +@acp.on_message_send +async def handle_message_send( + params: SendMessageParams, +) -> TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]: + """Handle incoming messages, streaming events through the unified surface.""" + agent = get_agent() + task_id = params.task.id + + user_message = params.content.content + logger.info(f"Processing message for task {task_id}") + + async with adk.tracing.span( + trace_id=task_id, + task_id=task_id, + name="message", + input={"message": user_message}, + data={"__span_type__": "AGENT_WORKFLOW"}, + ) as turn_span: + # Construct the UnifiedEmitter from the ACP/streaming context so tracing + # is automatic: tool spans nest under this turn's span. + emitter = UnifiedEmitter( + task_id=task_id, + trace_id=task_id, + parent_span_id=turn_span.id if turn_span else None, + ) + + async with agent.run_stream_events(user_message) as stream: + # PydanticAITurn preserves token-by-token tool-call argument + # streaming (Start+Delta+Done) on the sync/HTTP channel. + turn = PydanticAITurn(stream, model=MODEL_NAME) + async for ev in emitter.yield_turn(turn): + yield ev diff --git a/examples/tutorials/00_sync/040_pydantic_ai/project/agent.py b/examples/tutorials/00_sync/040_pydantic_ai/project/agent.py new file mode 100644 index 000000000..72fd74173 --- /dev/null +++ b/examples/tutorials/00_sync/040_pydantic_ai/project/agent.py @@ -0,0 +1,39 @@ +"""Pydantic AI agent definition for the sync harness test agent. + +The Agent is the boundary between this module and the API layer (acp.py). +Pydantic AI handles its own tool-call loop internally — no graph required. +""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic_ai import Agent + +from project.tools import get_weather + +__all__ = ["create_agent", "MODEL_NAME"] + +MODEL_NAME = "openai:gpt-4o-mini" +SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. + +Current date and time: {timestamp} + +Guidelines: +- Be concise and helpful +- Use tools when they would help answer the user's question +- If you're unsure, ask clarifying questions +- Always provide accurate information +""" + + +def create_agent() -> Agent: + """Build and return the Pydantic AI agent with tools registered.""" + agent = Agent( + MODEL_NAME, + system_prompt=SYSTEM_PROMPT.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + ) + + agent.tool_plain(get_weather) + + return agent diff --git a/examples/tutorials/00_sync/040_pydantic_ai/project/tools.py b/examples/tutorials/00_sync/040_pydantic_ai/project/tools.py new file mode 100644 index 000000000..d649c75f1 --- /dev/null +++ b/examples/tutorials/00_sync/040_pydantic_ai/project/tools.py @@ -0,0 +1,20 @@ +"""Tool definitions for the sync harness Pydantic AI agent. + +Pydantic AI tools are registered directly on the Agent via decorators +(see project.agent). This module hosts the bare function so it is easy to +unit-test in isolation. +""" + +from __future__ import annotations + + +def get_weather(city: str) -> str: + """Get the current weather for a city. + + Args: + city: The name of the city to get weather for. + + Returns: + A string describing the weather conditions. + """ + return f"The weather in {city} is sunny and 72°F" diff --git a/examples/tutorials/00_sync/040_pydantic_ai/pyproject.toml b/examples/tutorials/00_sync/040_pydantic_ai/pyproject.toml new file mode 100644 index 000000000..748a9f3cb --- /dev/null +++ b/examples/tutorials/00_sync/040_pydantic_ai/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "s040-pydantic-ai" +version = "0.1.0" +description = "A sync Pydantic AI harness test agent using the unified emitter surface" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "pydantic-ai-slim[openai]>=1.0,<2", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/examples/tutorials/00_sync/040_pydantic_ai/tests/test_agent.py b/examples/tutorials/00_sync/040_pydantic_ai/tests/test_agent.py new file mode 100644 index 000000000..4aad12a56 --- /dev/null +++ b/examples/tutorials/00_sync/040_pydantic_ai/tests/test_agent.py @@ -0,0 +1,137 @@ +"""Live tests for the sync Pydantic AI agent. + +These tests require a running agent (server + deployed agent) and exercise the +unified-surface sync handler end-to-end over the wire. + +Offline coverage of the same wiring (TestModel + fake streaming/tracing) lives +in the SDK repo under ``tests/lib/core/harness/`` (the pydantic-ai sync suite). + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: s040-pydantic-ai) +""" + +import os + +import pytest +from test_utils.sync import validate_text_in_string, collect_streaming_response + +from agentex import Agentex +from agentex.types import TextContentParam +from agentex.types.agent_rpc_params import ParamsSendMessageRequest + +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "s040-pydantic-ai") + + +@pytest.fixture +def client(): + """Create an AgentEx client instance for testing.""" + return Agentex(base_url=AGENTEX_API_BASE_URL) + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest.fixture +def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingMessages: + """Test non-streaming message sending with the unified-surface sync agent.""" + + def test_send_simple_message(self, client: Agentex, agent_name: str): + """Test sending a simple message and receiving a response.""" + response = client.agents.send_message( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content="Hello! What can you help me with?", + type="text", + ) + ), + ) + result = response.result + assert result is not None + assert len(result) >= 1 + + def test_tool_calling(self, client: Agentex, agent_name: str): + """Test that the agent can use tools (e.g., weather tool).""" + response = client.agents.send_message( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content="What's the weather in San Francisco?", + type="text", + ) + ), + ) + result = response.result + assert result is not None + assert len(result) >= 1 + + +class TestStreamingMessages: + """Test streaming message sending through the unified yield_turn path.""" + + def test_stream_simple_message(self, client: Agentex, agent_name: str): + """Test streaming a simple message response.""" + stream = client.agents.send_message_stream( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content="Tell me a short joke.", + type="text", + ) + ), + ) + + aggregated_content, chunks = collect_streaming_response(stream) + + assert aggregated_content is not None + assert len(chunks) > 1, "No chunks received in streaming response." + + def test_stream_tool_calling(self, client: Agentex, agent_name: str): + """Test streaming with tool calls through the unified surface. + + Exercises token-by-token tool-call argument streaming (coalesce off), + which the unified yield_turn path preserves on the sync channel. + """ + stream = client.agents.send_message_stream( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content="What's the weather in New York? Respond with the temperature.", + type="text", + ) + ), + ) + + aggregated_content, chunks = collect_streaming_response(stream) + + assert aggregated_content is not None + assert len(chunks) > 0, "No chunks received in streaming response." + # The weather tool always returns "72°F", so the agent's reply should mention it. + validate_text_in_string("72", aggregated_content) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/00_sync/050_openai_agents/.dockerignore b/examples/tutorials/00_sync/050_openai_agents/.dockerignore new file mode 100644 index 000000000..c49489471 --- /dev/null +++ b/examples/tutorials/00_sync/050_openai_agents/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/00_sync/050_openai_agents/Dockerfile b/examples/tutorials/00_sync/050_openai_agents/Dockerfile new file mode 100644 index 000000000..c9ccd6f54 --- /dev/null +++ b/examples/tutorials/00_sync/050_openai_agents/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 00_sync/050_openai_agents/pyproject.toml /app/050_openai_agents/pyproject.toml +COPY 00_sync/050_openai_agents/README.md /app/050_openai_agents/README.md + +WORKDIR /app/050_openai_agents + +# Copy the project code +COPY 00_sync/050_openai_agents/project /app/050_openai_agents/project + +# Copy the test files +COPY 00_sync/050_openai_agents/tests /app/050_openai_agents/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies +RUN uv pip install --system .[dev] + +# Set environment variables +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=s050-openai-agents + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/00_sync/050_openai_agents/README.md b/examples/tutorials/00_sync/050_openai_agents/README.md new file mode 100644 index 000000000..98cec3f9a --- /dev/null +++ b/examples/tutorials/00_sync/050_openai_agents/README.md @@ -0,0 +1,35 @@ +# Sync OpenAI Agents on the unified harness surface + +A sync (HTTP) Agentex agent that runs the OpenAI Agents SDK and delivers its +output through the **unified harness surface**. + +## What this demonstrates + +The OpenAI Agents SDK produces native streaming events. This tutorial wraps a +`Runner.run_streamed` result in an `OpenAITurn` — the provider -> canonical +`StreamTaskMessage*` adapter — and forwards the canonical stream to the frontend +via `UnifiedEmitter.yield_turn`. The same `OpenAITurn` flows unchanged through +`auto_send_turn` in the async (`10_async/00_base/120_openai_agents`) and temporal +(`10_async/10_temporal/120_openai_agents`) variants; only the delivery method differs. + +```python +result = Runner.run_streamed(starting_agent=agent, input=user_message) +turn = OpenAITurn(result=result, model="gpt-4o") +emitter = UnifiedEmitter(task_id=task_id, trace_id=task_id, parent_span_id=parent_span_id) +async for event in emitter.yield_turn(turn): + yield event +``` + +## Run it + +```bash +agentex agents run --manifest manifest.yaml +``` + +## Test it + +The offline test exercises the harness wiring without a server or API key: + +```bash +pytest tests/test_agent.py -v +``` diff --git a/examples/tutorials/00_sync/050_openai_agents/manifest.yaml b/examples/tutorials/00_sync/050_openai_agents/manifest.yaml new file mode 100644 index 000000000..bdb47e8d8 --- /dev/null +++ b/examples/tutorials/00_sync/050_openai_agents/manifest.yaml @@ -0,0 +1,58 @@ +build: + context: + root: ../../ + include_paths: + - 00_sync/050_openai_agents + - test_utils + dockerfile: 00_sync/050_openai_agents/Dockerfile + dockerignore: 00_sync/050_openai_agents/.dockerignore + +local_development: + agent: + port: 8000 + host_address: host.docker.internal + paths: + acp: project/acp.py + +agent: + acp_type: sync + name: s050-openai-agents + description: A sync OpenAI Agents SDK agent on the unified harness surface + + temporal: + enabled: false + + credentials: + - env_var_name: OPENAI_API_KEY + secret_name: openai-api-key + secret_key: api-key + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + - env_var_name: SGP_ACCOUNT_ID + secret_name: sgp-account-id + secret_key: account-id + - env_var_name: SGP_CLIENT_BASE_URL + secret_name: sgp-client-base-url + secret_key: url + +deployment: + image: + repository: "" + tag: "latest" + + global: + agent: + name: "s050-openai-agents" + description: "A sync OpenAI Agents SDK agent on the unified harness surface" + replicaCount: 1 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/00_sync/050_openai_agents/project/__init__.py b/examples/tutorials/00_sync/050_openai_agents/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/00_sync/050_openai_agents/project/acp.py b/examples/tutorials/00_sync/050_openai_agents/project/acp.py new file mode 100644 index 000000000..caaa0b132 --- /dev/null +++ b/examples/tutorials/00_sync/050_openai_agents/project/acp.py @@ -0,0 +1,87 @@ +"""ACP handler for the sync OpenAI Agents harness tutorial. + +This is the API layer. It runs the OpenAI Agents SDK via ``Runner.run_streamed``, +wraps the streamed run in an ``OpenAITurn`` (the provider -> canonical +``StreamTaskMessage*`` adapter), and forwards the canonical stream to the +Agentex frontend via ``UnifiedEmitter.yield_turn`` — the same harness surface +used by the async and temporal variants of this tutorial. +""" + +from __future__ import annotations + +import os +from typing import AsyncGenerator + +from dotenv import load_dotenv + +load_dotenv() + +from agents import Runner + +from agentex.lib import adk +from project.agent import MODEL_NAME, create_agent +from agentex.lib.types.acp import SendMessageParams +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.types.task_message_update import TaskMessageUpdate +from agentex.types.task_message_content import TaskMessageContent +from agentex.lib.adk.providers._modules.openai_turn import OpenAITurn +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config + +logger = make_logger(__name__) + +# LiteLLM proxy auth: copy LITELLM_API_KEY to OPENAI_API_KEY for OpenAI client +# compatibility, so the same example works behind the Scale LiteLLM gateway. +_litellm_key = os.environ.get("LITELLM_API_KEY") +if _litellm_key and not os.environ.get("OPENAI_API_KEY"): + os.environ["OPENAI_API_KEY"] = _litellm_key + +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) +) + +acp = FastACP.create(acp_type="sync") + +_agent = None + + +def get_agent(): + """Get or create the OpenAI Agents SDK agent instance.""" + global _agent + if _agent is None: + _agent = create_agent() + return _agent + + +@acp.on_message_send +async def handle_message_send( + params: SendMessageParams, +) -> TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]: + """Handle incoming messages, streaming tokens and tool calls via the harness.""" + agent = get_agent() + task_id = params.task.id + user_message = params.content.content + logger.info(f"Processing message for task {task_id}") + + async with adk.tracing.span( + trace_id=task_id, + task_id=task_id, + name="message", + input={"message": user_message}, + data={"__span_type__": "AGENT_WORKFLOW"}, + ) as turn_span: + result = Runner.run_streamed(starting_agent=agent, input=user_message) + turn = OpenAITurn(result=result, model=MODEL_NAME) + emitter = UnifiedEmitter( + task_id=task_id, + trace_id=task_id, + parent_span_id=turn_span.id if turn_span else None, + ) + async for event in emitter.yield_turn(turn): + yield event diff --git a/examples/tutorials/00_sync/050_openai_agents/project/agent.py b/examples/tutorials/00_sync/050_openai_agents/project/agent.py new file mode 100644 index 000000000..3611012fe --- /dev/null +++ b/examples/tutorials/00_sync/050_openai_agents/project/agent.py @@ -0,0 +1,47 @@ +"""OpenAI Agents SDK agent definition for the harness tutorial. + +The agent is the boundary between this module and the API layer (acp.py). +The OpenAI Agents SDK runs its own tool-call loop internally; acp.py wraps a +``Runner.run_streamed`` result with ``OpenAITurn`` so it flows through the +unified harness surface. +""" + +from __future__ import annotations + +from datetime import datetime + +from agents import Agent, function_tool, set_tracing_disabled + +from project.tools import get_weather + +# Disable the openai-agents SDK's native tracer so it doesn't ship traces to +# api.openai.com (the key may be a gateway/proxy key). Agentex tracing still +# runs via the harness + tracing manager configured in acp.py. +set_tracing_disabled(True) + +MODEL_NAME = "gpt-4o" +INSTRUCTIONS = """You are a helpful AI assistant with access to tools. + +Current date and time: {timestamp} + +Guidelines: +- Be concise and helpful +- Use the weather tool when the user asks about the weather +- Always report the real tool output back to the user +""" + + +@function_tool +def weather(city: str) -> str: + """Get the current weather for a city.""" + return get_weather(city) + + +def create_agent() -> Agent: + """Build and return the OpenAI Agents SDK agent with the weather tool.""" + return Agent( + name="Harness OpenAI Assistant", + model=MODEL_NAME, + instructions=INSTRUCTIONS.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + tools=[weather], + ) diff --git a/examples/tutorials/00_sync/050_openai_agents/project/tools.py b/examples/tutorials/00_sync/050_openai_agents/project/tools.py new file mode 100644 index 000000000..b03aa7c31 --- /dev/null +++ b/examples/tutorials/00_sync/050_openai_agents/project/tools.py @@ -0,0 +1,19 @@ +"""Tool definitions for the OpenAI Agents harness tutorial. + +The bare function lives here so it's easy to unit-test; it's wrapped as an +OpenAI Agents SDK ``function_tool`` in ``project.agent``. +""" + +from __future__ import annotations + + +def get_weather(city: str) -> str: + """Get the current weather for a city. + + Args: + city: The name of the city to get weather for. + + Returns: + A string describing the weather conditions. + """ + return f"The weather in {city} is sunny and 72°F" diff --git a/examples/tutorials/00_sync/050_openai_agents/pyproject.toml b/examples/tutorials/00_sync/050_openai_agents/pyproject.toml new file mode 100644 index 000000000..48d2481dd --- /dev/null +++ b/examples/tutorials/00_sync/050_openai_agents/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "s050-openai-agents" +version = "0.1.0" +description = "A sync OpenAI Agents SDK agent on the unified harness surface" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "openai-agents", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/examples/tutorials/00_sync/050_openai_agents/tests/test_agent.py b/examples/tutorials/00_sync/050_openai_agents/tests/test_agent.py new file mode 100644 index 000000000..960b232b7 --- /dev/null +++ b/examples/tutorials/00_sync/050_openai_agents/tests/test_agent.py @@ -0,0 +1,48 @@ +"""Offline test for the sync OpenAI Agents harness tutorial. + +This test does NOT require a running Agentex server or an OpenAI API key. It +verifies the harness wiring this tutorial demonstrates: an ``OpenAITurn`` built +from an injected canonical ``StreamTaskMessage*`` stream, forwarded through +``UnifiedEmitter.yield_turn`` (the sync HTTP ACP delivery path), passes the +events through unchanged. + +To run: ``pytest tests/test_agent.py -v`` +""" + +from __future__ import annotations + +import pytest + +from agentex.types.text_content import TextContent +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.types.task_message_delta import TextDelta +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.lib.adk.providers._modules.openai_turn import OpenAITurn + + +async def _canonical_stream(events): + for e in events: + yield e + + +@pytest.mark.asyncio +async def test_yield_turn_forwards_canonical_stream(): + events = [ + StreamTaskMessageStart(type="start", index=0, content=TextContent(type="text", author="agent", content="")), + StreamTaskMessageDelta(type="delta", index=0, delta=TextDelta(type="text", text_delta="Hi")), + StreamTaskMessageDone(type="done", index=0), + ] + turn = OpenAITurn(stream=_canonical_stream(events), model="gpt-4o") + # trace_id=None disables tracing, so no Agentex server is needed. + emitter = UnifiedEmitter(task_id="task-1", trace_id=None, parent_span_id=None) + + out = [e async for e in emitter.yield_turn(turn)] + assert out == events + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/00_sync/060_claude_code/.dockerignore b/examples/tutorials/00_sync/060_claude_code/.dockerignore new file mode 100644 index 000000000..c49489471 --- /dev/null +++ b/examples/tutorials/00_sync/060_claude_code/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/00_sync/060_claude_code/Dockerfile b/examples/tutorials/00_sync/060_claude_code/Dockerfile new file mode 100644 index 000000000..ec22d7e0b --- /dev/null +++ b/examples/tutorials/00_sync/060_claude_code/Dockerfile @@ -0,0 +1,46 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies including Node.js (required by the claude CLI) +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +# Install the claude CLI (requires Node.js) +# NOTE: live runs require ANTHROPIC_API_KEY in the environment. +RUN npm install -g @anthropic-ai/claude-code || true + +ENV UV_HTTP_TIMEOUT=1000 + +COPY 00_sync/060_claude_code/pyproject.toml /app/060_claude_code/pyproject.toml +COPY 00_sync/060_claude_code/README.md /app/060_claude_code/README.md + +WORKDIR /app/060_claude_code + +COPY 00_sync/060_claude_code/project /app/060_claude_code/project +COPY 00_sync/060_claude_code/tests /app/060_claude_code/tests +COPY test_utils /app/test_utils + +RUN uv pip install --system .[dev] + +ENV PYTHONPATH=/app + +ENV AGENT_NAME=s060-claude-code + +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/00_sync/060_claude_code/README.md b/examples/tutorials/00_sync/060_claude_code/README.md new file mode 100644 index 000000000..e9c724732 --- /dev/null +++ b/examples/tutorials/00_sync/060_claude_code/README.md @@ -0,0 +1,76 @@ +# Tutorial 060: Sync Claude Code Agent + +This tutorial demonstrates how to build a **synchronous** agent that spawns the +Claude Code CLI as a local subprocess and streams its output through the Agentex +unified harness surface via ``ClaudeCodeTurn`` and ``UnifiedEmitter``. + +## Key Concepts + +### ClaudeCodeTurn + UnifiedEmitter + +``ClaudeCodeTurn`` wraps ``convert_claude_code_to_agentex_events``, which +parses the newline-delimited JSON envelopes emitted by +``claude -p --output-format stream-json``. It implements the ``HarnessTurn`` +protocol: an ``events`` async iterator of canonical ``StreamTaskMessage*`` +objects and a ``usage()`` method (populated once the stream is exhausted). + +``UnifiedEmitter.yield_turn(turn)`` is the sync delivery path: it forwards +events as HTTP yield chunks while tracing as a side effect. + +### Local subprocess spawn + +The ``_spawn_claude`` function in ``project/acp.py`` uses +``asyncio.create_subprocess_exec`` to run: + +``` +claude -p --output-format stream-json --verbose +``` + +The prompt is written to stdin. Stdout is read line by line and fed into +``ClaudeCodeTurn``. This is purely local -- no Scale sandbox is involved. + +Production isolation (Scale sandbox, secret injection, MCP configuration) +is the golden agent's concern at +``teams/sgp/agents/golden_agent/project/harness/providers/claude.py``. + +### Injectable spawn seam + +``_spawn_claude`` is a top-level async generator in ``project/acp.py``. +Tests monkeypatch it to inject pre-recorded stream-json lines instead of +spawning the real process, so offline unit tests run without the CLI. + +## Files + +| File | Description | +|------|-------------| +| ``project/acp.py`` | ACP server, ``_spawn_claude`` seam, and message handler | +| ``tests/test_agent.py`` | Live integration tests (needs CLI + API key) | +| ``tests/test_agent_offline.py`` | Offline unit tests with injected fake subprocess | +| ``manifest.yaml`` | Agent configuration | + +## Running Locally (live) + +Requires the ``claude`` CLI installed and ``ANTHROPIC_API_KEY`` set: + +```bash +npm install -g @anthropic-ai/claude-code +export ANTHROPIC_API_KEY=sk-ant-... +agentex agents run +``` + +## Running Offline Tests + +No CLI or API key needed: + +```bash +uv run pytest tests/test_agent_offline.py -v +``` + +## Notes + +- Production isolation (sandbox, secrets, MCP) is the golden agent's concern. + This tutorial runs the CLI directly to keep the code as simple as possible. +- Multi-turn session resumption (``claude -r ``) is out of scope + for this tutorial. See the golden agent for that pattern. +- The ``--verbose`` flag is included to match the golden agent's invocation; + it causes the CLI to emit ``stream_event`` triples for incremental streaming. diff --git a/examples/tutorials/00_sync/060_claude_code/manifest.yaml b/examples/tutorials/00_sync/060_claude_code/manifest.yaml new file mode 100644 index 000000000..56b9fd9e4 --- /dev/null +++ b/examples/tutorials/00_sync/060_claude_code/manifest.yaml @@ -0,0 +1,55 @@ +build: + context: + root: ../../ + include_paths: + - 00_sync/060_claude_code + - test_utils + dockerfile: 00_sync/060_claude_code/Dockerfile + dockerignore: 00_sync/060_claude_code/.dockerignore + +local_development: + agent: + port: 8000 + host_address: host.docker.internal + paths: + acp: project/acp.py + +agent: + acp_type: sync + name: s060-claude-code + description: A sync Claude Code agent streaming the unified harness surface via a local CLI subprocess + + temporal: + enabled: false + + credentials: + - env_var_name: ANTHROPIC_API_KEY + secret_name: anthropic-api-key + secret_key: api-key + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + - env_var_name: SGP_ACCOUNT_ID + secret_name: sgp-account-id + secret_key: account-id + - env_var_name: SGP_CLIENT_BASE_URL + secret_name: sgp-client-base-url + secret_key: url + +deployment: + image: + repository: "" + tag: "latest" + + global: + agent: + name: "s060-claude-code" + description: "A sync Claude Code agent streaming via local CLI subprocess" + replicaCount: 1 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/00_sync/060_claude_code/project/__init__.py b/examples/tutorials/00_sync/060_claude_code/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/00_sync/060_claude_code/project/acp.py b/examples/tutorials/00_sync/060_claude_code/project/acp.py new file mode 100644 index 000000000..aad53801a --- /dev/null +++ b/examples/tutorials/00_sync/060_claude_code/project/acp.py @@ -0,0 +1,137 @@ +"""ACP handler for the sync Claude Code tutorial. + +Spawns ``claude -p --output-format stream-json --verbose`` as a LOCAL +asyncio subprocess (no Scale sandbox -- that is the golden agent's +production concern). Stdout lines are fed into ``ClaudeCodeTurn``, which +wraps ``convert_claude_code_to_agentex_events``. Events are delivered via +``UnifiedEmitter.yield_turn``, the sync HTTP yield path. + +Live runs require the ``claude`` CLI to be installed and an +ANTHROPIC_API_KEY (or equivalent credential) to be in the environment. +For offline testing, see ``tests/test_agent_offline.py``, which injects a +fake subprocess. +""" + +from __future__ import annotations + +import os +import asyncio +from typing import AsyncIterator, AsyncGenerator + +from dotenv import load_dotenv + +load_dotenv() + +import agentex.lib.adk as adk +from agentex.lib.adk import ClaudeCodeTurn +from agentex.lib.types.acp import SendMessageParams +from agentex.lib.core.harness import UnifiedEmitter +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.types.task_message_update import TaskMessageUpdate +from agentex.types.task_message_content import TaskMessageContent +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config + +logger = make_logger(__name__) + +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) +) + +acp = FastACP.create(acp_type="sync") + + +async def _spawn_claude(prompt: str) -> AsyncIterator[str]: + """Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines. + + This is a seam: tests replace it with a fake async iterator of + pre-recorded lines so no real CLI invocation is needed offline. + """ + proc = await asyncio.create_subprocess_exec( + "claude", + "-p", + "--output-format", + "stream-json", + "--verbose", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + assert proc.stdout is not None + assert proc.stdin is not None + + proc.stdin.write(prompt.encode()) + proc.stdin.close() + + # Drain stderr concurrently. With --verbose, Claude Code can write enough to + # stderr to fill the OS pipe buffer; if we only read stdout, the CLI blocks + # on its stderr write while we block reading stdout — a deadlock. A + # background task keeps stderr flowing so stdout never stalls. + async def _drain_stderr() -> None: + assert proc.stderr is not None + async for _ in proc.stderr: + pass + + stderr_task = asyncio.create_task(_drain_stderr()) + + try: + buffer = "" + async for chunk in proc.stdout: + buffer += chunk.decode("utf-8", errors="replace") + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if line: + yield line + + if buffer.strip(): + yield buffer.strip() + + await proc.wait() + finally: + # Release the subprocess and stderr drain task even if the consumer + # abandons the generator early (task cancellation / client disconnect): + # cancel the drain task and terminate+reap the process if it is still + # running, so neither is leaked. + stderr_task.cancel() + try: + await stderr_task + except asyncio.CancelledError: + pass + if proc.returncode is None: + try: + proc.terminate() + except ProcessLookupError: + pass + await proc.wait() + + +@acp.on_message_send +async def handle_message_send( + params: SendMessageParams, +) -> TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]: + """Handle an incoming message: run Claude Code locally and stream events.""" + task_id = params.task.id + prompt = params.content.content + logger.info("Processing message for task %s", task_id) + + async with adk.tracing.span( + trace_id=task_id, + task_id=task_id, + name="message", + input={"message": prompt}, + data={"__span_type__": "AGENT_WORKFLOW"}, + ) as turn_span: + emitter = UnifiedEmitter( + task_id=task_id, + trace_id=task_id, + parent_span_id=turn_span.id if turn_span else None, + ) + turn = ClaudeCodeTurn(_spawn_claude(prompt)) + async for event in emitter.yield_turn(turn): + yield event diff --git a/examples/tutorials/00_sync/060_claude_code/pyproject.toml b/examples/tutorials/00_sync/060_claude_code/pyproject.toml new file mode 100644 index 000000000..e5c1c4ea6 --- /dev/null +++ b/examples/tutorials/00_sync/060_claude_code/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "s060-claude-code" +version = "0.1.0" +description = "A sync Claude Code agent streaming the unified harness surface via a local CLI subprocess" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "python-dotenv>=1.0,<2", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] diff --git a/examples/tutorials/00_sync/060_claude_code/tests/test_agent.py b/examples/tutorials/00_sync/060_claude_code/tests/test_agent.py new file mode 100644 index 000000000..954a520f3 --- /dev/null +++ b/examples/tutorials/00_sync/060_claude_code/tests/test_agent.py @@ -0,0 +1,162 @@ +"""Tests for the sync Claude Code tutorial agent. + +LIVE tests (``TestClaudeCodeLive``): + - Require the ``claude`` CLI on PATH and ``ANTHROPIC_API_KEY`` set. + - Run the full agent end-to-end against a live Agentex server. + - Skipped automatically when ``CLAUDE_LIVE_TESTS`` is not set to ``1``. + +OFFLINE unit tests (``TestClaudeCodeOffline``): + - Inject a fake async iterator of pre-recorded stream-json lines. + - Assert the ``ClaudeCodeTurn`` + ``UnifiedEmitter`` pipeline yields events, + populates usage, and satisfies the ``HarnessTurn`` protocol. + - Always run -- no CLI or API key needed. +""" + +from __future__ import annotations + +import os +import json +from typing import AsyncIterator + +import pytest + +# --------------------------------------------------------------------------- +# Recorded stream-json fixtures +# --------------------------------------------------------------------------- + +_TEXT_ONLY_LINES: list[str] = [ + json.dumps({"type": "system", "subtype": "init", "session_id": "sess-offline-1"}), + json.dumps( + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "Hello from Claude Code!"}]}, + } + ), + json.dumps( + { + "type": "result", + "usage": {"input_tokens": 10, "output_tokens": 5}, + "cost_usd": 0.0001, + "duration_ms": 250, + "num_turns": 1, + } + ), +] + + +async def _fake_lines(lines: list[str]) -> AsyncIterator[str]: + """Async iterator of pre-recorded stream-json lines (no subprocess).""" + for line in lines: + yield line + + +# --------------------------------------------------------------------------- +# Offline tests (always run -- no CLI or API key needed) +# --------------------------------------------------------------------------- + + +class TestClaudeCodeOffline: + """Unit tests that run without a real claude CLI or network.""" + + @pytest.mark.asyncio + async def test_yields_stream_events(self): + """ClaudeCodeTurn drives UnifiedEmitter and yields StreamTaskMessage* events.""" + from agentex.lib.adk import ClaudeCodeTurn + from agentex.lib.core.harness import UnifiedEmitter + from agentex.types.task_message_update import StreamTaskMessageStart + + turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) + emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) + + events = [e async for e in emitter.yield_turn(turn)] + assert len(events) > 0, "No events yielded" + assert any(isinstance(e, StreamTaskMessageStart) for e in events) + + @pytest.mark.asyncio + async def test_stream_task_message_done_present(self): + """StreamTaskMessageDone must appear after stream exhaustion.""" + from agentex.lib.adk import ClaudeCodeTurn + from agentex.lib.core.harness import UnifiedEmitter + from agentex.types.task_message_update import StreamTaskMessageDone + + turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) + emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) + + events = [e async for e in emitter.yield_turn(turn)] + assert any(isinstance(e, StreamTaskMessageDone) for e in events), ( + "Expected at least one StreamTaskMessageDone event" + ) + + @pytest.mark.asyncio + async def test_usage_populated_after_stream_exhausted(self): + """ClaudeCodeTurn.usage() returns correct tokens after stream is exhausted.""" + from agentex.lib.adk import ClaudeCodeTurn + + turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) + _ = [e async for e in turn.events] + usage = turn.usage() + assert usage.input_tokens == 10 + assert usage.output_tokens == 5 + assert usage.num_llm_calls == 1 + + @pytest.mark.asyncio + async def test_protocol_compliance(self): + """ClaudeCodeTurn satisfies the HarnessTurn protocol.""" + from agentex.lib.adk import ClaudeCodeTurn + + turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) + assert hasattr(turn, "events"), "ClaudeCodeTurn missing .events" + assert hasattr(turn, "usage"), "ClaudeCodeTurn missing .usage()" + + +# --------------------------------------------------------------------------- +# Live tests (skipped unless CLAUDE_LIVE_TESTS=1) +# --------------------------------------------------------------------------- + +pytestmark_live = pytest.mark.skipif( + not os.environ.get("CLAUDE_LIVE_TESTS"), + reason="Set CLAUDE_LIVE_TESTS=1 and ensure the `claude` CLI + ANTHROPIC_API_KEY are available", +) + +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "s060-claude-code") + + +@pytestmark_live +class TestClaudeCodeLive: + """Live streaming tests -- needs the claude CLI + ANTHROPIC_API_KEY.""" + + @pytest.fixture + def client(self): + from agentex import Agentex + + return Agentex(base_url=AGENTEX_API_BASE_URL) + + @pytest.fixture + def agent_name(self): + return AGENT_NAME + + def test_stream_simple_message(self, client, agent_name: str): + """Stream a simple prompt through the local Claude Code subprocess.""" + from test_utils.sync import collect_streaming_response + + from agentex.types import TextContentParam + from agentex.types.agent_rpc_params import ParamsSendMessageRequest + + stream = client.agents.send_message_stream( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content="Reply with exactly three words: hello from claude", + type="text", + ) + ), + ) + aggregated_content, chunks = collect_streaming_response(stream) + assert aggregated_content is not None + assert len(chunks) >= 1 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/00_sync/060_claude_code/tests/test_agent_offline.py b/examples/tutorials/00_sync/060_claude_code/tests/test_agent_offline.py new file mode 100644 index 000000000..23ac52a57 --- /dev/null +++ b/examples/tutorials/00_sync/060_claude_code/tests/test_agent_offline.py @@ -0,0 +1,210 @@ +"""Offline unit tests for the sync Claude Code tutorial agent. + +These tests do NOT require the ``claude`` CLI or an ANTHROPIC_API_KEY. +They inject a fake async iterator of pre-recorded stream-json lines in +place of the real subprocess spawn, and a fake streaming backend in place +of the real Redis/AGP layer, then assert that the handler correctly drives +the unified surface (``UnifiedEmitter.yield_turn``). + +The injection seam is the ``_spawn_claude`` function in ``project/acp.py``. +Tests monkeypatch it with a coroutine that returns a pre-recorded async +iterator, so the handler code runs in full without any subprocess. +""" + +from __future__ import annotations + +import json +from typing import AsyncIterator + +import pytest + +from agentex.lib.adk import ClaudeCodeTurn +from agentex.lib.core.harness import UnifiedEmitter +from agentex.types.task_message_update import ( + StreamTaskMessageStart, +) + +# --------------------------------------------------------------------------- +# Recorded stream-json fixtures +# --------------------------------------------------------------------------- + +_TEXT_ONLY_LINES: list[str] = [ + json.dumps({"type": "system", "subtype": "init", "session_id": "sess-1"}), + json.dumps( + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "Hello from Claude Code!"}]}, + } + ), + json.dumps( + { + "type": "result", + "usage": {"input_tokens": 10, "output_tokens": 5}, + "cost_usd": 0.0001, + "duration_ms": 250, + "num_turns": 1, + } + ), +] + +_TOOL_CALL_LINES: list[str] = [ + json.dumps({"type": "system", "subtype": "init", "session_id": "sess-2"}), + json.dumps( + { + "type": "assistant", + "message": { + "content": [ + { + "type": "tool_use", + "id": "tool_abc", + "name": "Bash", + "input": {"command": "echo hello"}, + } + ] + }, + } + ), + json.dumps( + { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "tool_abc", + "content": "hello\n", + "is_error": False, + } + ] + }, + } + ), + json.dumps( + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "Done."}]}, + } + ), + json.dumps( + { + "type": "result", + "usage": {"input_tokens": 20, "output_tokens": 8}, + "cost_usd": 0.0002, + "duration_ms": 400, + "num_turns": 1, + } + ), +] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _fake_lines(lines: list[str]) -> AsyncIterator[str]: + for line in lines: + yield line + + +async def _collect_yield_turn(lines: list[str]) -> list: + """Run a ClaudeCodeTurn through UnifiedEmitter.yield_turn and collect events.""" + turn = ClaudeCodeTurn(_fake_lines(lines)) + emitter = UnifiedEmitter(task_id="t1", trace_id=None, parent_span_id=None) + return [e async for e in emitter.yield_turn(turn)] + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_text_only_produces_start_and_done(): + events = await _collect_yield_turn(_TEXT_ONLY_LINES) + types = [type(e).__name__ for e in events] + assert "StreamTaskMessageStart" in types + assert "StreamTaskMessageDone" in types + + +@pytest.mark.asyncio +async def test_text_only_content(): + events = await _collect_yield_turn(_TEXT_ONLY_LINES) + starts = [e for e in events if isinstance(e, StreamTaskMessageStart)] + assert len(starts) == 1 + assert starts[0].content.type == "text" + + +@pytest.mark.asyncio +async def test_usage_is_populated_after_stream(): + turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) + _ = [e async for e in turn.events] + usage = turn.usage() + assert usage.input_tokens == 10 + assert usage.output_tokens == 5 + assert usage.cost_usd == pytest.approx(0.0001, rel=1e-4) + assert usage.num_llm_calls == 1 + + +@pytest.mark.asyncio +async def test_tool_call_produces_tool_request_and_response(): + events = await _collect_yield_turn(_TOOL_CALL_LINES) + content_types = { + getattr(e, "content", None) and getattr(e.content, "type", None) for e in events if hasattr(e, "content") + } + assert "tool_request" in content_types + assert "tool_response" in content_types + + +@pytest.mark.asyncio +async def test_tool_call_has_one_text_block(): + """The tool_use block is not text; only 'Done.' is the text block.""" + events = await _collect_yield_turn(_TOOL_CALL_LINES) + text_starts = [ + e for e in events if isinstance(e, StreamTaskMessageStart) and getattr(e.content, "type", None) == "text" + ] + assert len(text_starts) == 1 + + +@pytest.mark.asyncio +async def test_empty_lines_are_skipped(): + """Inserting blank lines in the stream must not crash the parser.""" + lines_with_blanks = ["", " "] + _TEXT_ONLY_LINES + [""] + events = await _collect_yield_turn(lines_with_blanks) + assert any(isinstance(e, StreamTaskMessageStart) for e in events) + + +@pytest.mark.asyncio +async def test_spawn_seam_concept(): + """Demonstrate the injectable spawn seam pattern used in project/acp.py. + + The ``_spawn_claude`` function in ``project/acp.py`` is a top-level async + generator. Production code calls it like:: + + turn = ClaudeCodeTurn(_spawn_claude(prompt)) + + In tests, a replacement function is injected (e.g. via monkeypatch) to + return pre-recorded lines. This test proves the pattern works end-to-end + without importing the full ACP module (which has module-level env-var + checks that only pass in a running agent environment). + """ + recorded_lines = _TEXT_ONLY_LINES + + async def _fake_spawn(prompt: str) -> AsyncIterator[str]: # noqa: ARG001 + """Drop-in replacement for _spawn_claude.""" + for line in recorded_lines: + yield line + + called_with: list[str] = [] + + async def _wrapped_spawn(prompt: str) -> AsyncIterator[str]: + called_with.append(prompt) + async for line in _fake_spawn(prompt): + yield line + + turn = ClaudeCodeTurn(_wrapped_spawn("test prompt")) + emitter = UnifiedEmitter(task_id="t2", trace_id=None, parent_span_id=None) + events = [e async for e in emitter.yield_turn(turn)] + + assert called_with == ["test prompt"] + assert any(isinstance(e, StreamTaskMessageStart) for e in events) diff --git a/examples/tutorials/00_sync/070_codex/.dockerignore b/examples/tutorials/00_sync/070_codex/.dockerignore new file mode 100644 index 000000000..c49489471 --- /dev/null +++ b/examples/tutorials/00_sync/070_codex/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/00_sync/070_codex/Dockerfile b/examples/tutorials/00_sync/070_codex/Dockerfile new file mode 100644 index 000000000..fb500b221 --- /dev/null +++ b/examples/tutorials/00_sync/070_codex/Dockerfile @@ -0,0 +1,56 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install the codex CLI: the agent spawns `codex exec --json`, so the binary +# must be present on PATH in the image. +RUN npm install -g @openai/codex + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 00_sync/070_codex/pyproject.toml /app/070_codex/pyproject.toml +COPY 00_sync/070_codex/README.md /app/070_codex/README.md + +WORKDIR /app/070_codex + +# Copy the project code +COPY 00_sync/070_codex/project /app/070_codex/project + +# Copy the test files +COPY 00_sync/070_codex/tests /app/070_codex/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies +RUN uv pip install --system .[dev] + +# Set environment variables +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=s070-codex + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/00_sync/070_codex/README.md b/examples/tutorials/00_sync/070_codex/README.md new file mode 100644 index 000000000..3abb2766f --- /dev/null +++ b/examples/tutorials/00_sync/070_codex/README.md @@ -0,0 +1,40 @@ +# 070_codex (sync) + +Tutorial agent demonstrating the `convert_codex_to_agentex_events` tap, +`CodexTurn`, and `UnifiedEmitter` for a **sync** (HTTP-yield) ACP agent. + +## What this tutorial shows + +- Spawning `codex exec --json` as a **local asyncio subprocess** (no Scale sandbox). +- Wrapping the stdout line stream in a `CodexTurn`. +- Delivering every canonical `StreamTaskMessage*` event to the HTTP caller via + `UnifiedEmitter.yield_turn` (tracing as a side-effect). + +> **Production isolation note:** A tutorial agent runs the Codex CLI locally. +> Production-grade isolation (Scale sandbox, secret injection, MCP configuration) +> is handled by the golden agent at +> `teams/sgp/agents/golden_agent/project/harness/providers/codex.py`. + +## Live runs + +Live runs require: +1. The `codex` CLI on PATH: `npm install -g @openai/codex` +2. `OPENAI_API_KEY` set in the environment. + +## Running offline unit tests + +The offline tests inject a fake subprocess and never invoke the real CLI: + +```bash +cd /path/to/scale-agentex-python +uv run --all-packages --all-extras pytest examples/tutorials/00_sync/070_codex/tests/test_agent.py -q +``` + +## Running live integration tests + +```bash +export CODEX_LIVE_TESTS=1 +export OPENAI_API_KEY=sk-... +# Start the agent server first, then: +pytest tests/test_agent.py -v +``` diff --git a/examples/tutorials/00_sync/070_codex/conftest.py b/examples/tutorials/00_sync/070_codex/conftest.py new file mode 100644 index 000000000..bdd78994b --- /dev/null +++ b/examples/tutorials/00_sync/070_codex/conftest.py @@ -0,0 +1,12 @@ +"""Add the agent's project root to sys.path so ``import project`` works. + +Also sets minimal environment variables so the FastACP and tracing modules +can be imported without a running agent server. +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) + +os.environ.setdefault("ACP_URL", "http://localhost:8000") diff --git a/examples/tutorials/00_sync/070_codex/manifest.yaml b/examples/tutorials/00_sync/070_codex/manifest.yaml new file mode 100644 index 000000000..87dad2847 --- /dev/null +++ b/examples/tutorials/00_sync/070_codex/manifest.yaml @@ -0,0 +1,58 @@ +build: + context: + root: ../../ + include_paths: + - 00_sync/070_codex + - test_utils + dockerfile: 00_sync/070_codex/Dockerfile + dockerignore: 00_sync/070_codex/.dockerignore + +local_development: + agent: + port: 8000 + host_address: host.docker.internal + paths: + acp: project/acp.py + +agent: + acp_type: sync + name: s070-codex + description: Sync tutorial agent driving the unified harness surface via local codex CLI subprocess + + temporal: + enabled: false + + credentials: + - env_var_name: OPENAI_API_KEY + secret_name: openai-api-key + secret_key: api-key + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + - env_var_name: SGP_ACCOUNT_ID + secret_name: sgp-account-id + secret_key: account-id + - env_var_name: SGP_CLIENT_BASE_URL + secret_name: sgp-client-base-url + secret_key: url + +deployment: + image: + repository: "" + tag: "latest" + + global: + agent: + name: "s070-codex" + description: "Sync tutorial agent driving the unified harness surface via local codex CLI subprocess" + replicaCount: 1 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/00_sync/070_codex/project/__init__.py b/examples/tutorials/00_sync/070_codex/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/00_sync/070_codex/project/acp.py b/examples/tutorials/00_sync/070_codex/project/acp.py new file mode 100644 index 000000000..bcb5e10df --- /dev/null +++ b/examples/tutorials/00_sync/070_codex/project/acp.py @@ -0,0 +1,175 @@ +"""Sync ACP handler for the Codex CLI harness tutorial. + +Demonstrates the ``convert_codex_to_agentex_events`` tap + ``CodexTurn`` + +``UnifiedEmitter`` for a sync (HTTP-yield) ACP agent. + +The handler: +1. Spawns ``codex exec --json`` as a LOCAL asyncio subprocess (no sandbox). + This is correct for tutorials and local development; production isolation + is handled by the golden agent's Scale sandbox at + ``teams/sgp/agents/golden_agent/project/harness/providers/codex.py``. +2. Wraps the stdout line stream in a ``CodexTurn``. +3. Delivers every canonical ``StreamTaskMessage*`` event via + ``UnifiedEmitter.yield_turn``, which traces + yields each event back to + the HTTP caller in one pass. + +Live runs require: +- ``codex`` CLI on PATH (``npm install -g @openai/codex``) +- ``OPENAI_API_KEY`` set in the environment +""" + +from __future__ import annotations + +import os +import time +import codecs +import asyncio +from typing import AsyncGenerator +from collections.abc import AsyncIterator + +from dotenv import load_dotenv + +load_dotenv() + +import agentex.lib.adk as adk +from agentex.lib.adk import CodexTurn +from agentex.lib.types.acp import SendMessageParams +from agentex.lib.core.harness import UnifiedEmitter +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.types.task_message_update import TaskMessageUpdate +from agentex.types.task_message_content import TaskMessageContent +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config + +logger = make_logger(__name__) + +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) +) + +acp = FastACP.create(acp_type="sync") + +MODEL = os.environ.get("CODEX_MODEL", "o4-mini") + + +async def _spawn_codex(model: str) -> asyncio.subprocess.Process: + """Spawn ``codex exec --json`` locally and return the live process. + + Injection seam: tests replace this function with a fake that returns a + mock process whose stdout yields pre-recorded event lines. + + The flags mirror the golden agent (codex.py in the golden agent repo): + --json machine-readable newline-delimited events + --skip-git-repo-check safe to run outside a git repo + --dangerously-bypass-approvals-and-sandbox + skip interactive approval prompts in a + non-interactive (server) context + --model which OpenAI model to use + + The caller writes the prompt to stdin after the process starts, then + closes stdin so codex knows input is complete. + """ + cmd = [ + "codex", + "exec", + "--json", + "--skip-git-repo-check", + "--dangerously-bypass-approvals-and-sandbox", + "--model", + model, + "-", # read prompt from stdin + ] + return await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + # Discard stderr: codex --json writes events to stdout; its stderr is + # progress/debug noise. Capturing it with PIPE but never reading it + # would deadlock once codex fills the OS pipe buffer (~64 KB). + stderr=asyncio.subprocess.DEVNULL, + env={**os.environ}, + ) + + +async def _process_stdout(process: asyncio.subprocess.Process) -> AsyncIterator[str]: + """Yield newline-delimited JSON lines from the process stdout. + + Uses an incremental UTF-8 decoder so a multibyte character split across two + 4 KB reads is decoded correctly instead of being corrupted at the boundary. + """ + assert process.stdout is not None + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + buffer = "" + while True: + chunk = await process.stdout.read(4096) + if not chunk: + break + buffer += decoder.decode(chunk) + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if line: + yield line + buffer += decoder.decode(b"", final=True) + if buffer.strip(): + yield buffer.strip() + + +@acp.on_message_send +async def handle_message_send( + params: SendMessageParams, +) -> TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]: + """Handle each message by running ``codex exec`` locally and streaming events.""" + task_id = params.task.id + user_message = params.content.content + logger.info("Processing message for task %s", task_id) + + start_ms = int(time.monotonic() * 1000) + + async with adk.tracing.span( + trace_id=task_id, + task_id=task_id, + name="message", + input={"message": user_message}, + data={"__span_type__": "AGENT_WORKFLOW"}, + ) as turn_span: + process = await _spawn_codex(MODEL) + + # Write prompt to stdin then close it so codex knows input is done. + assert process.stdin is not None + process.stdin.write(user_message.encode("utf-8")) + await process.stdin.drain() + process.stdin.close() + + turn = CodexTurn( + events=_process_stdout(process), + model=MODEL, + ) + + emitter = UnifiedEmitter( + task_id=task_id, + trace_id=task_id, + parent_span_id=turn_span.id if turn_span else None, + ) + + async for event in emitter.yield_turn(turn): + yield event + + await process.wait() + + # Record the real wall-clock duration AFTER streaming completes; setting + # it before the stream ran would capture only subprocess spawn overhead. + turn.duration_ms = int(time.monotonic() * 1000) - start_ms + + if turn_span: + usage = turn.usage() + turn_span.output = { + "model": usage.model, + "input_tokens": usage.input_tokens, + "output_tokens": usage.output_tokens, + } diff --git a/examples/tutorials/00_sync/070_codex/pyproject.toml b/examples/tutorials/00_sync/070_codex/pyproject.toml new file mode 100644 index 000000000..88bbb9cca --- /dev/null +++ b/examples/tutorials/00_sync/070_codex/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "s070-codex" +version = "0.1.0" +description = "Sync tutorial agent driving the unified harness surface via local codex CLI subprocess" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/examples/tutorials/00_sync/070_codex/tests/test_agent.py b/examples/tutorials/00_sync/070_codex/tests/test_agent.py new file mode 100644 index 000000000..94aa2aaf2 --- /dev/null +++ b/examples/tutorials/00_sync/070_codex/tests/test_agent.py @@ -0,0 +1,176 @@ +"""Tests for the sync Codex harness tutorial agent. + +LIVE tests (``TestLiveCodexAgent``): + - Require the ``codex`` CLI on PATH and ``OPENAI_API_KEY`` set. + - Run the full agent end-to-end against a live Agentex server. + - Skipped automatically when ``CODEX_LIVE_TESTS`` is not set to ``1``. + +OFFLINE unit tests (``TestOfflineCodexHandler``): + - Inject a fake async iterator of pre-recorded codex event lines. + - Assert the ``CodexTurn`` + ``UnifiedEmitter`` pipeline yields events, + populates usage, and satisfies the ``HarnessTurn`` protocol. + - Always run. +""" + +from __future__ import annotations + +import os +import json +from typing import Any + +import pytest + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +SAMPLE_EVENTS: list[dict[str, Any]] = [ + {"type": "thread.started", "thread_id": "thread-abc"}, + {"type": "turn.started"}, + { + "type": "item.started", + "item": {"id": "msg-1", "type": "agent_message", "text": "Hello"}, + }, + { + "type": "item.completed", + "item": {"id": "msg-1", "type": "agent_message", "text": "Hello, world!"}, + }, + { + "type": "turn.completed", + "usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, + }, +] + + +async def _fake_event_stream(): + """Async iterator of pre-recorded codex event JSON lines (no subprocess).""" + for evt in SAMPLE_EVENTS: + yield json.dumps(evt) + + +class TestOfflineCodexHandler: + """Unit tests that run without a real codex CLI or network.""" + + @pytest.mark.asyncio + async def test_codex_turn_yields_stream_events(self): + """CodexTurn drives the unified surface and yields StreamTaskMessage* events.""" + from agentex.lib.adk import CodexTurn + from agentex.lib.core.harness import UnifiedEmitter + + turn = CodexTurn(events=_fake_event_stream(), model="o4-mini") + emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) + + events = [e async for e in emitter.yield_turn(turn)] + assert len(events) > 0, "No events yielded" + + types_seen = {type(e).__name__ for e in events} + known_types = { + "StreamTaskMessageStart", + "StreamTaskMessageDelta", + "StreamTaskMessageFull", + "StreamTaskMessageDone", + } + assert bool(types_seen & known_types), f"Unexpected event types: {types_seen}" + + @pytest.mark.asyncio + async def test_usage_populated_after_stream_exhausted(self): + """CodexTurn.usage() returns correct tokens after stream is exhausted.""" + from agentex.lib.adk import CodexTurn + + turn = CodexTurn(events=_fake_event_stream(), model="o4-mini") + + collected = [e async for e in turn.events] + + usage = turn.usage() + assert usage.input_tokens == 10 + assert usage.output_tokens == 5 + assert usage.total_tokens == 15 + assert usage.model == "o4-mini" + + @pytest.mark.asyncio + async def test_codex_turn_protocol_compliance(self): + """CodexTurn satisfies the HarnessTurn protocol.""" + from agentex.lib.adk import CodexTurn + from agentex.lib.core.harness.types import HarnessTurn + + turn = CodexTurn(events=_fake_event_stream(), model="o4-mini") + assert isinstance(turn, HarnessTurn), "CodexTurn does not satisfy HarnessTurn protocol" + + @pytest.mark.asyncio + async def test_unified_emitter_yield_passes_through_events(self): + """UnifiedEmitter.yield_turn passes events through unchanged in sync mode.""" + from agentex.lib.adk import CodexTurn + from agentex.lib.core.harness import UnifiedEmitter + + turn = CodexTurn(events=_fake_event_stream(), model="o4-mini") + emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) + + events = [e async for e in emitter.yield_turn(turn)] + assert len(events) > 0 + + @pytest.mark.asyncio + async def test_convert_codex_to_agentex_events_direct(self): + """convert_codex_to_agentex_events tap produces text start/done events.""" + from agentex.lib.adk import convert_codex_to_agentex_events + from agentex.types.task_message_update import StreamTaskMessageDone + + events = [e async for e in convert_codex_to_agentex_events(_fake_event_stream())] + assert any(isinstance(e, StreamTaskMessageDone) for e in events), ( + "Expected at least one StreamTaskMessageDone event" + ) + + @pytest.mark.asyncio + async def test_on_result_callback_receives_session_id(self): + """on_result callback receives the session_id from thread.started.""" + from agentex.lib.adk import convert_codex_to_agentex_events + + captured: list[dict] = [] + + events = [ + e + async for e in convert_codex_to_agentex_events( + _fake_event_stream(), + on_result=captured.append, + ) + ] + + assert len(captured) == 1 + assert captured[0]["session_id"] == "thread-abc" + assert captured[0]["tool_call_count"] == 0 + + +# --------------------------------------------------------------------------- +# Live tests (skipped unless CODEX_LIVE_TESTS=1) +# --------------------------------------------------------------------------- + +LIVE = os.environ.get("CODEX_LIVE_TESTS", "") == "1" +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "s070-codex") + + +@pytest.mark.skipif(not LIVE, reason="Set CODEX_LIVE_TESTS=1 and ensure codex CLI + OPENAI_API_KEY are available") +class TestLiveCodexAgent: + """End-to-end tests that require the real codex CLI and a running Agentex server.""" + + @pytest.fixture + def client(self): + from agentex import Agentex + + return Agentex(base_url=AGENTEX_API_BASE_URL) + + def test_send_simple_message(self, client): + from agentex.types import TextContentParam + from agentex.types.agent_rpc_params import ParamsSendMessageRequest + + response = client.agents.send_message( + agent_name=AGENT_NAME, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content="What is 2+2? Reply with just the number.", + type="text", + ) + ), + ) + assert response.result is not None + assert len(response.result) >= 1 diff --git a/examples/tutorials/10_async/00_base/000_hello_acp/.dockerignore b/examples/tutorials/10_async/00_base/000_hello_acp/.dockerignore new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/examples/tutorials/10_async/00_base/000_hello_acp/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/00_base/000_hello_acp/Dockerfile b/examples/tutorials/10_async/00_base/000_hello_acp/Dockerfile new file mode 100644 index 000000000..8b0d20f88 --- /dev/null +++ b/examples/tutorials/10_async/00_base/000_hello_acp/Dockerfile @@ -0,0 +1,51 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 10_async/00_base/000_hello_acp/pyproject.toml /app/000_hello_acp/pyproject.toml +COPY 10_async/00_base/000_hello_acp/README.md /app/000_hello_acp/README.md + +WORKDIR /app/000_hello_acp + +# Copy the project code +COPY 10_async/00_base/000_hello_acp/project /app/000_hello_acp/project + +# Copy the test files +COPY 10_async/00_base/000_hello_acp/tests /app/000_hello_acp/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies (includes pytest) +RUN uv pip install --system .[dev] pytest-asyncio httpx + +WORKDIR /app/000_hello_acp +# Set environment variables +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=ab000-hello-acp + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_async/00_base/000_hello_acp/README.md b/examples/tutorials/10_async/00_base/000_hello_acp/README.md new file mode 100644 index 000000000..ba8aece1f --- /dev/null +++ b/examples/tutorials/10_async/00_base/000_hello_acp/README.md @@ -0,0 +1,49 @@ +# [Async] Hello ACP + +Async agents use three handlers for async task management: `on_task_create`, `on_task_event_send`, and `on_task_cancel`. Unlike sync agents, tasks persist and can receive multiple events over time. + +## What You'll Learn +- The three-handler pattern for async agents +- How tasks differ from sync messages +- When to use async vs sync agents + +## Prerequisites +- Development environment set up (see [main repo README](https://github.com/scaleapi/scale-agentex)) +- Backend services running: `make dev` from repository root +- Understanding of sync agents (see [00_sync/000_hello_acp](../../../00_sync/000_hello_acp/)) + +## Quick Start + +```bash +cd examples/tutorials/10_async/00_base/000_hello_acp +uv run agentex agents run --manifest manifest.yaml +``` + +## Key Pattern + +```python +@acp.on_task_create +async def handle_task_create(params: CreateTaskParams): + # Initialize task state, send welcome message + +@acp.on_task_event_send +async def handle_event_send(params: SendEventParams): + # Handle each message/event in the task + +@acp.on_task_cancel +async def handle_task_cancel(params: CancelTaskParams): + # Cleanup when task is cancelled +``` + +Three handlers instead of one, giving you full control over task lifecycle. Tasks can receive multiple events and maintain state across them. + +## When to Use +- Conversational agents that need memory +- Operations that require task tracking +- Agents that need lifecycle management (initialization, cleanup) +- Building towards production systems + +## Why This Matters +The task-based model is the foundation of production agents. Unlike sync agents where each message is independent, async agents maintain persistent tasks that can receive multiple events, store state, and have full lifecycle management. This is the stepping stone to Temporal-based agents. + +**Next:** [010_multiturn](../010_multiturn/) - Add conversation memory diff --git a/examples/tutorials/10_async/00_base/000_hello_acp/dev.ipynb b/examples/tutorials/10_async/00_base/000_hello_acp/dev.ipynb new file mode 100644 index 000000000..2d5b8800c --- /dev/null +++ b/examples/tutorials/10_async/00_base/000_hello_acp/dev.ipynb @@ -0,0 +1,126 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "0", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"ab000-hello-acp\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "# (REQUIRED) Create a new task. For Async agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/tutorials/10_async/00_base/000_hello_acp/manifest.yaml b/examples/tutorials/10_async/00_base/000_hello_acp/manifest.yaml new file mode 100644 index 000000000..ba0c68369 --- /dev/null +++ b/examples/tutorials/10_async/00_base/000_hello_acp/manifest.yaml @@ -0,0 +1,122 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../../../ # Up to tutorials level to include test_utils + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - 10_async/00_base/000_hello_acp + - test_utils + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: 10_async/00_base/000_hello_acp/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: 10_async/00_base/000_hello_acp/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + +# Agent Configuration +# ----------------- +agent: + # Unique name for your agent + # Used for task routing and monitoring + name: ab000-hello-acp + + # Type of ACP to use + # sync: Simple synchronous ACP implementation + # async: Advanced ACP with sub-types "base" or "temporal" (requires config) + acp_type: async + + # Description of what your agent does + # Helps with documentation and discovery + description: An AgentEx agent that is not intelligent. It just shows how to implement the base async ACP type. + + # Temporal workflow configuration + # Set enabled: true to use Temporal workflows for long-running tasks + temporal: + enabled: false + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + # credentials: + # - env_var_name: OPENAI_API_KEY + # secret_name: openai-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + # env: + # - name: OPENAI_BASE_URL + # value: "https://api.openai.com/v1" + # - name: ACCOUNT_ID + # value: "your_account_id_here" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific files (deploy/*.yaml) + global: + agent: + name: "ab000-hello-acp" + description: "An AgentEx agent that is not intelligent. It just shows how to implement the base async ACP type." + + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/examples/tutorials/10_async/00_base/000_hello_acp/project/__init__.py b/examples/tutorials/10_async/00_base/000_hello_acp/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/00_base/000_hello_acp/project/acp.py b/examples/tutorials/10_async/00_base/000_hello_acp/project/acp.py new file mode 100644 index 000000000..341a22716 --- /dev/null +++ b/examples/tutorials/10_async/00_base/000_hello_acp/project/acp.py @@ -0,0 +1,75 @@ +import json + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams +from agentex.lib.types.fastacp import AsyncACPConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.sdk.fastacp.fastacp import FastACP + +logger = make_logger(__name__) + + +# Create an ACP server with base configuration +# This sets up the core server that will handle task creation, events, and cancellation +acp = FastACP.create( + acp_type="async", + config=AsyncACPConfig( + type="base", + ), +) + +@acp.on_task_create +async def handle_task_create(params: CreateTaskParams): + # This handler is called first whenever a new task is created. + # It's a good place to initialize any state or resources needed for the task. + + ######################################################### + # 1. (👋) Do task initialization here. + ######################################################### + + # Acknowledge that the task has been created. + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"Hello! I've received your task. Normally you can do some state initialization here, or just pass and do nothing until you get your first event. For now I'm just acknowledging that I've received a task with the following params:\n\n{json.dumps(params.params, indent=2)}.\n\nYou should only see this message once, when the task is created. All subsequent events will be handled by the `on_task_event_send` handler.", + ), + ) + +@acp.on_task_event_send +async def handle_event_send(params: SendEventParams): + # This handler is called whenever a new event (like a message) is sent to the task + + ######################################################### + # 2. (👋) Echo back the client's message to show it in the UI. + ######################################################### + + # This is not done by default so the agent developer has full control over what is shown to the user. + if params.event.content: + await adk.messages.create(task_id=params.task.id, content=params.event.content) + + ######################################################### + # 3. (👋) Send a simple response message. + ######################################################### + + # In future tutorials, this is where we'll add more sophisticated response logic. + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"Hello! I've received your message. I can't respond right now, but in future tutorials we'll see how you can get me to intelligently respond to your message.", + ), + ) + +@acp.on_task_cancel +async def handle_task_cancel(params: CancelTaskParams): + # This handler is called when a task is cancelled. + # It's useful for cleaning up any resources or state associated with the task. + + ######################################################### + # 4. (👋) Do task cleanup here. + ######################################################### + + # This is mostly for durable workflows that are cancellable like Temporal, but we will leave it here for demonstration purposes. + logger.info(f"Hello! I've received task cancel for task {params.task.id}: {params.task}. This isn't necessary for this example, but it's good to know that it's available.") diff --git a/examples/tutorials/10_async/00_base/000_hello_acp/pyproject.toml b/examples/tutorials/10_async/00_base/000_hello_acp/pyproject.toml new file mode 100644 index 000000000..b65795e84 --- /dev/null +++ b/examples/tutorials/10_async/00_base/000_hello_acp/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "ab000-hello-acp" +version = "0.1.0" +description = "An AgentEx agent that is not intelligent. It just shows how to implement the base async ACP type." +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 \ No newline at end of file diff --git a/examples/tutorials/10_async/00_base/000_hello_acp/tests/test_agent.py b/examples/tutorials/10_async/00_base/000_hello_acp/tests/test_agent.py new file mode 100644 index 000000000..c57cec448 --- /dev/null +++ b/examples/tutorials/10_async/00_base/000_hello_acp/tests/test_agent.py @@ -0,0 +1,191 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: ab000-hello-acp) +""" + +import os +import uuid +import asyncio + +import pytest +import pytest_asyncio +from test_utils.async_utils import ( + poll_messages, + stream_agent_response, + send_event_and_poll_yielding, +) + +from agentex import AsyncAgentex +from agentex.types import TaskMessage +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "ab000-hello-acp") + + +@pytest_asyncio.fixture +async def client(): + """Create an AgentEx client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client: AsyncAgentex, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_id: str): + """Test sending an event and polling for the response.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Poll for the initial task creation message + task_creation_message_found = False + + async for message in poll_messages( + client=client, + task_id=task.id, + timeout=30, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + if message.content and message.content.type == "text" and message.content.author == "agent": + assert "Hello! I've received your task" in message.content.content + task_creation_message_found = True + break + + assert task_creation_message_found, "Task creation message not found" + + # Send an event and poll for response + user_message = "Hello, this is a test message!" + agent_response_found = False + + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message, + timeout=30, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + if message.content and message.content.type == "text" and message.content.author == "agent": + assert "Hello! I've received your task" in message.content.content + agent_response_found = True + break + + assert agent_response_found, "Agent response not found" +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_id: str): + """Test sending an event and streaming the response.""" + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + + assert task is not None + task_creation_found = False + + async for message in poll_messages( + client=client, + task_id=task.id, + timeout=30, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + if message.content and message.content.type == "text" and message.content.author == "agent": + assert "Hello! I've received your task" in message.content.content + task_creation_found = True + break + + assert task_creation_found, "Task creation message not found" + + user_message = "Hello, this is a test message!" + stream_timeout = 10 + + # Collect events from stream + all_events = [] + + # Flags to track what we've received + user_echo_found = False + agent_response_found = False + + async def stream_messages() -> None: + nonlocal user_echo_found, agent_response_found + async for event in stream_agent_response( + client=client, + task_id=task.id, + timeout=stream_timeout, + ): + all_events.append(event) + # Check events as they arrive + event_type = event.get("type") + if event_type == "full": + content = event.get("content", {}) + if content.get("content") is None: + continue # Skip empty content + if content.get("type") == "text" and content.get("author") == "agent": + # Check for agent response to user message + if "Hello! I've received your message" in content.get("content", ""): + # Agent response should come after user echo + assert user_echo_found, "Agent response arrived before user message echo (incorrect order)" + agent_response_found = True + elif content.get("type") == "text" and content.get("author") == "user": + # Check for user message echo + if content.get("content") == user_message: + user_echo_found = True + elif event_type == "done": + break + + # Exit early if we've found all expected messages + if user_echo_found and agent_response_found: + break + + stream_task = asyncio.create_task(stream_messages()) + + # Send the event + event_content = TextContentParam(type="text", author="user", content=user_message) + await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + await stream_task + + # Verify all expected messages were received (fail if stream ended without finding them) + assert user_echo_found, "User message echo not found in stream" + assert agent_response_found, "Agent response not found in stream" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/00_base/010_multiturn/.dockerignore b/examples/tutorials/10_async/00_base/010_multiturn/.dockerignore new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/examples/tutorials/10_async/00_base/010_multiturn/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/00_base/010_multiturn/Dockerfile b/examples/tutorials/10_async/00_base/010_multiturn/Dockerfile new file mode 100644 index 000000000..48969ad90 --- /dev/null +++ b/examples/tutorials/10_async/00_base/010_multiturn/Dockerfile @@ -0,0 +1,51 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 10_async/00_base/010_multiturn/pyproject.toml /app/010_multiturn/pyproject.toml +COPY 10_async/00_base/010_multiturn/README.md /app/010_multiturn/README.md + +WORKDIR /app/010_multiturn + +COPY 10_async/00_base/010_multiturn/project /app/010_multiturn/project + +# Copy the test files +COPY 10_async/00_base/010_multiturn/tests /app/010_multiturn/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies (includes pytest) +RUN uv pip install --system .[dev] pytest-asyncio httpx + +WORKDIR /app/010_multiturn + +# Set environment variables +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=ab010-multiturn + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_async/00_base/010_multiturn/README.md b/examples/tutorials/10_async/00_base/010_multiturn/README.md new file mode 100644 index 000000000..e16b96c78 --- /dev/null +++ b/examples/tutorials/10_async/00_base/010_multiturn/README.md @@ -0,0 +1,61 @@ +# [Async] Multiturn + +Handle multi-turn conversations in async agents with task-based state management. Each task maintains its own conversation history automatically. + +## What You'll Learn +- How tasks maintain conversation state across multiple exchanges +- Difference between sync and async multiturn patterns +- Building stateful conversational agents with minimal code + +## Prerequisites +- Development environment set up (see [main repo README](https://github.com/scaleapi/scale-agentex)) +- Backend services running: `make dev` from repository root +- Understanding of basic async agents (see [000_hello_acp](../000_hello_acp/)) + +## Quick Start + +```bash +cd examples/tutorials/10_async/00_base/010_multiturn +uv run agentex agents run --manifest manifest.yaml +``` + +## Key Pattern + +Unlike sync agents where you manually track conversation history, async agents automatically maintain state within each task: + +```python +@app.on_task_event_send() +async def on_task_event_send(event_send: TaskEventSendInput): + # The task's messages list automatically includes all previous exchanges + messages = event_send.task.messages + + # No need to manually pass history - it's already there! + response = await openai_client.chat.completions.create( + model="gpt-4o-mini", + messages=messages + ) + + return {"content": response.choices[0].message.content} +``` + +## Try It + +1. Start the agent with the command above +2. Open the web UI or use the notebook to create a task +3. Send multiple messages in the same task: + - "What's 25 + 17?" + - "What was that number again?" + - "Multiply it by 2" +4. Notice the agent remembers context from previous exchanges + +## When to Use +- Conversational agents that need memory across exchanges +- Chat interfaces where users ask follow-up questions +- Agents that build context over time within a session + +## Why This Matters +Task-based state management eliminates the complexity of manually tracking conversation history. The AgentEx platform handles state persistence automatically, making it easier to build stateful agents without custom session management code. + +**Comparison:** In the sync version ([00_sync/010_multiturn](../../../00_sync/010_multiturn/)), you manually manage conversation history. Here, the task object does it for you. + +**Next:** [020_streaming](../020_streaming/) - Add real-time streaming responses diff --git a/examples/tutorials/10_async/00_base/010_multiturn/dev.ipynb b/examples/tutorials/10_async/00_base/010_multiturn/dev.ipynb new file mode 100644 index 000000000..e174e4705 --- /dev/null +++ b/examples/tutorials/10_async/00_base/010_multiturn/dev.ipynb @@ -0,0 +1,126 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "0", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"ab010-multiturn\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "# (REQUIRED) Create a new task. For Async agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/tutorials/10_async/00_base/010_multiturn/manifest.yaml b/examples/tutorials/10_async/00_base/010_multiturn/manifest.yaml new file mode 100644 index 000000000..5d21e78d5 --- /dev/null +++ b/examples/tutorials/10_async/00_base/010_multiturn/manifest.yaml @@ -0,0 +1,122 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../../../ # Up to tutorials level to include test_utils + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - 10_async/00_base/010_multiturn + - test_utils + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: 10_async/00_base/010_multiturn/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: 10_async/00_base/010_multiturn/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + +# Agent Configuration +# ----------------- +agent: + # Unique name for your agent + # Used for task routing and monitoring + name: ab010-multiturn + + # Type of ACP to use + # sync: Simple synchronous ACP implementation + # async: Advanced ACP with sub-types "base" or "temporal" (requires config) + acp_type: async + + # Description of what your agent does + # Helps with documentation and discovery + description: An AgentEx agent that echoes back the user's message + + # Temporal workflow configuration + # Set enabled: true to use Temporal workflows for long-running tasks + temporal: + enabled: false + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + # credentials: + # - env_var_name: OPENAI_API_KEY + # secret_name: openai-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + # env: + # - name: OPENAI_BASE_URL + # value: "https://api.openai.com/v1" + # - name: ACCOUNT_ID + # value: "your_account_id_here" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific files (deploy/*.yaml) + global: + agent: + name: "ab010-multiturn" + description: "An AgentEx agent that echoes back the user's message" + + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/examples/tutorials/10_async/00_base/010_multiturn/project/__init__.py b/examples/tutorials/10_async/00_base/010_multiturn/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/00_base/010_multiturn/project/acp.py b/examples/tutorials/10_async/00_base/010_multiturn/project/acp.py new file mode 100644 index 000000000..a32eed68e --- /dev/null +++ b/examples/tutorials/10_async/00_base/010_multiturn/project/acp.py @@ -0,0 +1,167 @@ +import os +from typing import List + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams +from agentex.lib.types.fastacp import AsyncACPConfig +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.types.llm_messages import ( + Message, + LLMConfig, + UserMessage, + SystemMessage, + AssistantMessage, +) +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.lib.core.tracing.tracing_processor_manager import ( + add_tracing_processor_config, +) + +logger = make_logger(__name__) + +# Add a tracing processor +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SCALE_GP_API_KEY", ""), sgp_account_id=os.environ.get("SCALE_GP_ACCOUNT_ID", "") + ) +) + +# Create an ACP server + +# !!! Warning: Because "Async" ACPs are designed to be fully asynchronous, race conditions can occur if parallel events are sent. It is highly recommended to use the "temporal" type in the AsyncACPConfig instead to handle complex use cases. The "base" ACP is only designed to be used for simple use cases and for learning purposes. +acp = FastACP.create( + acp_type="async", + config=AsyncACPConfig(type="base"), +) + + +class StateModel(BaseModel): + messages: List[Message] + + +@acp.on_task_create +async def handle_task_create(params: CreateTaskParams): + # Upon task creation, we initialize the task state with a system message. + # This will be fetched by the `on_task_event_send` handler when each event is sent. + + ######################################################### + # 1. Initialize the task state. + ######################################################### + + state = StateModel(messages=[SystemMessage(content="You are a helpful assistant that can answer questions.")]) + await adk.state.create(task_id=params.task.id, agent_id=params.agent.id, state=state) + + +@acp.on_task_event_send +async def handle_event_send(params: SendEventParams): + # !!! Warning: Because "Async" ACPs are designed to be fully asynchronous, race conditions can occur if parallel events are sent. It is highly recommended to use the "temporal" type in the AsyncACPConfig instead to handle complex use cases. The "base" ACP is only designed to be used for simple use cases and for learning purposes. + + ######################################################### + # 2. Validate the event content. + ######################################################### + if not params.event.content: + return + + if params.event.content.type != "text": + raise ValueError(f"Expected text message, got {params.event.content.type}") + + if params.event.content.author != "user": + raise ValueError(f"Expected user message, got {params.event.content.author}") + + ######################################################### + # 3. Echo back the user's message so it shows up in the UI. + ######################################################### + + await adk.messages.create( + task_id=params.task.id, + trace_id=params.task.id, + content=params.event.content, + ) + + ######################################################### + # 4. (👋) If the OpenAI API key is not set, send a message to the user to let them know. + ######################################################### + + if not os.environ.get("OPENAI_API_KEY"): + await adk.messages.create( + task_id=params.task.id, + trace_id=params.task.id, + content=TextContent( + author="agent", + content="Hey, sorry I'm unable to respond to your message because you're running this example without an OpenAI API key. Please set the OPENAI_API_KEY environment variable to run this example. Do this by either by adding a .env file to the project/ directory or by setting the environment variable in your terminal.", + ), + ) + + ######################################################### + # 5. (👋) Retrieve the task state. + ######################################################### + + task_state = await adk.state.get_by_task_and_agent(task_id=params.task.id, agent_id=params.agent.id) + if not task_state: + raise ValueError("Task state not found - ensure task was properly initialized") + state = StateModel.model_validate(task_state.state) + + ######################################################### + # 6. (👋) Add the new user message to the message history + ######################################################### + + # Safely extract content from the event + content_text = "" + if hasattr(params.event.content, "content"): + content_val = getattr(params.event.content, "content", "") + if isinstance(content_val, str): + content_text = content_val + state.messages.append(UserMessage(content=content_text)) + + ######################################################### + # 7. (👋) Call an LLM to respond to the user's message + ######################################################### + + # Call an LLM to respond to the user's message + chat_completion = await adk.providers.litellm.chat_completion( + llm_config=LLMConfig(model="gpt-4o-mini", messages=state.messages), + trace_id=params.task.id, + ) + response_content = "" + if chat_completion.choices[0].message: + response_content = chat_completion.choices[0].message.content or "" + state.messages.append(AssistantMessage(content=response_content)) + + ######################################################### + # 8. (👋) Send agent response to client + ######################################################### + + if chat_completion.choices[0].message: + content_str = chat_completion.choices[0].message.content or "" + else: + content_str = "" + + await adk.messages.create( + task_id=params.task.id, + trace_id=params.task.id, + content=TextContent( + author="agent", + content=content_str, + ), + ) + + ######################################################### + # 9. (👋) Store the messages in the task state for the next turn + ######################################################### + + await adk.state.update( + state_id=task_state.id, + task_id=params.task.id, + agent_id=params.agent.id, + state=state, + trace_id=params.task.id, + ) + + +@acp.on_task_cancel +async def handle_task_cancel(params: CancelTaskParams): + """Default task cancel handler""" + logger.info(f"Task canceled: {params.task}") diff --git a/examples/tutorials/10_async/00_base/010_multiturn/pyproject.toml b/examples/tutorials/10_async/00_base/010_multiturn/pyproject.toml new file mode 100644 index 000000000..8b0bb1c19 --- /dev/null +++ b/examples/tutorials/10_async/00_base/010_multiturn/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "ab010-multiturn" +version = "0.1.0" +description = "An AgentEx agent that echoes back the user's message" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 \ No newline at end of file diff --git a/examples/tutorials/10_async/00_base/010_multiturn/tests/test_agent.py b/examples/tutorials/10_async/00_base/010_multiturn/tests/test_agent.py new file mode 100644 index 000000000..43d283b8c --- /dev/null +++ b/examples/tutorials/10_async/00_base/010_multiturn/tests/test_agent.py @@ -0,0 +1,221 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: ab010-multiturn) +""" + +import os +import uuid +import asyncio +from typing import List + +import pytest +import pytest_asyncio +from test_utils.async_utils import ( + stream_agent_response, + send_event_and_poll_yielding, +) + +from agentex import AsyncAgentex +from agentex.types import TextContent +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "ab010-multiturn") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_id: str): + """Test sending an event and polling for the response.""" + # TODO: Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + await asyncio.sleep(1) # wait for state to be initialized + states = await client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + + state = states[0].state + assert state is not None + messages = state.get("messages", []) + assert isinstance(messages, List) + assert len(messages) == 1 # initial message + message = messages[0] + assert message == { + "role": "system", + "content": "You are a helpful assistant that can answer questions.", + } + + user_message = "Hello! Here is my test message" + messages = [] + + # Flags to track what we've received + user_message_found = False + agent_response_found = False + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message, + timeout=30, + sleep_interval=1.0, + ): + messages.append(message) + + # Validate messages as they arrive + if message.content and hasattr(message.content, "author"): + msg_text = getattr(message.content, "content", None) + if message.content.author == "user" and msg_text == user_message: + assert message.content == TextContent( + author="user", + content=user_message, + type="text", + ) + user_message_found = True + elif message.content.author == "agent": + assert user_message_found, "Agent response arrived before user message" + agent_response_found = True + + # Exit early if we've found all expected messages + if user_message_found and agent_response_found: + break + + assert user_message_found, "User message not found" + assert agent_response_found, "Agent response not found" + + await asyncio.sleep(1) # wait for state to be updated + states = await client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + state = states[0].state + messages = state.get("messages", []) + + assert isinstance(messages, list) + assert len(messages) == 3 + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_id: str): + """Test sending an event and streaming the response.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + await asyncio.sleep(1) # wait for state to be initialized + # Check initial state + states = await client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + + state = states[0].state + assert state is not None + messages = state.get("messages", []) + assert isinstance(messages, List) + assert len(messages) == 1 # initial message + message = messages[0] + assert message == { + "role": "system", + "content": "You are a helpful assistant that can answer questions.", + } + user_message = "Hello! Here is my streaming test message" + + # Collect events from stream + all_events = [] + + # Flags to track what we've received + user_message_found = False + agent_response_found = False + async def stream_messages() -> None: + nonlocal user_message_found, agent_response_found + async for event in stream_agent_response( + client=client, + task_id=task.id, + timeout=15, + ): + all_events.append(event) + + # Check events as they arrive + event_type = event.get("type") + if event_type == "full": + content = event.get("content", {}) + if content.get("content") == user_message and content.get("author") == "user": + # User message should come before agent response + assert not agent_response_found, "User message arrived after agent response (incorrect order)" + user_message_found = True + elif content.get("author") == "agent": + # Agent response should come after user message + assert user_message_found, "Agent response arrived before user message (incorrect order)" + agent_response_found = True + elif event_type == "done": + break + + # Exit early if we've found both messages + if user_message_found and agent_response_found: + break + + stream_task = asyncio.create_task(stream_messages()) + event_content = TextContentParam(type="text", author="user", content=user_message) + await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + await stream_task + + # Validate we received events + assert len(all_events) > 0, "No events received in streaming response" + assert user_message_found, "User message not found in stream" + assert agent_response_found, "Agent response not found in stream" + + # Verify the state has been updated + await asyncio.sleep(1) # wait for state to be updated + states = await client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + state = states[0].state + messages = state.get("messages", []) + + assert isinstance(messages, list) + assert len(messages) == 3 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/00_base/020_streaming/.dockerignore b/examples/tutorials/10_async/00_base/020_streaming/.dockerignore new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/examples/tutorials/10_async/00_base/020_streaming/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/00_base/020_streaming/Dockerfile b/examples/tutorials/10_async/00_base/020_streaming/Dockerfile new file mode 100644 index 000000000..447ca292d --- /dev/null +++ b/examples/tutorials/10_async/00_base/020_streaming/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 10_async/00_base/020_streaming/pyproject.toml /app/020_streaming/pyproject.toml +COPY 10_async/00_base/020_streaming/README.md /app/020_streaming/README.md + +WORKDIR /app/020_streaming + +# Copy the project code +COPY 10_async/00_base/020_streaming/project /app/020_streaming/project + +# Copy the test files +COPY 10_async/00_base/020_streaming/tests /app/020_streaming/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies (includes pytest) +RUN uv pip install --system .[dev] pytest-asyncio httpx + +# Set environment variables +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=ab020-streaming + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_async/00_base/020_streaming/README.md b/examples/tutorials/10_async/00_base/020_streaming/README.md new file mode 100644 index 000000000..17c19b57d --- /dev/null +++ b/examples/tutorials/10_async/00_base/020_streaming/README.md @@ -0,0 +1,47 @@ +# [Agentic] Streaming + +Stream responses in async agents using `adk.messages.create()` to send progressive updates. More flexible than sync streaming since you can send multiple messages at any time. + +## What You'll Learn +- How to stream with explicit message creation +- Difference between sync and async streaming patterns +- When to send multiple messages vs single streamed response + +## Prerequisites +- Development environment set up (see [main repo README](https://github.com/scaleapi/scale-agentex)) +- Backend services running: `make dev` from repository root +- Understanding of async basics (see [000_hello_acp](../000_hello_acp/)) + +## Quick Start + +```bash +cd examples/tutorials/10_async/00_base/020_streaming +uv run agentex agents run --manifest manifest.yaml +``` + +## Key Pattern + +```python +@acp.on_task_event_send +async def handle_event_send(params: SendEventParams): + # Send first message + await adk.messages.create(task_id=task_id, content=...) + + # Do work... + + # Send second message + await adk.messages.create(task_id=task_id, content=...) +``` + +Unlike sync streaming (which uses async generators), async streaming uses explicit message creation calls, giving you more control over when and what to send. + +## When to Use +- Multi-step processes with intermediate results +- Long-running operations with progress updates +- Agents that need to send messages at arbitrary times +- More complex streaming patterns than simple LLM responses + +## Why This Matters +Agentic streaming is more powerful than sync streaming. You can send messages at any time, from anywhere in your code, and even from background tasks. This flexibility is essential for complex agents with multiple concurrent operations. + +**Next:** [030_tracing](../030_tracing/) - Add observability to your agents diff --git a/examples/tutorials/10_async/00_base/020_streaming/dev.ipynb b/examples/tutorials/10_async/00_base/020_streaming/dev.ipynb new file mode 100644 index 000000000..f66be24df --- /dev/null +++ b/examples/tutorials/10_async/00_base/020_streaming/dev.ipynb @@ -0,0 +1,126 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "0", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"ab020-streaming\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "# (REQUIRED) Create a new task. For Agentic agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/tutorials/10_async/00_base/020_streaming/manifest.yaml b/examples/tutorials/10_async/00_base/020_streaming/manifest.yaml new file mode 100644 index 000000000..bd5673a6b --- /dev/null +++ b/examples/tutorials/10_async/00_base/020_streaming/manifest.yaml @@ -0,0 +1,119 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../../../ # Up to tutorials level to include test_utils + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - 10_async/00_base/020_streaming + - test_utils + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: 10_async/00_base/020_streaming/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: 10_async/00_base/020_streaming/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + +# Agent Configuration +# ----------------- +agent: + acp_type: async + + # Unique name for your agent + # Used for task routing and monitoring + name: ab020-streaming + + # Description of what your agent does + # Helps with documentation and discovery + description: A multiturn AgentEx agent that streams outputs + + # Temporal workflow configuration + # Set enabled: true to use Temporal workflows for long-running tasks + temporal: + enabled: false + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + # credentials: + # - env_var_name: OPENAI_API_KEY + # secret_name: openai-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + # env: + # - name: OPENAI_BASE_URL + # value: "https://api.openai.com/v1" + # - name: ACCOUNT_ID + # value: "your_account_id_here" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific files (deploy/*.yaml) + global: + agent: + name: "ab020-streaming" + description: "A multiturn AgentEx agent that streams outputs" + + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/10_async/00_base/020_streaming/project/__init__.py b/examples/tutorials/10_async/00_base/020_streaming/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/00_base/020_streaming/project/acp.py b/examples/tutorials/10_async/00_base/020_streaming/project/acp.py new file mode 100644 index 000000000..41e44912e --- /dev/null +++ b/examples/tutorials/10_async/00_base/020_streaming/project/acp.py @@ -0,0 +1,144 @@ +import os +from typing import List + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams +from agentex.lib.types.fastacp import AsyncACPConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.types.llm_messages import Message, LLMConfig, UserMessage, SystemMessage, AssistantMessage +from agentex.lib.sdk.fastacp.fastacp import FastACP + +logger = make_logger(__name__) + + +# Create an ACP server + +# !!! Warning: Because "Async" ACPs are designed to be fully asynchronous, race conditions can occur if parallel events are sent. It is highly recommended to use the "temporal" type in the AsyncACPConfig instead to handle complex use cases. The "base" ACP is only designed to be used for simple use cases and for learning purposes. +acp = FastACP.create( + acp_type="async", + config=AsyncACPConfig(type="base"), +) + +class StateModel(BaseModel): + messages: List[Message] + + +@acp.on_task_create +async def handle_task_create(params: CreateTaskParams): + # Upon task creation, we initialize the task state with a system message. + # This will be fetched by the `on_task_event_send` handler when each event is sent. + + ######################################################### + # 1. Initialize the task state. + ######################################################### + + state = StateModel(messages=[SystemMessage(content="You are a helpful assistant that can answer questions.")]) + await adk.state.create(task_id=params.task.id, agent_id=params.agent.id, state=state) + +@acp.on_task_event_send +async def handle_event_send(params: SendEventParams): + # !!! Warning: Because "Agentic" ACPs are designed to be fully asynchronous, race conditions can occur if parallel events are sent. It is highly recommended to use the "temporal" type in the AgenticACPConfig instead to handle complex use cases. The "base" ACP is only designed to be used for simple use cases and for learning purposes. + + ######################################################### + # 2. Validate the event content. + ######################################################### + if not params.event.content: + return + + if params.event.content.type != "text": + raise ValueError(f"Expected text message, got {params.event.content.type}") + + if params.event.content.author != "user": + raise ValueError(f"Expected user message, got {params.event.content.author}") + + ######################################################### + # 3. Echo back the user's message. + ######################################################### + + await adk.messages.create( + task_id=params.task.id, + trace_id=params.task.id, + content=params.event.content, + ) + + ######################################################### + # 4. If the OpenAI API key is not set, send a message to the user to let them know. + ######################################################### + + if not os.environ.get("OPENAI_API_KEY"): + await adk.messages.create( + task_id=params.task.id, + trace_id=params.task.id, + content=TextContent( + author="agent", + content="Hey, sorry I'm unable to respond to your message because you're running this example without an OpenAI API key. Please set the OPENAI_API_KEY environment variable to run this example. Do this by either by adding a .env file to the project/ directory or by setting the environment variable in your terminal.", + ), + ) + + ######################################################### + # 5. Retrieve the task state. + ######################################################### + + task_state = await adk.state.get_by_task_and_agent(task_id=params.task.id, agent_id=params.agent.id) + if not task_state: + raise ValueError("Task state not found - ensure task was properly initialized") + state = StateModel.model_validate(task_state.state) + + ######################################################### + # 6. Add the new user message to the message history + ######################################################### + + # Safely extract content from the event + content_text = "" + if hasattr(params.event.content, 'content'): + content_val = getattr(params.event.content, 'content', '') + if isinstance(content_val, str): + content_text = content_val + state.messages.append(UserMessage(content=content_text)) + + ######################################################### + # 7. (👋) Call an LLM to respond to the user's message + ######################################################### + + # When we use the streaming version of chat completion, we can either use the `chat_completion_stream_auto_send` method, or we can use the `chat_completion_stream` method. Here is the difference: + + # `chat_completion_stream_auto_send` - This is the "managed version" of the streaming method. It will automatically send the response to the client as an agent TaskMessage. + + # `chat_completion_stream` - This is the "unmanaged version" of the streaming method. It will return a generator of chat completion chunks. You can then do whatever you want with the chunks, such as sending them to the client as an agent message, or storing them in the task state, or whatever you want. + + # Here we use the `chat_completion_stream_auto_send` method. + ######################################################### + + task_message = await adk.providers.litellm.chat_completion_stream_auto_send( + task_id=params.task.id, + llm_config=LLMConfig(model="gpt-4o-mini", messages=state.messages, stream=True), + trace_id=params.task.id, + ) + + # Safely extract content from the task message + response_text = "" + if task_message.content and hasattr(task_message.content, 'content'): # type: ignore[union-attr] + content_val = getattr(task_message.content, 'content', '') # type: ignore[union-attr] + if isinstance(content_val, str): + response_text = content_val + state.messages.append(AssistantMessage(content=response_text)) + + ######################################################### + # 8. Store the messages in the task state for the next turn + ######################################################### + + await adk.state.update( + state_id=task_state.id, + task_id=params.task.id, + agent_id=params.agent.id, + state=state, + trace_id=params.task.id, + ) + +@acp.on_task_cancel +async def handle_task_cancel(params: CancelTaskParams): + """Default task cancel handler""" + logger.info(f"Task canceled: {params.task}") + diff --git a/examples/tutorials/10_async/00_base/020_streaming/pyproject.toml b/examples/tutorials/10_async/00_base/020_streaming/pyproject.toml new file mode 100644 index 000000000..271bcaac9 --- /dev/null +++ b/examples/tutorials/10_async/00_base/020_streaming/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "ab020-streaming" +version = "0.1.0" +description = "A multiturn AgentEx agent that streams outputs" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 \ No newline at end of file diff --git a/examples/tutorials/10_async/00_base/020_streaming/tests/test_agent.py b/examples/tutorials/10_async/00_base/020_streaming/tests/test_agent.py new file mode 100644 index 000000000..c55525191 --- /dev/null +++ b/examples/tutorials/10_async/00_base/020_streaming/tests/test_agent.py @@ -0,0 +1,219 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: ab020-streaming) +""" + +import os +import uuid +import asyncio +from typing import List + +import pytest +import pytest_asyncio +from test_utils.async_utils import ( + stream_agent_response, + send_event_and_poll_yielding, +) + +from agentex import AsyncAgentex +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "ab020-streaming") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_id: str): + """Test sending an event and polling for the response.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + await asyncio.sleep(1) # wait for state to be initialized + states = await client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + + state = states[0].state + assert state is not None + messages = state.get("messages", []) + assert isinstance(messages, List) + assert len(messages) == 1 # initial message + message = messages[0] + assert message == { + "role": "system", + "content": "You are a helpful assistant that can answer questions.", + } + + user_message = "Hello! Here is my test message" + messages = [] + + # Flags to track what we've received + user_message_found = False + agent_response_found = False + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message, + timeout=30, + sleep_interval=1.0, + yield_updates=False, + ): + messages.append(message) + + # Validate messages as they come in + if message.content and hasattr(message.content, "author"): + if message.content.author == "user" and message.content.content == user_message: + user_message_found = True + elif message.content.author == "agent": + # Agent response should come after user message + assert user_message_found, "Agent response arrived before user message" + agent_response_found = True + + # Exit early if we've found all expected messages + if user_message_found and agent_response_found: + break + + # Validate we received expected messages + assert len(messages) >= 2, "Expected at least 2 messages (user + agent)" + assert user_message_found, "User message not found" + assert agent_response_found, "Agent response not found" + + # assert the state has been updated + await asyncio.sleep(1) # wait for state to be updated + states = await client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + state = states[0].state + messages = state.get("messages", []) + + assert isinstance(messages, list) + assert len(messages) == 3 + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_id: str): + """Test sending an event and streaming the response.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Check initial state + await asyncio.sleep(1) # wait for state to be initialized + states = await client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + + state = states[0].state + assert state is not None + messages = state.get("messages", []) + assert isinstance(messages, List) + assert len(messages) == 1 # initial message + message = messages[0] + assert message == { + "role": "system", + "content": "You are a helpful assistant that can answer questions.", + } + user_message = "Hello! This is my first message. Can you please tell me something interesting about yourself?" + + # Collect events from stream + all_events = [] + + # Flags to track what we've received + user_message_found = False + full_agent_message_found = False + delta_messages_found = False + async def stream_messages() -> None: + nonlocal user_message_found, full_agent_message_found, delta_messages_found + async for event in stream_agent_response( + client=client, + task_id=task.id, + timeout=15, + ): + all_events.append(event) + + # Check events as they arrive + event_type = event.get("type") + if event_type == "full": + content = event.get("content", {}) + if content.get("content") == user_message and content.get("author") == "user": + user_message_found = True + elif content.get("author") == "agent": + full_agent_message_found = True + elif event_type == "delta": + delta_messages_found = True + elif event_type == "done": + break + + # Exit early if we've found all expected messages + if user_message_found and full_agent_message_found and delta_messages_found: + break + + stream_task = asyncio.create_task(stream_messages()) + event_content = TextContentParam(type="text", author="user", content=user_message) + await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + await stream_task + + # Validate we received events + assert len(all_events) > 0, "No events received in streaming response" + assert user_message_found, "User message not found in stream" + assert full_agent_message_found, "Full agent message not found in stream" + assert delta_messages_found, "Delta messages not found in stream (streaming response expected)" + + # Verify the state has been updated + await asyncio.sleep(1) # wait for state to be updated + states = await client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + state: dict[str, object] = states[0].state + messages = state.get("messages", []) + + assert isinstance(messages, list) + assert len(messages) == 3 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/00_base/030_tracing/.dockerignore b/examples/tutorials/10_async/00_base/030_tracing/.dockerignore new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/examples/tutorials/10_async/00_base/030_tracing/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/00_base/030_tracing/Dockerfile b/examples/tutorials/10_async/00_base/030_tracing/Dockerfile new file mode 100644 index 000000000..2aee7e1dd --- /dev/null +++ b/examples/tutorials/10_async/00_base/030_tracing/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 10_async/00_base/030_tracing/pyproject.toml /app/030_tracing/pyproject.toml +COPY 10_async/00_base/030_tracing/README.md /app/030_tracing/README.md + +WORKDIR /app/030_tracing + +# Copy the project code +COPY 10_async/00_base/030_tracing/project /app/030_tracing/project + +# Copy the test files +COPY 10_async/00_base/030_tracing/tests /app/030_tracing/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies (includes pytest) +RUN uv pip install --system .[dev] pytest-asyncio httpx + +# Set environment variables +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=ab030-tracing + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_async/00_base/030_tracing/README.md b/examples/tutorials/10_async/00_base/030_tracing/README.md new file mode 100644 index 000000000..3c66248e8 --- /dev/null +++ b/examples/tutorials/10_async/00_base/030_tracing/README.md @@ -0,0 +1,86 @@ +# [Agentic] Tracing + +Add observability to your agents with spans and traces using `adk.tracing.start_span()`. Track execution flow, measure performance, and debug complex agent behaviors. + +## What You'll Learn +- How to instrument agents with tracing +- Creating hierarchical spans to track operations +- Viewing traces in Scale Groundplane +- Performance debugging with observability + +## Prerequisites +- Development environment set up (see [main repo README](https://github.com/scaleapi/scale-agentex)) +- Backend services running: `make dev` from repository root +- Understanding of async agents (see [000_hello_acp](../000_hello_acp/)) + +## Quick Start + +```bash +cd examples/tutorials/10_async/00_base/030_tracing +uv run agentex agents run --manifest manifest.yaml +``` + +## Key Pattern + +```python +# Start a span to track an operation +span = await adk.tracing.start_span( + trace_id=task.id, + name="LLM Call", + input={"prompt": prompt} +) + +# Do work... + +# End span with output +await adk.tracing.end_span( + span_id=span.id, + output={"response": response} +) +``` + +Spans create a hierarchical view of agent execution, making it easy to see which operations take time and where errors occur. + +## Token Usage & Cost Tracking + +Token usage on spans is what the backend bills from, and it reads two shapes: + +- **Per-turn aggregate** — `span.data["usage"]` + `span.data["cost_usd"]`. Emit at most + once per turn, holding that turn's own usage (not a session-cumulative total). When a + trace has an aggregate, the backend keeps it and de-dups all per-call spans against it. +- **Per-call detail** — `span.output["usage"]`. Optional; the SDK's LLM adapters + (litellm, OpenAI Agents SDK, LangGraph) emit this automatically. Summed only when no + aggregate exists in the trace. + +Record the turn rollup with `adk.tracing.turn_span()` instead of hand-writing usage keys. +It accepts the harness `TurnUsage` that every turn adapter reports (`LangGraphTurn.usage()`, +`run_turn(...).usage`, `ClaudeCodeTurn.usage()`, ...), cost included: + +```python +async with adk.tracing.turn_span( + trace_id=task.id, + name="turn", + input={"prompt": prompt}, + task_id=task.id, +) as turn: + result = await run_turn(...) + turn.output = {"response": result.final_output} + turn.record_usage(result.usage) # TurnUsage; cost_usd stamped automatically +``` + +**Never put usage on both a rollup span's `output` and its per-call children's +`output`** — that double-counts. `turn_span` writes the aggregate to `data`, so child +spans stay safe to emit. Recognized token keys: `input_tokens`/`prompt_tokens`, +`output_tokens`/`completion_tokens`, `cached_input_tokens`/`cached_tokens`, +`reasoning_tokens`; cost is `cost_usd`. + +## When to Use +- Debugging complex agent behaviors +- Performance optimization and bottleneck identification +- Production monitoring and observability +- Understanding execution flow in multi-step agents + +## Why This Matters +Without tracing, debugging agents is like flying blind. Tracing gives you visibility into what your agent is doing, how long operations take, and where failures occur. It's essential for production agents and invaluable during development. + +**Next:** [040_other_sdks](../040_other_sdks/) - Integrate any SDK or framework diff --git a/examples/tutorials/10_async/00_base/030_tracing/dev.ipynb b/examples/tutorials/10_async/00_base/030_tracing/dev.ipynb new file mode 100644 index 000000000..f667737bb --- /dev/null +++ b/examples/tutorials/10_async/00_base/030_tracing/dev.ipynb @@ -0,0 +1,126 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "0", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"ab030-tracing\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "# (REQUIRED) Create a new task. For Agentic agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/tutorials/10_async/00_base/030_tracing/manifest.yaml b/examples/tutorials/10_async/00_base/030_tracing/manifest.yaml new file mode 100644 index 000000000..3c9b2c147 --- /dev/null +++ b/examples/tutorials/10_async/00_base/030_tracing/manifest.yaml @@ -0,0 +1,119 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../../../ # Up to tutorials level to include test_utils + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - 10_async/00_base/030_tracing + - test_utils + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: 10_async/00_base/030_tracing/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: 10_async/00_base/030_tracing/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + +# Agent Configuration +# ----------------- +agent: + acp_type: async + + # Unique name for your agent + # Used for task routing and monitoring + name: ab030-tracing + + # Description of what your agent does + # Helps with documentation and discovery + description: An AgentEx agent that demonstrates how to do hierarchical and custom tracing + + # Temporal workflow configuration + # Set enabled: true to use Temporal workflows for long-running tasks + temporal: + enabled: false + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + # credentials: + # - env_var_name: OPENAI_API_KEY + # secret_name: openai-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + # env: + # - name: OPENAI_BASE_URL + # value: "https://api.openai.com/v1" + # - name: ACCOUNT_ID + # value: "your_account_id_here" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific files (deploy/*.yaml) + global: + agent: + name: "ab030-tracing" + description: "An AgentEx agent that demonstrates how to do hierarchical and custom tracing" + + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/examples/tutorials/10_async/00_base/030_tracing/project/__init__.py b/examples/tutorials/10_async/00_base/030_tracing/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/00_base/030_tracing/project/acp.py b/examples/tutorials/10_async/00_base/030_tracing/project/acp.py new file mode 100644 index 000000000..a46e77698 --- /dev/null +++ b/examples/tutorials/10_async/00_base/030_tracing/project/acp.py @@ -0,0 +1,167 @@ +import os +from typing import List + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams +from agentex.lib.types.fastacp import AsyncACPConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.types.llm_messages import Message, LLMConfig, UserMessage, SystemMessage, AssistantMessage +from agentex.lib.sdk.fastacp.fastacp import FastACP + +logger = make_logger(__name__) + + +# Create an ACP server + +# !!! Warning: Because "Async" ACPs are designed to be fully asynchronous, race conditions can occur if parallel events are sent. It is highly recommended to use the "temporal" type in the AsyncACPConfig instead to handle complex use cases. The "base" ACP is only designed to be used for simple use cases and for learning purposes. +acp = FastACP.create( + acp_type="async", + config=AsyncACPConfig(type="base"), +) + +class StateModel(BaseModel): + messages: List[Message] + turn_number: int + + +@acp.on_task_create +async def handle_task_create(params: CreateTaskParams): + # Upon task creation, we initialize the task state with a system message. + # This will be fetched by the `on_task_event_send` handler when each event is sent. + + ######################################################### + # 1. Initialize the task state. + ######################################################### + + state = StateModel( + messages=[SystemMessage(content="You are a helpful assistant that can answer questions.")], + turn_number=0, + ) + await adk.state.create(task_id=params.task.id, agent_id=params.agent.id, state=state) + +@acp.on_task_event_send +async def handle_event_send(params: SendEventParams): + # !!! Warning: Because "Agentic" ACPs are designed to be fully asynchronous, race conditions can occur if parallel events are sent. It is highly recommended to use the "temporal" type in the AgenticACPConfig instead to handle complex use cases. The "base" ACP is only designed to be used for simple use cases and for learning purposes. + + ######################################################### + # 2. Validate the event content. + ######################################################### + if not params.event.content: + return + + if params.event.content.type != "text": + raise ValueError(f"Expected text message, got {params.event.content.type}") + + if params.event.content.author != "user": + raise ValueError(f"Expected user message, got {params.event.content.author}") + + ######################################################### + # 3. Retrieve the task state. + ######################################################### + + task_state = await adk.state.get_by_task_and_agent(task_id=params.task.id, agent_id=params.agent.id) + if not task_state: + raise ValueError("Task state not found - ensure task was properly initialized") + state = StateModel.model_validate(task_state.state) + state.turn_number += 1 + + # Add the new user message to the message history + # Safely extract content from the event + content_text = "" + if hasattr(params.event.content, 'content'): + content_val = getattr(params.event.content, 'content', '') + if isinstance(content_val, str): + content_text = content_val + state.messages.append(UserMessage(content=content_text)) + + ######################################################### + # 4. (👋) Create a tracing span. + ######################################################### + + # Create a tracing span. All of the Agentex ADK methods are "auto-traced", but by default show up as a flat list associated with a single trace id (which is usually just set to the task id by default). + # If you want to create a hierarchical trace, you can do so by creating spans in your business logic and passing the span id to the ADK methods. Traces will be grouped under parent spans for better readability. + # If you're not trying to create a hierarchical trace, but just trying to create a custom span to trace something, you can use this too to create a custom span that is associate with your trace by trace ID. + + async with adk.tracing.span( + trace_id=params.task.id, + name=f"Turn {state.turn_number}", + input=state + ) as span: + + ######################################################### + # 5. Echo back the user's message so it shows up in the UI. + ######################################################### + + # (👋) Notice that we pass the parent_span_id to the ADK methods to create a hierarchical trace. + await adk.messages.create( + task_id=params.task.id, + trace_id=params.task.id, + content=params.event.content, + parent_span_id=span.id if span else None, + ) + + ######################################################### + # 6. If the OpenAI API key is not set, send a message to the user to let them know. + ######################################################### + + # (👋) Notice that we pass the parent_span_id to the ADK methods to create a hierarchical trace. + if not os.environ.get("OPENAI_API_KEY"): + await adk.messages.create( + task_id=params.task.id, + trace_id=params.task.id, + content=TextContent( + author="agent", + content="Hey, sorry I'm unable to respond to your message because you're running this example without an OpenAI API key. Please set the OPENAI_API_KEY environment variable to run this example. Do this by either by adding a .env file to the project/ directory or by setting the environment variable in your terminal.", + ), + parent_span_id=span.id if span else None, + ) + + ######################################################### + # 7. Call an LLM to respond to the user's message + ######################################################### + + # (👋) Notice that we pass the parent_span_id to the ADK methods to create a hierarchical trace. + task_message = await adk.providers.litellm.chat_completion_stream_auto_send( + task_id=params.task.id, + llm_config=LLMConfig(model="gpt-4o-mini", messages=state.messages, stream=True), + trace_id=params.task.id, + parent_span_id=span.id if span else None, + ) + + # Safely extract content from the task message + response_text = "" + if task_message.content and hasattr(task_message.content, 'content'): # type: ignore[union-attr] + content_val = getattr(task_message.content, 'content', '') # type: ignore[union-attr] + if isinstance(content_val, str): + response_text = content_val + state.messages.append(AssistantMessage(content=response_text)) + + ######################################################### + # 8. Store the messages in the task state for the next turn + ######################################################### + + # (👋) Notice that we pass the parent_span_id to the ADK methods to create a hierarchical trace. + await adk.state.update( + state_id=task_state.id, + task_id=params.task.id, + agent_id=params.agent.id, + state=state, + trace_id=params.task.id, + parent_span_id=span.id if span else None, + ) + + ######################################################### + # 9. (👋) Set the span output to the state for the next turn + ######################################################### + + # (👋) You can store an arbitrary pydantic model or dictionary in the span output. The idea of a span is that it easily allows you to compare the input and output of a span to see what the wrapped function did. + # In this case, the state is comprehensive and expressive, so we just store the change in state that occured. + if span: + span.output = state # type: ignore[misc] + +@acp.on_task_cancel +async def handle_task_cancel(params: CancelTaskParams): + """Default task cancel handler""" + logger.info(f"Task canceled: {params.task}") diff --git a/examples/tutorials/10_async/00_base/030_tracing/pyproject.toml b/examples/tutorials/10_async/00_base/030_tracing/pyproject.toml new file mode 100644 index 000000000..fe1468a87 --- /dev/null +++ b/examples/tutorials/10_async/00_base/030_tracing/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "ab030-tracing" +version = "0.1.0" +description = "An AgentEx agent that demonstrates how to do hierarchical and custom tracing" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 \ No newline at end of file diff --git a/examples/tutorials/10_async/00_base/030_tracing/tests/test_agent.py b/examples/tutorials/10_async/00_base/030_tracing/tests/test_agent.py new file mode 100644 index 000000000..0cc65c566 --- /dev/null +++ b/examples/tutorials/10_async/00_base/030_tracing/tests/test_agent.py @@ -0,0 +1,124 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: ab030-tracing) +""" + +import os + +import pytest +import pytest_asyncio + +from agentex import AsyncAgentex + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "ab030-tracing") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_id: str): + """Test sending an event and polling for the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # TODO: Send an event and poll for response using the helper function + # messages = [] + # async for message in send_event_and_poll_yielding( + # client=client, + # agent_id=agent_id, + # task_id=task.id, + # user_message="Your test message here", + # timeout=30, + # sleep_interval=1.0, + # ): + # messages.append(message) + + # TODO: Validate the response + # assert len(messages) > 0, "No response received from agent" + # assert validate_text_in_response("expected text", messages) + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_id: str): + """Test sending an event and streaming the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # TODO: Send an event and stream the response using the helper function + # all_events = [] + # + # async def collect_stream_events(): + # async for event in stream_agent_response( + # client=client, + # task_id=task.id, + # timeout=30, + # ): + # all_events.append(event) + # + # stream_task = asyncio.create_task(collect_stream_events()) + # + # event_content = TextContentParam(type="text", author="user", content="Your test message here") + # await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + # + # await stream_task + + # TODO: Validate the streaming response + # assert len(all_events) > 0, "No events received in streaming response" + # + # text_found = False + # for event in all_events: + # content = event.get("content", {}) + # if "expected text" in str(content).lower(): + # text_found = True + # break + # assert text_found, "Expected text not found in streaming response" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/00_base/040_other_sdks/.dockerignore b/examples/tutorials/10_async/00_base/040_other_sdks/.dockerignore new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/examples/tutorials/10_async/00_base/040_other_sdks/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/00_base/040_other_sdks/Dockerfile b/examples/tutorials/10_async/00_base/040_other_sdks/Dockerfile new file mode 100644 index 000000000..2e0ee6ef0 --- /dev/null +++ b/examples/tutorials/10_async/00_base/040_other_sdks/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 10_async/00_base/040_other_sdks/pyproject.toml /app/040_other_sdks/pyproject.toml +COPY 10_async/00_base/040_other_sdks/README.md /app/040_other_sdks/README.md + +WORKDIR /app/040_other_sdks + +# Copy the project code +COPY 10_async/00_base/040_other_sdks/project /app/040_other_sdks/project + +# Copy the test files +COPY 10_async/00_base/040_other_sdks/tests /app/040_other_sdks/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies (includes pytest) +RUN uv pip install --system .[dev] pytest-asyncio httpx + +# Set environment variables +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=ab040-other-sdks + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_async/00_base/040_other_sdks/README.md b/examples/tutorials/10_async/00_base/040_other_sdks/README.md new file mode 100644 index 000000000..5c086233b --- /dev/null +++ b/examples/tutorials/10_async/00_base/040_other_sdks/README.md @@ -0,0 +1,45 @@ +# [Agentic] Other SDKs + +Agents are just Python code - integrate any SDK you want (OpenAI, Anthropic, LangChain, LlamaIndex, custom libraries, etc.). AgentEx doesn't lock you into a specific framework. + +## What You'll Learn +- How to integrate OpenAI, Anthropic, or any SDK +- What AgentEx provides vs what you bring +- Framework-agnostic agent development +- Building agents with your preferred tools + +## Prerequisites +- Development environment set up (see [main repo README](https://github.com/scaleapi/scale-agentex)) +- Backend services running: `make dev` from repository root +- Understanding of async agents (see [000_hello_acp](../000_hello_acp/)) + +## Quick Start + +```bash +cd examples/tutorials/10_async/00_base/040_other_sdks +uv run agentex agents run --manifest manifest.yaml +``` + +## Key Insight + +AgentEx provides: +- ACP protocol implementation (task management, message handling) +- Deployment infrastructure +- Monitoring and observability + +You provide: +- Agent logic using whatever SDK/library you want +- Tools and capabilities specific to your use case + +Mix and match OpenAI, Anthropic, LangChain, or roll your own - it's all just Python. + +## When to Use +- You have an existing agent codebase to migrate +- Your team prefers specific SDKs or frameworks +- You need features from multiple providers +- You want full control over your agent logic + +## Why This Matters +AgentEx is infrastructure, not a framework. We handle deployment, task management, and protocol implementation - you handle the agent logic with whatever tools you prefer. This keeps you flexible and avoids vendor lock-in. + +**Next:** [080_batch_events](../080_batch_events/) - See when you need Temporal diff --git a/examples/tutorials/10_async/00_base/040_other_sdks/dev.ipynb b/examples/tutorials/10_async/00_base/040_other_sdks/dev.ipynb new file mode 100644 index 000000000..abb1b9e73 --- /dev/null +++ b/examples/tutorials/10_async/00_base/040_other_sdks/dev.ipynb @@ -0,0 +1,126 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "0", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"ab040-other-sdks\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "# (REQUIRED) Create a new task. For Agentic agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello tell me the latest news about AI and AI startups\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=20,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/tutorials/10_async/00_base/040_other_sdks/manifest.yaml b/examples/tutorials/10_async/00_base/040_other_sdks/manifest.yaml new file mode 100644 index 000000000..8fd324c13 --- /dev/null +++ b/examples/tutorials/10_async/00_base/040_other_sdks/manifest.yaml @@ -0,0 +1,119 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../../../ # Up to tutorials level to include test_utils + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - 10_async/00_base/040_other_sdks + - test_utils + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: 10_async/00_base/040_other_sdks/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: 10_async/00_base/040_other_sdks/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + +# Agent Configuration +# ----------------- +agent: + acp_type: async + + # Unique name for your agent + # Used for task routing and monitoring + name: ab040-other-sdks + + # Description of what your agent does + # Helps with documentation and discovery + description: An AgentEx agent that uses other SDKs to show the flexibilty that agents are just code + + # Temporal workflow configuration + # Set enabled: true to use Temporal workflows for long-running tasks + temporal: + enabled: false + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + # credentials: + # - env_var_name: OPENAI_API_KEY + # secret_name: openai-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + # env: + # - name: OPENAI_BASE_URL + # value: "https://api.openai.com/v1" + # - name: ACCOUNT_ID + # value: "your_account_id_here" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific files (deploy/*.yaml) + global: + agent: + name: "ab040-other-sdks" + description: "An AgentEx agent that uses other SDKs to show the flexibilty that agents are just code" + + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/examples/tutorials/10_async/00_base/040_other_sdks/project/__init__.py b/examples/tutorials/10_async/00_base/040_other_sdks/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/00_base/040_other_sdks/project/acp.py b/examples/tutorials/10_async/00_base/040_other_sdks/project/acp.py new file mode 100644 index 000000000..d2ec84fcd --- /dev/null +++ b/examples/tutorials/10_async/00_base/040_other_sdks/project/acp.py @@ -0,0 +1,375 @@ +from __future__ import annotations + +import os +import json +from typing import Dict, List, Optional +from contextlib import AsyncExitStack, asynccontextmanager + +from mcp import StdioServerParameters +from agents import Agent, Runner +from pydantic import BaseModel +from agents.mcp import MCPServerStdio +from openai.types.responses import ( + ResponseCompletedEvent, + ResponseTextDeltaEvent, + ResponseFunctionToolCall, + ResponseOutputItemDoneEvent, +) + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams +from agentex.lib.types.fastacp import AsyncACPConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.types.task_message_delta import TextDelta +from agentex.types.task_message_update import ( + StreamTaskMessageFull, + StreamTaskMessageDelta, +) +from agentex.types.task_message_content import ToolRequestContent, ToolResponseContent +from agentex.lib.core.services.adk.streaming import StreamingTaskMessageContext + +logger = make_logger(__name__) + + +# Create an ACP server + +# !!! Warning: Because "Async" ACPs are designed to be fully asynchronous, race conditions can occur if parallel events are sent. It is highly recommended to use the "temporal" type in the AsyncACPConfig instead to handle complex use cases. The "base" ACP is only designed to be used for simple use cases and for learning purposes. +acp = FastACP.create( + acp_type="async", + config=AsyncACPConfig(type="base"), +) + + +class StateModel(BaseModel): + input_list: List[dict] + turn_number: int + + +MCP_SERVERS = [ + StdioServerParameters( + command="npx", + args=["-y", "@modelcontextprotocol/server-sequential-thinking"], + ), + StdioServerParameters( + command="uvx", args=["openai-websearch-mcp"], env={"OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY", "")} + ), +] + + +@acp.on_task_create +async def handle_task_create(params: CreateTaskParams): + # Upon task creation, we initialize the task state with a system message. + # This will be fetched by the `on_task_event_send` handler when each event is sent. + state = StateModel( + input_list=[], + turn_number=0, + ) + await adk.state.create(task_id=params.task.id, agent_id=params.agent.id, state=state) + + +@acp.on_task_event_send +async def handle_event_send(params: SendEventParams): + # !!! Warning: Because "Async" ACPs are designed to be fully asynchronous, race conditions can occur if parallel events are sent. It is highly recommended to use the "temporal" type in the AsyncACPConfig instead to handle complex use cases. The "base" ACP is only designed to be used for simple use cases and for learning purposes. + + if not params.event.content: + return + + if params.event.content.type != "text": + raise ValueError(f"Expected text message, got {params.event.content.type}") + + if params.event.content.author != "user": + raise ValueError(f"Expected user message, got {params.event.content.author}") + + # Retrieve the task state. Each event is handled as a new turn, so we need to get the state for the current turn. + task_state = await adk.state.get_by_task_and_agent(task_id=params.task.id, agent_id=params.agent.id) + if not task_state: + raise ValueError("Task state not found - ensure task was properly initialized") + state = StateModel.model_validate(task_state.state) + state.turn_number += 1 + # Add the new user message to the message history + state.input_list.append({"role": "user", "content": params.event.content.content}) + + async with adk.tracing.span(trace_id=params.task.id, name=f"Turn {state.turn_number}", input=state) as span: + # Echo back the user's message so it shows up in the UI. This is not done by default so the agent developer has full control over what is shown to the user. + await adk.messages.create( + task_id=params.task.id, + trace_id=params.task.id, + content=params.event.content, + parent_span_id=span.id if span else None, + ) + + if not os.environ.get("OPENAI_API_KEY"): + await adk.messages.create( + task_id=params.task.id, + trace_id=params.task.id, + content=TextContent( + author="agent", + content="Hey, sorry I'm unable to respond to your message because you're running this example without an OpenAI API key. Please set the OPENAI_API_KEY environment variable to run this example. Do this by either by adding a .env file to the project/ directory or by setting the environment variable in your terminal.", + ), + parent_span_id=span.id if span else None, + ) + + ######################################################### + # (👋) Call an LLM to respond to the user's message using custom streaming + ######################################################### + + # This demonstrates advanced streaming patterns using adk.streaming. + # We'll show two different streaming approaches: + # 1. Simple streaming with context managers for complete messages (tool calls) + # 2. Delta-based streaming for incremental text responses + run_result = await run_openai_agent_with_custom_streaming( + task_id=params.task.id, + trace_id=params.task.id, + input_list=state.input_list, + mcp_server_params=MCP_SERVERS, + agent_name="Tool-Enabled Assistant", + agent_instructions="""You are a helpful assistant that can answer questions using various tools. + You have access to sequential thinking and web search capabilities through MCP servers. + Use these tools when appropriate to provide accurate and well-reasoned responses.""", + parent_span_id=span.id if span else None, + ) + + state.input_list = run_result.to_input_list() + logger.info(f"state.input_list: {state.input_list}") + logger.info(f"state: {state}") + # Store the messages in the task state for the next turn + await adk.state.update( + state_id=task_state.id, + task_id=params.task.id, + agent_id=params.agent.id, + state=state, + trace_id=params.task.id, + parent_span_id=span.id if span else None, + ) + logger.info("successfully updated the state") + # Set the span output to the state for the next turn + if span: + span.output = state + + +@acp.on_task_cancel +async def handle_task_cancel(params: CancelTaskParams): + """Default task cancel handler""" + logger.info(f"Task canceled: {params.task}") + + +######################################################## +# Helper functions that integrate Agentex primitives with other SDKs like OpenAI Agents +######################################################## + + +@asynccontextmanager +async def mcp_server_context(mcp_server_params: list[StdioServerParameters]): + """Context manager for MCP servers.""" + servers = [] + for params in mcp_server_params: + server = MCPServerStdio( + name=f"Server: {params.command}", + params=params.model_dump(), + cache_tools_list=True, + client_session_timeout_seconds=60, + ) + servers.append(server) + + async with AsyncExitStack() as stack: + for server in servers: + await stack.enter_async_context(server) + yield servers + + +def redact_mcp_server_params( + mcp_server_params: list[StdioServerParameters], +) -> list[StdioServerParameters]: + """Redact MCP server params.""" + return [ + StdioServerParameters( + **{k: v for k, v in server_param.model_dump().items() if k != "env"}, + env={k: "********" for k in server_param.env} if server_param.env else None, + ) + for server_param in mcp_server_params + ] + + +async def run_openai_agent_with_custom_streaming( + task_id: str, + trace_id: str, + input_list: list[Dict], + mcp_server_params: list[StdioServerParameters], + agent_name: str, + agent_instructions: str, + parent_span_id: Optional[str] = None, +): + """ + Run an OpenAI agent with custom streaming using adk.streaming. + + This demonstrates advanced streaming patterns using adk.streaming. + We'll show two different streaming approaches: + 1. Simple streaming with context managers for complete messages (tool calls) + 2. Delta-based streaming for incremental text responses + """ + + tool_call_map: Dict[str, ResponseFunctionToolCall] = {} + + redacted_mcp_server_params = redact_mcp_server_params(mcp_server_params) + + result = None + async with adk.tracing.span( + trace_id=trace_id, + name="run_agent_with_custom_streaming", + input={ + "input_list": input_list, + "mcp_server_params": redacted_mcp_server_params, + "agent_name": agent_name, + "agent_instructions": agent_instructions, + }, + parent_id=parent_span_id, + ) as span: + async with mcp_server_context(mcp_server_params) as servers: + agent = Agent( + name=agent_name, + instructions=agent_instructions, + mcp_servers=servers, + ) + + # Run with streaming enabled + result = Runner.run_streamed(starting_agent=agent, input=input_list) + + ######################################################### + # (👋) For complete messages like tool calls we will use a with block to create a streaming context, but for text deltas we will use a streaming context that is created and closed manually. To make sure we close all streaming contexts we will track the item_id and close them all at the end. + ######################################################### + + item_id_to_streaming_context: Dict[str, StreamingTaskMessageContext] = {} + unclosed_item_ids: set[str] = set() + + try: + # Process streaming events with TaskMessage creation + async for event in result.stream_events(): + if event.type == "run_item_stream_event": + if event.item.type == "tool_call_item": + tool_call_item = event.item.raw_item + tool_call_map[tool_call_item.call_id] = tool_call_item + + logger.info(f"Tool call item: {tool_call_item}") + + tool_request_content = ToolRequestContent( + author="agent", + tool_call_id=tool_call_item.call_id, + name=tool_call_item.name, + arguments=json.loads(tool_call_item.arguments), + ) + + # (👋) Create a streaming context for the tool call + # Since a tool call is a complete message, we can use a with block to create a streaming context. This will take care of creating a TaskMessage, sending a START event, and sending a DONE event when the context is closed. Of course you will also want to stream the content of the tool call so clients that are subscribed to streaming updates to the task will see the tool call. + async with adk.streaming.streaming_task_message_context( + task_id=task_id, + initial_content=tool_request_content, + ) as streaming_context: + # The message has already been persisted, but we still need to send an upda + await streaming_context.stream_update( + update=StreamTaskMessageFull( + parent_task_message=streaming_context.task_message, + content=tool_request_content, + content_type=tool_request_content.type, + type="full", + ), + ) + + elif event.item.type == "tool_call_output_item": + tool_output_item = event.item.raw_item + + tool_response_content = ToolResponseContent( + author="agent", + tool_call_id=tool_output_item["call_id"], + name=tool_call_map[tool_output_item["call_id"]].name, + content=tool_output_item["output"], + ) + + # (👋) Create a streaming context for the tool call output + # Since a tool call output is a complete message, we can use a with block to create a streaming context. This will take care of creating a TaskMessage, sending a START event, and sending a DONE event when the context is closed. Of course you will also want to stream the content of the tool call output so clients that are subscribed to streaming updates to the task will see the tool call output. + async with adk.streaming.streaming_task_message_context( + task_id=task_id, + initial_content=tool_response_content, + ) as streaming_context: + # The message has already been persisted, but we still need to send an update + await streaming_context.stream_update( + update=StreamTaskMessageFull( + parent_task_message=streaming_context.task_message, + content=tool_response_content, + content_type=tool_response_content.type, + type="full", + ), + ) + + elif event.type == "raw_response_event": + if isinstance(event.data, ResponseTextDeltaEvent): + # Handle text delta + item_id = event.data.item_id + + # (👋) Create a streaming context for the text delta + # Since a text delta is a partial message, we will create a streaming context manually without a with block because we need to persist the context across the for loop. + if item_id not in item_id_to_streaming_context: + streaming_context = adk.streaming.streaming_task_message_context( + task_id=task_id, + initial_content=TextContent( + author="agent", + content="", + ), + ) + # (👋) Open the streaming context manually + # This will create a TaskMessage and send a START event for you. + item_id_to_streaming_context[item_id] = await streaming_context.open() + + # (👋) Add the item_id to the set of unclosed item_ids + # This will allow us to close any lingering streaming context when the agent is done. + unclosed_item_ids.add(item_id) + else: + streaming_context = item_id_to_streaming_context[item_id] + + # (👋) Stream the delta through the streaming service + # This will send a DELTA event. The context manager will accumulate the content for you into a final message when you close the context. + await streaming_context.stream_update( + update=StreamTaskMessageDelta( + parent_task_message=streaming_context.task_message, + delta=TextDelta(text_delta=event.data.delta, type="text"), + type="delta", + ), + ) + + elif isinstance(event.data, ResponseOutputItemDoneEvent): + # Handle item completion + item_id = event.data.item.id + + # (👋) Close the streaming context + # This will send a DONE event and update the persisted message. + if item_id in item_id_to_streaming_context: + streaming_context = item_id_to_streaming_context[item_id] + await streaming_context.close() + unclosed_item_ids.remove(item_id) + + elif isinstance(event.data, ResponseCompletedEvent): + # (👋) Close all remaining streaming contexts + # This will send a DONE event and update the persisted messages for all remaining streaming contents. Normally this won't be needed if all messages are closed by the time the agent is done. + for item_id in unclosed_item_ids: + streaming_context = item_id_to_streaming_context[item_id] + await streaming_context.close() + unclosed_item_ids.remove(item_id) + + finally: + # (👋) Close all remaining streaming contexts + # This will send a DONE event and update the persisted messages for all remaining streaming contents. Normally this won't be needed, but we do it in case any errors occur. + for item_id in list(unclosed_item_ids): + streaming_context = item_id_to_streaming_context[item_id] + await streaming_context.close() + unclosed_item_ids.remove(item_id) + if span: + span.output = { + "new_items": [ + item.raw_item.model_dump() if isinstance(item.raw_item, BaseModel) else item.raw_item + for item in result.new_items + ], + "final_output": result.final_output, + } + return result diff --git a/examples/tutorials/10_async/00_base/040_other_sdks/pyproject.toml b/examples/tutorials/10_async/00_base/040_other_sdks/pyproject.toml new file mode 100644 index 000000000..2d6695120 --- /dev/null +++ b/examples/tutorials/10_async/00_base/040_other_sdks/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "ab040-other-sdks" +version = "0.1.0" +description = "An AgentEx agent that uses other SDKs to show the flexibilty that agents are just code" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 \ No newline at end of file diff --git a/examples/tutorials/10_async/00_base/040_other_sdks/tests/test_agent.py b/examples/tutorials/10_async/00_base/040_other_sdks/tests/test_agent.py new file mode 100644 index 000000000..1704a2271 --- /dev/null +++ b/examples/tutorials/10_async/00_base/040_other_sdks/tests/test_agent.py @@ -0,0 +1,426 @@ +""" +Sample tests for AgentEx ACP agent with MCP servers and custom streaming. + + +This test suite demonstrates how to test agents that integrate: +- OpenAI Agents SDK with streaming +- MCP (Model Context Protocol) servers for tool access +- Custom streaming patterns (delta-based and full messages) +- Complex multi-turn conversations with tool usage + +Key differences from regular streaming (020_streaming): +1. MCP Integration: Agent has access to external tools via MCP servers (sequential-thinking, web-search) +2. Tool Call Streaming: Tests both tool request and tool response streaming patterns +3. Mixed Streaming: Combines full message streaming (tools) with delta streaming (text) +4. Advanced State: Tracks turn_number and input_list instead of simple message history +5. Custom Streaming Context: Manual lifecycle management for different message types + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Ensure OPENAI_API_KEY is set in the environment +4. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: ab040-other-sdks) +""" + +import os +import uuid +import asyncio + +import pytest +import pytest_asyncio +from test_utils.async_utils import ( + stream_agent_response, + send_event_and_poll_yielding, +) + +from agentex import AsyncAgentex +from agentex.types import TaskMessage, TextContent +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "ab040-other-sdks") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling with MCP tools.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll_simple_query(self, client: AsyncAgentex, agent_id: str): + """Test sending a simple event and polling for the response (no tool use).""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Check initial state - should have empty input_list and turn_number 0 + await asyncio.sleep(1) # wait for state to be initialized + states = await client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + + state = states[0].state + assert state is not None + assert state.get("input_list", []) == [] + assert state.get("turn_number", 0) == 0 + + # Send a simple message that shouldn't require tool use + user_message = "Hello! Please introduce yourself briefly." + messages = [] + user_message_found = False + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message, + timeout=30, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + messages.append(message) + + if message.content and message.content.author == "user": + assert message.content == TextContent( + author="user", + content=user_message, + type="text", + ) + user_message_found = True + break + + assert user_message_found, "User message not found" + + # Verify state has been updated by polling the states for 10 seconds + for i in range(20): + if i == 9: + raise Exception("Timeout waiting for state updates") + states = await client.states.list(agent_id=agent_id, task_id=task.id) + state = states[0].state + if len(state.get("input_list", [])) > 0 and state.get("turn_number") == 1: + break + await asyncio.sleep(1) + + states = await client.states.list(agent_id=agent_id, task_id=task.id) + state = states[0].state + assert state.get("turn_number") == 1 + + @pytest.mark.asyncio + async def test_send_event_and_poll_with_tool_use(self, client: AsyncAgentex, agent_id: str): + """Test sending an event that triggers tool usage and polling for the response.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Send a message that should trigger the sequential-thinking tool + user_message = "What is 15 multiplied by 37? Please think through this step by step." + tool_request_found = False + tool_response_found = False + has_final_agent_response = False + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message, + timeout=60, # Longer timeout for tool use + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + if message.content and message.content.type == "tool_request": + tool_request_found = True + assert message.content.author == "agent" + assert hasattr(message.content, "name") + assert hasattr(message.content, "tool_call_id") + elif message.content and message.content.type == "tool_response": + tool_response_found = True + assert message.content.author == "agent" + elif message.content and message.content.type == "text" and message.content.author == "agent": + has_final_agent_response = True + break + + assert has_final_agent_response, "Did not receive final agent text response" + assert tool_request_found, "Did not see tool request message" + assert tool_response_found, "Did not see tool response message" + + @pytest.mark.asyncio + async def test_multi_turn_conversation_with_state(self, client: AsyncAgentex, agent_id: str): + """ + Test message ordering by sending messages about distinct topics. + + This validates that the agent receives messages in chronological order. + If messages are reversed (newest first), the agent would respond about + the wrong topic. + """ + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # ensure the task is created before we send the first event + await asyncio.sleep(1) + + # First turn - ask about tennis + user_message_1 = "Tell me about tennis. You must include the word 'tennis' in your response." + first_turn_response_found = False + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message_1, + timeout=20, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + if ( + message.content + and message.content.type == "text" + and message.content.author == "agent" + and message.content.content + ): + # Validate response is about tennis + assert "tennis" in message.content.content.lower(), "First response should be about tennis" + first_turn_response_found = True + break + + assert first_turn_response_found, "First turn response not found" + + ## keep polling the states for 10 seconds for the input_list and turn_number to be updated + for i in range(30): + if i == 29: + raise Exception("Timeout waiting for state updates") + states = await client.states.list(agent_id=agent_id, task_id=task.id) + state = states[0].state + if len(state.get("input_list", [])) > 0 and state.get("turn_number") == 1: + break + await asyncio.sleep(1) + + states = await client.states.list(agent_id=agent_id, task_id=task.id) + state = states[0].state + assert state.get("turn_number") == 1 + + await asyncio.sleep(1) + + # Second turn - ask about basketball (different topic) + # If message ordering is wrong, agent might respond about tennis instead + user_message_2 = "Now tell me about basketball. You must include the word 'basketball' in your response. Do not mention tennis." + second_turn_response_found = False + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message_2, + timeout=30, + sleep_interval=1.0, + ): + if ( + message.content + and message.content.type == "text" + and message.content.author == "agent" + and message.content.content + ): + response_text = message.content.content.lower() + # Validate response is about basketball, not tennis + assert "basketball" in response_text, f"Second response should be about basketball, got: {response_text}" + second_turn_response_found = True + break + + assert second_turn_response_found, "Did not receive final agent text response" + for i in range(10): + if i == 9: + raise Exception("Timeout waiting for state updates") + states = await client.states.list(agent_id=agent_id, task_id=task.id) + state = states[0].state + if len(state.get("input_list", [])) > 0 and state.get("turn_number") == 2: + break + await asyncio.sleep(1) + + states = await client.states.list(agent_id=agent_id, task_id=task.id) + state = states[0].state + assert state.get("turn_number") == 2 + + +class TestStreamingEvents: + """Test streaming event sending with MCP tools and custom streaming patterns.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream_simple(self, client: AsyncAgentex, agent_id: str): + """Test streaming a simple response without tool usage.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Check initial state + await asyncio.sleep(1) # wait for state to be initialized + states = await client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + state = states[0].state + assert state.get("input_list", []) == [] + assert state.get("turn_number", 0) == 0 + + user_message = "Tell me a very short joke about programming." + + # Collect events from stream + # Check for user message and delta messages + user_message_found = False + async def stream_messages() -> None: + nonlocal user_message_found + async for event in stream_agent_response( + client=client, + task_id=task.id, + timeout=20, + ): + msg_type = event.get("type") + # For full messages, content is at the top level + # For delta messages, we need to check parent_task_message + if msg_type == "full": + if ( + event.get("content", {}).get("type") == "text" + and event.get("content", {}).get("author") == "user" + ): + user_message_found = True + elif msg_type == "done": + break + + if user_message_found: + break + + stream_task = asyncio.create_task(stream_messages()) + event_content = TextContentParam(type="text", author="user", content=user_message) + await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + await stream_task + assert user_message_found, "User message found in stream" + ## keep polling the states for 10 seconds for the input_list and turn_number to be updated + for i in range(10): + if i == 9: + raise Exception("Timeout waiting for state updates") + states = await client.states.list(agent_id=agent_id, task_id=task.id) + state = states[0].state + if len(state.get("input_list", [])) > 0 and state.get("turn_number") == 1: + break + await asyncio.sleep(1) + + # Verify state has been updated + states = await client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + state = states[0].state + input_list = state.get("input_list", []) + + assert isinstance(input_list, list) + assert len(input_list) >= 2 + assert state.get("turn_number") == 1 + + @pytest.mark.asyncio + async def test_send_event_and_stream_with_tools(self, client: AsyncAgentex, agent_id: str): + """Test streaming with tool calls - demonstrates mixed streaming patterns.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # This query should trigger tool usage + user_message = "Use sequential thinking to calculate what 123 times 456 equals." + + tool_requests_seen = [] + tool_responses_seen = [] + text_deltas_seen = [] + async def stream_messages() -> None: + async for event in stream_agent_response( + client=client, + task_id=task.id, + timeout=45, + ): + msg_type = event.get("type") + + # For full messages, content is at the top level + # For delta messages, we need to check parent_task_message + if msg_type == "delta": + parent_msg = event.get("parent_task_message", {}) + content = parent_msg.get("content", {}) + delta = event.get("delta", {}) + content_type = content.get("type") + + if content_type == "text": + text_deltas_seen.append(delta.get("text_delta", "")) + elif msg_type == "full": + # For full messages + content = event.get("content", {}) + content_type = content.get("type") + + if content_type == "tool_request": + tool_requests_seen.append( + { + "name": content.get("name"), + "tool_call_id": content.get("tool_call_id"), + "streaming_type": msg_type, + } + ) + elif content_type == "tool_response": + tool_responses_seen.append( + { + "tool_call_id": content.get("tool_call_id"), + "streaming_type": msg_type, + } + ) + elif msg_type == "done": + break + + stream_task = asyncio.create_task(stream_messages()) + event_content = TextContentParam(type="text", author="user", content=user_message) + await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + await stream_task + + # Verify we saw tool usage (if the agent decided to use tools) + # Note: The agent may or may not use tools depending on its reasoning + # Verify the state has a response written to it + # assert len(text_deltas_seen) > 0, "Should have received text delta streaming" + for i in range(10): + if i == 9: + raise Exception("Timeout waiting for state updates") + states = await client.states.list(agent_id=agent_id, task_id=task.id) + state = states[0].state + if len(state.get("input_list", [])) > 0 and state.get("turn_number") == 1: + break + await asyncio.sleep(1) + + # Verify state has been updated + states = await client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + state = states[0].state + input_list = state.get("input_list", []) + + assert isinstance(input_list, list) + assert len(input_list) >= 2 + print(input_list) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/00_base/080_batch_events/.dockerignore b/examples/tutorials/10_async/00_base/080_batch_events/.dockerignore new file mode 100644 index 000000000..c4f7a8b4b --- /dev/null +++ b/examples/tutorials/10_async/00_base/080_batch_events/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store \ No newline at end of file diff --git a/examples/tutorials/10_async/00_base/080_batch_events/Dockerfile b/examples/tutorials/10_async/00_base/080_batch_events/Dockerfile new file mode 100644 index 000000000..dbeccdfb9 --- /dev/null +++ b/examples/tutorials/10_async/00_base/080_batch_events/Dockerfile @@ -0,0 +1,51 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 10_async/00_base/080_batch_events/pyproject.toml /app/080_batch_events/pyproject.toml +COPY 10_async/00_base/080_batch_events/README.md /app/080_batch_events/README.md + +WORKDIR /app/080_batch_events + +# Copy the project code +COPY 10_async/00_base/080_batch_events/project /app/080_batch_events/project + +# Copy the test files +COPY 10_async/00_base/080_batch_events/tests /app/080_batch_events/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies (includes pytest) +RUN uv pip install --system .[dev] pytest-asyncio httpx + +WORKDIR /app/080_batch_events +# Set environment variables +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=ab080-batch-events + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_async/00_base/080_batch_events/README.md b/examples/tutorials/10_async/00_base/080_batch_events/README.md new file mode 100644 index 000000000..b49e01873 --- /dev/null +++ b/examples/tutorials/10_async/00_base/080_batch_events/README.md @@ -0,0 +1,46 @@ +# [Agentic] Batch Events + +Demonstrates limitations of the base async protocol with concurrent event processing. When multiple events arrive rapidly, base async agents handle them sequentially, which can cause issues. + +## What You'll Learn +- Limitations of non-Temporal async agents +- Race conditions and ordering issues in concurrent scenarios +- When you need workflow orchestration +- Why this motivates Temporal adoption + +## Prerequisites +- Development environment set up (see [main repo README](https://github.com/scaleapi/scale-agentex)) +- Backend services running: `make dev` from repository root +- Understanding of async patterns (see previous tutorials) + +## Quick Start + +```bash +cd examples/tutorials/10_async/00_base/080_batch_events +uv run agentex agents run --manifest manifest.yaml +``` + +## Why This Matters + +This tutorial shows **when you need Temporal**. If your agent needs to: +- Handle events that might arrive out of order +- Process multiple events in parallel safely +- Maintain consistent state under concurrent load + +Then you should use Temporal workflows (see tutorials 10_async/10_temporal/) which provide: +- Deterministic event ordering +- Safe concurrent processing +- Guaranteed state consistency + +This is the "breaking point" tutorial that motivates moving to Temporal for production agents. + +## When to Use (This Pattern) +This tutorial shows what NOT to use for production. Use base async agents only when: +- Events are infrequent (< 1 per second) +- Order doesn't matter +- State consistency isn't critical + +## Why This Matters +Every production agent eventually hits concurrency issues. This tutorial shows you those limits early, so you know when to graduate to Temporal. Better to learn this lesson in a tutorial than in production! + +**Next:** Ready for production? → [../10_temporal/000_hello_acp](../../10_temporal/000_hello_acp/) or explore [090_multi_agent_non_temporal](../090_multi_agent_non_temporal/) for complex non-Temporal coordination diff --git a/examples/tutorials/10_async/00_base/080_batch_events/dev.ipynb b/examples/tutorials/10_async/00_base/080_batch_events/dev.ipynb new file mode 100644 index 000000000..5bb98625c --- /dev/null +++ b/examples/tutorials/10_async/00_base/080_batch_events/dev.ipynb @@ -0,0 +1,155 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "0", + "metadata": {}, + "outputs": [], + "source": [ + "from __future__ import annotations\n", + "\n", + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"ab080-batch-events\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "# (REQUIRED) Create a new task. For Agentic agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex.types import Event\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "from agentex.types.agent_rpc_params import ParamsSendEventRequest\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "concurrent_event_messages: list[ParamsSendEventRequest] = [\n", + " {\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello, what can you do?\"},\n", + " \"task_id\": task.id,\n", + " },\n", + " {\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Can you tell me a joke?\"},\n", + " \"task_id\": task.id,\n", + " },\n", + " {\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"What is the capital of France?\"},\n", + " \"task_id\": task.id,\n", + " },\n", + " {\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Write a short story about a cat\"},\n", + " \"task_id\": task.id,\n", + " },\n", + " {\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Tell me how an LLM works\"},\n", + " \"task_id\": task.id,\n", + " },\n", + "]\n", + "\n", + "events: list[Event] = []\n", + "\n", + "for event_message in concurrent_event_messages:\n", + " rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params=event_message\n", + " )\n", + "\n", + " event = rpc_response.result\n", + " events.append(event)\n", + "\n", + "for event in events:\n", + " print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=20,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/examples/tutorials/10_async/00_base/080_batch_events/manifest.yaml b/examples/tutorials/10_async/00_base/080_batch_events/manifest.yaml new file mode 100644 index 000000000..dd5f8cbdc --- /dev/null +++ b/examples/tutorials/10_async/00_base/080_batch_events/manifest.yaml @@ -0,0 +1,117 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../../../ # Up to tutorials level to include test_utils + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - 10_async/00_base/080_batch_events + - test_utils + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: 10_async/00_base/080_batch_events/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: 10_async/00_base/080_batch_events/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + +# Agent Configuration +# ----------------- +agent: + acp_type: async + + # Unique name for your agent + # Used for task routing and monitoring + name: ab080-batch-events + + # Description of what your agent does + # Helps with documentation and discovery + description: An AgentEx agent + + # Temporal workflow configuration + # Set enabled: true to use Temporal workflows for long-running tasks + temporal: + enabled: false + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + # credentials: + # - env_var_name: OPENAI_API_KEY + # secret_name: openai-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + # env: + # OPENAI_API_KEY: "" + # OPENAI_BASE_URL: "" + # OPENAI_ORG_ID: "" + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific files (deploy/*.yaml) + global: + agent: + name: "ab080-batch-events" + description: "An AgentEx agent" + + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/examples/tutorials/10_async/00_base/080_batch_events/project/__init__.py b/examples/tutorials/10_async/00_base/080_batch_events/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/00_base/080_batch_events/project/acp.py b/examples/tutorials/10_async/00_base/080_batch_events/project/acp.py new file mode 100644 index 000000000..94e79068b --- /dev/null +++ b/examples/tutorials/10_async/00_base/080_batch_events/project/acp.py @@ -0,0 +1,235 @@ +""" +WARNING: This tutorial is NOT something that is production ready. It is meant for a demonstration of how to handle a bulk of events in an async ACP. + +THere are many limitations with trying to do something similar to this. Please see the README.md for more details. +""" +import asyncio +from enum import Enum + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams +from agentex.lib.types.fastacp import AsyncACPConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.sdk.fastacp.fastacp import FastACP + +logger = make_logger(__name__) + + +class TaskCancelledError(Exception): + pass + + +class Status(Enum): + PROCESSING = "processing" + READY = "ready" + CANCELLED = "cancelled" + + +# Create an ACP server +acp = FastACP.create( + acp_type="async", + config=AsyncACPConfig(type="base") +) + +async def process_events_batch(events, task_id: str) -> str: + """ + Process a batch of events with 2s sleep per event to simulate work. + Returns the ID of the last processed event. + """ + if not events: + return None + + logger.info(f"🔄 Processing {len(events)} events: {[e.id for e in events]}") + + # Sleep for 2s per event to simulate processing work + for event in events: + await asyncio.sleep(3) + logger.info(f" INSIDE PROCESSING LOOP - FINISHED PROCESSING EVENT {event.id}") + + # Create message showing what was processed + event_ids = [event.id for event in events] + message_content = TextContent( + author="agent", + content=f"Processed event IDs: {event_ids}" + ) + + await adk.messages.create( + task_id=task_id, + content=message_content + ) + + final_cursor = events[-1].id + logger.info(f"📝 Message created for {len(events)} events (cursor: {final_cursor})") + return final_cursor + + +@acp.on_task_create +async def handle_task_create(params: CreateTaskParams) -> None: + # For this tutorial, we print the parameters sent to the handler + # so you can see where and how task creation is handled + + logger.info(f"Task created: {params.task.id} for agent: {params.agent.id}") + + # The AgentTaskTracker is automatically created by the server when a task is created + # Let's verify it exists and log its initial state + try: + tracker = await adk.agent_task_tracker.get_by_task_and_agent( + task_id=params.task.id, + agent_id=params.agent.id + ) + logger.info(f"AgentTaskTracker found: {tracker.id}, status: {tracker.status}, last_processed_event_id: {tracker.last_processed_event_id}") + except Exception as e: + logger.error(f"Error getting AgentTaskTracker: {e}") + + logger.info("Task creation complete") + return + + +@acp.on_task_event_send +async def handle_task_event_send(params: SendEventParams) -> None: + """ + NOTE: See the README.md for a set of limitations as to why this is not the best way to handle events. + + Handle incoming events with batching behavior. + + Demonstrates how events arriving during PROCESSING get queued and batched: + 1. Check status - skip if CANCELLED or already PROCESSING + 2. Set status to PROCESSING + 3. Process events in batches until no more arrive + 4. Set status back to READY + + The key insight: while this agent is sleeping 2s per event, new events + can arrive and will be batched together in the next processing cycle. + """ + logger.info(f"📥 Received event: {params.event.id}") + + # Get the current AgentTaskTracker state + try: + tracker = await adk.agent_task_tracker.get_by_task_and_agent( + task_id=params.task.id, + agent_id=params.agent.id + ) + logger.info(f"Current tracker status: {tracker.status}, cursor: {tracker.last_processed_event_id}") + except Exception as e: + logger.error(f"Error getting AgentTaskTracker: {e}") + return + + # Skip if task is cancelled + if tracker.status == Status.CANCELLED.value: + logger.error("❌ Task is cancelled. Skipping.") + return + + # Skip if already processing (another pod is handling it) + if tracker.status == Status.PROCESSING.value: + logger.info("⏭️ Task is already being processed by another pod. Skipping.") + return + + # LIMITATION - because this is not atomic, it is possible that two different processes will read the value of true + # and then both will try to set it to processing. The only way to prevent this is locking, which is not supported + # by the agentex server. + # + # Options: + # 1. Implement your own database locking mechanism and provide the agent with the credentials to the database + # 2. Use Temporal, which will ensure that there is only one workflow execution to be processing at a time (thus not needing a lock anymore) + # Update status to PROCESSING to claim this processing cycle + try: + tracker = await adk.agent_task_tracker.update( + tracker_id=tracker.id, + status=Status.PROCESSING.value, + status_reason="Processing events in batches" + + ) + logger.info(f"🔒 Set status to PROCESSING") + except Exception as e: + logger.error(f"❌ Failed to set status to PROCESSING (another pod may have claimed it): {e}") + return + + reset_to_ready = True + try: + current_cursor = tracker.last_processed_event_id + # Main processing loop - keep going until no more new events + while True: + print(f"\n🔍 Checking for new events since cursor: {current_cursor}") + + tracker = await adk.agent_task_tracker.get(tracker_id=tracker.id) + if tracker.status == Status.CANCELLED.value: + logger.error("❌ Task is cancelled. Skipping.") + raise TaskCancelledError("Task is cancelled") + + # Get all new events since current cursor + try: + print("Listing events since cursor: ", current_cursor) + new_events = await adk.events.list_events( + task_id=params.task.id, + agent_id=params.agent.id, + last_processed_event_id=current_cursor, + limit=100 + ) + + if not new_events: + print("✅ No more new events found - processing cycle complete") + break + + logger.info(f"🎯 BATCH: Found {len(new_events)} events to process") + + except Exception as e: + logger.error(f"❌ Error collecting events: {e}") + break + + # Process this batch of events (with 2s sleeps) + try: + final_cursor = await process_events_batch(new_events, params.task.id) + + # Update cursor to mark these events as processed + await adk.agent_task_tracker.update( + tracker_id=tracker.id, + last_processed_event_id=final_cursor, + status=Status.PROCESSING.value, # Still processing, might be more + status_reason=f"Processed batch of {len(new_events)} events" + ) + + current_cursor = final_cursor + logger.info(f"📊 Updated cursor to: {current_cursor}") + + except Exception as e: + logger.error(f"❌ Error processing events batch: {e}") + break + except TaskCancelledError as e: + logger.error(f"❌ Task cancelled: {e}") + reset_to_ready = False + finally: + if reset_to_ready: + # Always set status back to READY when done processing + try: + await adk.agent_task_tracker.update( + tracker_id=tracker.id, + status=Status.READY.value, + status_reason="Completed event processing - ready for new events" + ) + logger.info(f"🟢 Set status back to READY - agent available for new events") + except Exception as e: + logger.error(f"❌ Error setting status back to READY: {e}") + + +@acp.on_task_cancel +async def handle_task_canceled(params: CancelTaskParams): + # For this tutorial, we print the parameters sent to the handler + # so you can see where and how task cancellation is handled + logger.info(f"Hello world! Task canceled: {params.task.id}") + + # Update the AgentTaskTracker to reflect cancellation + try: + tracker = await adk.agent_task_tracker.get_by_task_and_agent( + task_id=params.task.id, + agent_id=params.agent.id + ) + await adk.agent_task_tracker.update( + tracker_id=tracker.id, + status=Status.CANCELLED.value, + status_reason="Task was cancelled by user" + ) + logger.info(f"Updated tracker status to cancelled") + except Exception as e: + logger.error(f"Error updating tracker on cancellation: {e}") + diff --git a/examples/tutorials/10_async/00_base/080_batch_events/pyproject.toml b/examples/tutorials/10_async/00_base/080_batch_events/pyproject.toml new file mode 100644 index 000000000..a38bfbb6c --- /dev/null +++ b/examples/tutorials/10_async/00_base/080_batch_events/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "ab080-batch-events" +version = "0.1.0" +description = "An AgentEx agent" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 \ No newline at end of file diff --git a/examples/tutorials/10_async/00_base/080_batch_events/test_batch_events.py b/examples/tutorials/10_async/00_base/080_batch_events/test_batch_events.py new file mode 100644 index 000000000..b7a5397d0 --- /dev/null +++ b/examples/tutorials/10_async/00_base/080_batch_events/test_batch_events.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +Simple script to test agent RPC endpoints using the actual schemas. +""" + +import json +import uuid +import asyncio + +import httpx + +# Configuration +BASE_URL = "http://localhost:5003" +# AGENT_ID = "b4f32d71-ff69-4ac9-84d1-eb2937fea0c7" +AGENT_ID = "58e78cd0-c898-4009-b5d9-eada8ebcad83" +RPC_ENDPOINT = f"{BASE_URL}/agents/{AGENT_ID}/rpc" + +async def send_rpc_request(method: str, params: dict): + """Send an RPC request to the agent.""" + request_data = { + "jsonrpc": "2.0", + "id": str(uuid.uuid4()), + "method": method, + "params": params + } + + print(f"→ Sending: {method}") + print(f" Request: {json.dumps(request_data, indent=2)}") + + async with httpx.AsyncClient() as client: + try: + response = await client.post( + RPC_ENDPOINT, + json=request_data, + headers={"Content-Type": "application/json"}, + timeout=30.0 + ) + + print(f" Status: {response.status_code}") + + if response.status_code == 200: + response_data = response.json() + print(f" Response: {json.dumps(response_data, indent=2)}") + return response_data + else: + print(f" Error: {response.text}") + return None + + except Exception as e: + print(f" Failed: {e}") + return None + +async def main(): + """Main function to test the agent RPC endpoints.""" + print(f"🚀 Testing Agent RPC: {AGENT_ID}") + print(f"🔗 Endpoint: {RPC_ENDPOINT}") + print("=" * 50) + + # Step 1: Create a task + print("\n📝 Step 1: Creating a task...") + task_response = await send_rpc_request("task/create", { + "params": { + "description": "Test task from simple script" + } + }) + + if not task_response or task_response.get("error"): + print("❌ Task creation failed, continuing anyway...") + task_id = str(uuid.uuid4()) # Generate a task ID to continue + else: + # Extract task_id from response (adjust based on actual response structure) + task_id = task_response.get("result", {}).get("id", str(uuid.uuid4())) + + print(f"📋 Using task_id: {task_id}") + + # Step 2: Send messages + print("\n📤 Step 2: Sending messages...") + + messages = [f"This is message {i}" for i in range(20)] + + for i, message in enumerate(messages, 1): + print(f"\n📨 Sending message {i}/{len(messages)}") + + # Create message content using TextContent structure + message_content = { + "type": "text", + "author": "user", + "style": "static", + "format": "plain", + "content": message + } + + # Send message using message/send method + response = await send_rpc_request("event/send", { + "task_id": task_id, + "event": message_content, + }) + + if response and not response.get("error"): + print(f"✅ Message {i} sent successfully") + else: + print(f"❌ Message {i} failed") + + # Small delay between messages + await asyncio.sleep(0.1) + + print("\n" + "=" * 50) + print("✨ Script completed!") + print(f"📋 Task ID: {task_id}") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/tutorials/10_async/00_base/080_batch_events/tests/test_agent.py b/examples/tutorials/10_async/00_base/080_batch_events/tests/test_agent.py new file mode 100644 index 000000000..1dea1300b --- /dev/null +++ b/examples/tutorials/10_async/00_base/080_batch_events/tests/test_agent.py @@ -0,0 +1,233 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: ab080-batch-events) +""" + +import os +import re +import uuid +import asyncio + +import pytest +import pytest_asyncio +from test_utils.async_utils import ( + stream_agent_response, + send_event_and_poll_yielding, +) + +from agentex import AsyncAgentex +from agentex.types import TaskMessage +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam +from agentex.types.task_message_content import TextContent + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "ab080-batch-events") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_id: str): + """Test sending a single event and polling for the response.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Send an event and poll for response using the helper function + # there should only be one message returned about batching + agent_response_found = False + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message="Process this single event", + timeout=30, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + if message.content and message.content.author == "agent": + assert isinstance(message.content, TextContent) + assert "Processed event IDs" in message.content.content + agent_response_found = True + break + + assert agent_response_found, "Agent response not found" + + @pytest.mark.asyncio + async def test_send_multiple_events_batched(self, client: AsyncAgentex, agent_id: str): + """Test sending multiple events that should be batched together.""" + # Create a task + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Send multiple events in quick succession (should be batched) + num_events = 7 + for i in range(num_events): + event_content = TextContentParam(type="text", author="user", content=f"Batch event {i + 1}") + await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + await asyncio.sleep(0.1) # Small delay to ensure ordering + + # Wait for processing to complete (5 events * 5 seconds each = 25s + buffer) + + ## there should be at least 2 agent responses to ensure that not all of the events are processed + ## in the same message + agent_messages = [] + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message="Process this single event", + timeout=30, + sleep_interval=1.0, + ): + if message.content and message.content.author == "agent": + agent_messages.append(message) + + if len(agent_messages) == 2: + break + + assert len(agent_messages) > 0, "Should have received at least one agent response" + + # PROOF OF BATCHING: Should have fewer responses than events sent + assert len(agent_messages) < num_events, ( + f"Expected batching to result in fewer responses than {num_events} events, got {len(agent_messages)}" + ) + + # Analyze each batch response to count how many events were in each batch + found_batch_with_multiple_events = False + for msg in agent_messages: + assert isinstance(msg.content, TextContent) + response = msg.content.content + + # Count event IDs in this response (they're in a list like ['id1', 'id2', ...]) + # Use regex to find all quoted strings in the list + event_ids = re.findall(r"'([^']+)'", response) + batch_size = len(event_ids) + if batch_size > 1: + # this measn that we have found a batch with multiple events + found_batch_with_multiple_events = True + break + + assert found_batch_with_multiple_events, "Should have found a batch with multiple events" + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_twenty_events_batched_streaming(self, client: AsyncAgentex, agent_id: str): + """Test sending 20 events and verifying batch processing via streaming.""" + # Create a task + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Stream the responses and collect agent messages + print("\nStreaming batch responses...") + + # We'll collect all agent messages from the stream + agent_messages = [] + stream_timeout = 90 # Longer timeout for 20 events + async def stream_messages() -> None: + async for event in stream_agent_response( + client=client, + task_id=task.id, + timeout=stream_timeout, + ): + # Collect agent text messages + if event.get("type") == "full": + content = event.get("content", {}) + if content.get("type") == "text" and content.get("author") == "agent": + msg_content = content.get("content", "") + if msg_content and msg_content.strip(): + agent_messages.append(msg_content) + elif event.get("type") == "done": + break + + if len(agent_messages) >= 2: + break + + stream_task = asyncio.create_task(stream_messages()) + + # Send 10 events in quick succession (should be batched) + num_events = 10 + print(f"\nSending {num_events} events in quick succession...") + for i in range(num_events): + event_content = TextContentParam(type="text", author="user", content=f"Batch event {i + 1}") + await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + await asyncio.sleep(0.1) # Small delay to ensure ordering + + await stream_task + + print(f"\nSent {num_events} events") + print(f"Received {len(agent_messages)} agent response(s)") + + assert len(agent_messages) > 0, "Should have received at least one agent response" + + # PROOF OF BATCHING: Should have fewer responses than events sent + assert len(agent_messages) < num_events, ( + f"Expected batching to result in fewer responses than {num_events} events, got {len(agent_messages)}" + ) + + # Analyze each batch response to count how many events were in each batch + total_events_processed = 0 + found_batch_with_multiple_events = False + for response in agent_messages: + # Count event IDs in this response (they're in a list like ['id1', 'id2', ...]) + # Use regex to find all quoted strings in the list + event_ids = re.findall(r"'([^']+)'", response) + batch_size = len(event_ids) + + total_events_processed += batch_size + + # At least one response should have multiple events (proof of batching) + if batch_size > 1: + found_batch_with_multiple_events = True + break + + assert found_batch_with_multiple_events, "Should have found a batch with multiple events" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/Dockerfile b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/Dockerfile new file mode 100644 index 000000000..24ecf4484 --- /dev/null +++ b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/Dockerfile @@ -0,0 +1,57 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim + +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 10_async/00_base/090_multi_agent_non_temporal/pyproject.toml /app/090_multi_agent_non_temporal/pyproject.toml +COPY 10_async/00_base/090_multi_agent_non_temporal/README.md /app/090_multi_agent_non_temporal/README.md + +WORKDIR /app/090_multi_agent_non_temporal + +# Copy the project code +COPY 10_async/00_base/090_multi_agent_non_temporal/project /app/090_multi_agent_non_temporal/project + +# Copy the test files +COPY 10_async/00_base/090_multi_agent_non_temporal/tests /app/090_multi_agent_non_temporal/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies +RUN uv pip install --system .[dev] + +# Set environment variables +ENV PYTHONPATH=/app + +ARG AGENT_FILE +ARG PORT + +# Set test environment variables +ENV AGENT_NAME=ab090-multi-agent-non-temporal + +# Note: AGENT_NAME can be overridden at runtime based on which agent is running +# (ab090-creator-agent, ab090-critic-agent, ab090-formatter-agent, or ab090-orchestrator-agent) + +# Run the agent using uvicorn +CMD uvicorn project.${AGENT_FILE%.*}:acp --host 0.0.0.0 --port ${PORT:-8000} diff --git a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/README.md b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/README.md new file mode 100644 index 000000000..d9f860e30 --- /dev/null +++ b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/README.md @@ -0,0 +1,210 @@ +# Multi-Agent Content Assembly Line + +A multi-agent system that creates content through a collaborative workflow. Four agents work together: a creator generates content, a critic reviews it against rules, and a formatter outputs the final result, all coordinated by an orchestrator. + +## 🏗️ Architecture Overview + +``` +090_multi_agent_non_temporal/ +├── project/ # All agent code +│ ├── creator.py # Content generation agent +│ ├── critic.py # Content review agent +│ ├── formatter.py # Content formatting agent +│ ├── orchestrator.py # Workflow coordination agent +│ ├── models.py # Pydantic models for type safety +│ └── state_machines/ +│ └── content_workflow.py # State machine definitions +├── creator.yaml # Creator agent manifest +├── critic.yaml # Critic agent manifest +├── formatter.yaml # Formatter agent manifest +├── orchestrator.yaml # Orchestrator agent manifest +├── Dockerfile # Single shared Dockerfile +├── pyproject.toml # Dependencies and project configuration +├── start-agents.sh # Agent management script +└── README.md # This file +``` + +## 📁 File Structure + +The system uses a shared build configuration with type-safe interfaces: +- **Single `Dockerfile`** with build arguments for different agents +- **Single `pyproject.toml`** for all dependencies +- **Agent code** in `project/` directory with clear separation of concerns +- **Individual manifest files** at root level for each agent deployment +- **Shared state machine definitions** for workflow coordination +- **Pydantic models** (`models.py`) for type safety and validation across all agents + +### Key Files: +- `project/models.py` - Defines request/response models for type safety +- `project/orchestrator.py` - Workflow coordination and inter-agent communication +- `project/creator.py` - Content generation with revision capabilities +- `project/critic.py` - Content validation against rules +- `project/formatter.py` - Multi-format content transformation +- `project/state_machines/content_workflow.py` - State management for the workflow + +## 🚀 Quick Start + +### Prerequisites +- Development environment set up (see [main repo README](https://github.com/scaleapi/scale-agentex)) +- Backend services running: `make dev` from repository root +- Python 3.12+ and uv package manager +- OpenAI API key (set `OPENAI_API_KEY` or create `.env` file) +- Understanding of async patterns (see previous tutorials) + +### Running the System + +1. **Start all agents**: + ```bash + cd examples/tutorials/10_async/00_base/090_multi_agent_non_temporal + ./start-agents.sh start + ``` + +2. **Check agent status**: + ```bash + ./start-agents.sh status + ``` + +3. **Send a test request**: + ```bash + ./start-agents.sh test + ``` + +4. **Monitor logs**: + ```bash + ./start-agents.sh logs + ``` + +5. **Stop all agents**: + ```bash + ./start-agents.sh stop + ``` + +## 🤖 Agent Responsibilities + +### **Creator Agent** (Port 8001) +- Generates original content based on user requests +- Revises content based on critic feedback +- Maintains conversation history and iteration tracking + +### **Critic Agent** (Port 8002) +- Reviews content against specified rules +- Provides specific, actionable feedback +- Approves content when all rules are met + +### **Formatter Agent** (Port 8003) +- Converts approved content to target formats (HTML, Markdown, JSON, etc.) +- Preserves meaning while applying format-specific conventions +- Supports multiple output formats + +### **Orchestrator Agent** (Port 8000) +- Coordinates the entire workflow using state machines +- Manages inter-agent communication +- Tracks progress and handles errors/retries + +## 📋 Example Request + +Send a JSON request to the orchestrator: + +```json +{ + "request": "Write a welcome message for our AI assistant", + "rules": ["Under 50 words", "Friendly tone", "Include emoji"], + "target_format": "HTML" +} +``` + +The system will: +1. **Create** content using the Creator agent +2. **Review** against rules using the Critic agent +3. **Revise** if needed (up to 10 iterations) +4. **Format** final approved content using the Formatter agent + +## 🔧 Development + +### Type Safety with Pydantic +The tutorial demonstrates proper type safety using Pydantic models: + +```python +# Define request structure +class CreatorRequest(BaseModel): + request: str = Field(..., description="The content creation request") + current_draft: Optional[str] = Field(default=None, description="Current draft for revision") + feedback: Optional[List[str]] = Field(default=None, description="Feedback from critic") + +# Validate incoming requests +creator_request = CreatorRequest.model_validate(request_data) +``` + +Benefits: +- **Explicit failures** when required fields are missing +- **Self-documenting** APIs with field descriptions +- **IDE support** with auto-completion and type checking +- **Runtime validation** with clear error messages + +### Adding New Agents +1. **Add models** to `project/models.py` for request/response types +2. **Create agent** in `project/new_agent.py` using the FastACP pattern +3. **Add manifest** as `new_agent.yaml` at root level with deployment configuration +4. **Update startup script** in `start-agents.sh` to include the new agent + +### Modifying Agents +- **Agent code** is in `project/` directory +- **Shared models** are in `project/models.py` for consistency +- **Dependencies** go in `pyproject.toml` +- **Docker configuration** is shared across all agents + +### Deployment +Each agent can be deployed independently using its manifest: +```bash +uv run agentex agents deploy --cluster your-cluster --manifest creator.yaml +``` + +## 🏗️ Technical Implementation + +### Shared Dockerfile +The Dockerfile uses build arguments to run different agents: +```dockerfile +CMD uvicorn project.${AGENT_FILE%.*}:acp --host 0.0.0.0 --port ${PORT:-8000} +``` + +Manifest files specify which agent to run: +```yaml +build_args: + AGENT_FILE: creator.py + PORT: 8001 +``` + +### State Machine Flow +The orchestrator coordinates the workflow through these states: +- `CREATING` → `WAITING_FOR_CREATOR` → `REVIEWING` → `WAITING_FOR_CRITIC` → `FORMATTING` → `COMPLETED` + +### Inter-Agent Communication +Agents communicate using AgentEx events: +```python +await adk.acp.send_event( + agent_name="ab090-creator-agent", + task_id=task_id, + content=TextContent(author="agent", content=json.dumps(request_data)) +) +``` + +## 📚 What You'll Learn + +This tutorial demonstrates: +- **Multi-agent coordination** using state machines for complex workflows +- **Type-safe communication** with Pydantic models for all request/response data +- **Shared build configuration** for multiple agents in a single deployment +- **AgentEx CLI usage** for development and deployment +- **Inter-agent communication patterns** with proper error handling +- **Scalable agent architecture** with clear separation of concerns + +## When to Use +- Complex workflows requiring multiple specialized agents +- Content pipelines with review/approval steps +- Systems where each stage needs different capabilities +- When you want agent separation without Temporal (though Temporal is recommended for production) + +## Why This Matters +This shows how far you can go with non-Temporal multi-agent systems. However, note the limitations: manual state management, potential race conditions, and no built-in durability. For production multi-agent systems, consider Temporal ([../10_temporal/](../../10_temporal/)) which provides workflow orchestration, durability, and state management out of the box. + +**Next:** Ready for production workflows? → [../../10_temporal/000_hello_acp](../../10_temporal/000_hello_acp/) diff --git a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/creator.yaml b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/creator.yaml new file mode 100644 index 000000000..9d531bbf4 --- /dev/null +++ b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/creator.yaml @@ -0,0 +1,42 @@ +# Creator Agent Manifest Configuration +# ---------------------------------- +# This file defines how the creator agent should be built and deployed. + +build: + context: + root: ../ + dockerfile: Dockerfile + build_args: + AGENT_FILE: creator.py + PORT: 8001 + +local_development: + agent: + port: 8001 + host_address: host.docker.internal + paths: + acp: project/creator.py + +agent: + name: ab090-creator-agent + acp_type: async + description: Creator agent that generates and revises content based on requests and feedback + temporal: + enabled: false + +deployment: + image: + repository: "" + tag: "latest" + global: + agent: + name: "ab090-creator-agent" + description: "Creator agent that generates and revises content based on requests and feedback" + replicaCount: 1 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/critic.yaml b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/critic.yaml new file mode 100644 index 000000000..0a18fc127 --- /dev/null +++ b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/critic.yaml @@ -0,0 +1,42 @@ +# Critic Agent Manifest Configuration +# --------------------------------- +# This file defines how the critic agent should be built and deployed. + +build: + context: + root: ../ + dockerfile: Dockerfile + build_args: + AGENT_FILE: critic.py + PORT: 8002 + +local_development: + agent: + port: 8002 + host_address: host.docker.internal + paths: + acp: project/critic.py + +agent: + name: ab090-critic-agent + acp_type: async + description: Critic agent that reviews content drafts against specified rules and provides feedback + temporal: + enabled: false + +deployment: + image: + repository: "" + tag: "latest" + global: + agent: + name: "ab090-critic-agent" + description: "Critic agent that reviews content drafts against specified rules and provides feedback" + replicaCount: 1 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/formatter.yaml b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/formatter.yaml new file mode 100644 index 000000000..9c69b74c6 --- /dev/null +++ b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/formatter.yaml @@ -0,0 +1,42 @@ +# Formatter Agent Manifest Configuration +# ------------------------------------- +# This file defines how the formatter agent should be built and deployed. + +build: + context: + root: ../ + dockerfile: Dockerfile + build_args: + AGENT_FILE: formatter.py + PORT: 8003 + +local_development: + agent: + port: 8003 + host_address: host.docker.internal + paths: + acp: project/formatter.py + +agent: + name: ab090-formatter-agent + acp_type: async + description: Formatter agent that converts approved content to various target formats (HTML, Markdown, etc.) + temporal: + enabled: false + +deployment: + image: + repository: "" + tag: "latest" + global: + agent: + name: "ab090-formatter-agent" + description: "Formatter agent that converts approved content to various target formats (HTML, Markdown, etc.)" + replicaCount: 1 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/orchestrator.yaml b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/orchestrator.yaml new file mode 100644 index 000000000..079329fd0 --- /dev/null +++ b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/orchestrator.yaml @@ -0,0 +1,42 @@ +# Orchestrator Agent Manifest Configuration +# ---------------------------------------- +# This file defines how the orchestrator agent should be built and deployed. + +build: + context: + root: ../ + dockerfile: Dockerfile + build_args: + AGENT_FILE: orchestrator.py + PORT: 8000 + +local_development: + agent: + port: 8000 + host_address: host.docker.internal + paths: + acp: project/orchestrator.py + +agent: + name: ab090-orchestrator-agent + acp_type: async + description: Orchestrator agent that coordinates a multi-agent content creation workflow using state machines and inter-agent communication + temporal: + enabled: false + +deployment: + image: + repository: "" + tag: "latest" + global: + agent: + name: "ab090-orchestrator-agent" + description: "Orchestrator agent that coordinates a multi-agent content creation workflow using state machines and inter-agent communication" + replicaCount: 1 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/__init__.py b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/__init__.py new file mode 100644 index 000000000..4d299677c --- /dev/null +++ b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/__init__.py @@ -0,0 +1 @@ +# Multi-agent package diff --git a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/creator.py b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/creator.py new file mode 100644 index 000000000..316975486 --- /dev/null +++ b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/creator.py @@ -0,0 +1,294 @@ +# Creator Agent - Generates and revises content based on requests and feedback + +import os +import sys +import json +from typing import List +from pathlib import Path + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams +from agentex.lib.types.fastacp import AsyncACPConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.types.llm_messages import ( + Message, + LLMConfig, + UserMessage, + SystemMessage, + AssistantMessage, +) +from agentex.lib.sdk.fastacp.fastacp import FastACP + +# Add the current directory to the Python path to enable imports +current_dir = Path(__file__).parent +if str(current_dir) not in sys.path: + sys.path.append(str(current_dir)) + +from models import CreatorRequest, CreatorResponse + +from agentex.lib.utils.model_utils import BaseModel + +logger = make_logger(__name__) + +# Create an ACP server with base configuration +acp = FastACP.create( + acp_type="async", + config=AsyncACPConfig( + type="base", + ), +) + + +class CreatorState(BaseModel): + messages: List[Message] + creation_history: List[dict] = [] + +@acp.on_task_create +async def handle_task_create(params: CreateTaskParams): + """Initialize the creator agent state.""" + logger.info(f"Creator task created: {params.task.id}") + + # Initialize state with system message + system_message = SystemMessage( + content="""You are a skilled content creator and writer. Your job is to generate and revise high-quality content based on requests and feedback. + +Your responsibilities: +1. Create engaging, original content based on user requests +2. Follow all specified rules and requirements precisely +3. Revise content based on feedback while maintaining quality +4. Ensure content meets all specified criteria + +When creating content: +- Be creative and engaging while staying on topic +- Follow all rules strictly +- Maintain appropriate tone and style +- Focus on quality and clarity + +When revising content: +- Address all feedback points thoroughly +- Maintain the core message while making improvements +- Ensure all rules are still followed after revision + +Return ONLY the content itself, no explanations or metadata.""" + ) + + state = CreatorState(messages=[system_message]) + await adk.state.create(task_id=params.task.id, agent_id=params.agent.id, state=state) + + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="✨ **Creator Agent** - Content Generation & Revision\n\nI specialize in creating and revising high-quality content based on your requests.\n\nFor content creation, send:\n```json\n{\n \"request\": \"Your content request\",\n \"rules\": [\"Rule 1\", \"Rule 2\"]\n}\n```\n\nFor content revision, send:\n```json\n{\n \"content\": \"Original content\",\n \"feedback\": \"Feedback to address\",\n \"rules\": [\"Rule 1\", \"Rule 2\"]\n}\n```\n\nReady to create amazing content! 🚀", + ), + ) + + +@acp.on_task_event_send +async def handle_event_send(params: SendEventParams): + """Handle content creation and revision requests.""" + + if not params.event.content: + return + + if params.event.content.type != "text": + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="❌ I can only process text messages.", + ), + ) + return + + # Echo back the message (if from user) + if params.event.content.author == "user": + await adk.messages.create(task_id=params.task.id, content=params.event.content) + + # Check if OpenAI API key is available + if not os.environ.get("OPENAI_API_KEY"): + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="❌ OpenAI API key not found. Please set the OPENAI_API_KEY environment variable.", + ), + ) + return + + content = params.event.content.content + + try: + # Parse the JSON request + try: + request_data = json.loads(content) + except json.JSONDecodeError: + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="❌ Please provide a valid JSON request with 'request', 'current_draft', and 'feedback' fields.", + ), + ) + return + + # Validate required fields + if "request" not in request_data: + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="❌ Missing required field: 'request'", + ), + ) + return + + # Parse and validate request using Pydantic + try: + creator_request = CreatorRequest.model_validate(request_data) + except ValueError as e: + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"❌ Invalid request format: {e}", + ), + ) + return + + user_request = creator_request.request + current_draft = creator_request.current_draft + feedback = creator_request.feedback + orchestrator_task_id = creator_request.orchestrator_task_id + + # Get current state + task_state = await adk.state.get_by_task_and_agent(task_id=params.task.id, agent_id=params.agent.id) + state = CreatorState.model_validate(task_state.state) + + # Add this request to history + state.creation_history.append({ + "request": user_request, + "current_draft": current_draft, + "feedback": feedback, + "is_revision": bool(current_draft) + }) + + # Create content generation prompt + if current_draft and feedback: + # This is a revision request + user_message_content = f"""Please revise the following content based on the feedback provided: + +ORIGINAL REQUEST: {user_request} + +CURRENT DRAFT: +{current_draft} + +FEEDBACK TO ADDRESS: +{chr(10).join(f'- {item}' for item in feedback)} + +Please provide a revised version that addresses all the feedback while maintaining the quality and intent of the original request.""" + + status_message = f"🔄 **Revising Content** (Iteration {len(state.creation_history)})\n\nRevising based on {len(feedback)} feedback point(s)..." + + else: + # This is an initial creation request + user_message_content = f"""Please create content for the following request: + +{user_request} + +Provide high-quality, engaging content that fulfills this request.""" + + status_message = f"✨ **Creating New Content**\n\nGenerating content for: {user_request}" + + # Send status update + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=status_message, + ), + ) + + # Add user message to conversation + state.messages.append(UserMessage(content=user_message_content)) + + # Generate content using LLM + chat_completion = await adk.providers.litellm.chat_completion( + llm_config=LLMConfig(model="gpt-4o-mini", messages=state.messages), + trace_id=params.task.id, + ) + + if not chat_completion.choices or not chat_completion.choices[0].message: + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="❌ Failed to generate content. Please try again.", + ), + ) + return + + generated_content = chat_completion.choices[0].message.content or "" + + # Add assistant response to conversation + state.messages.append(AssistantMessage(content=generated_content)) + + # Send the generated content back to this task + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=generated_content, + ), + ) + + # Also send the result back to the orchestrator agent if this request came from another agent + if params.event.content.author == "agent" and orchestrator_task_id: + try: + # Send result back to orchestrator using Pydantic model + result_data = CreatorResponse( + content=generated_content, + task_id=params.task.id + ).model_dump() + + await adk.acp.send_event( + agent_name="ab090-orchestrator-agent", + task_id=orchestrator_task_id, # Use the orchestrator's original task ID + content=TextContent( + author="agent", + content=json.dumps(result_data) + ) + ) + logger.info(f"Sent result back to orchestrator for task {orchestrator_task_id}") + + except Exception as e: + logger.error(f"Failed to send result to orchestrator: {e}") + + # Update state + await adk.state.update( + state_id=task_state.id, + task_id=params.task.id, + agent_id=params.agent.id, + state=state, + trace_id=params.task.id, + ) + + logger.info(f"Generated content for task {params.task.id}: {len(generated_content)} characters") + + except Exception as e: + logger.error(f"Error in content creation: {e}") + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"❌ Error creating content: {e}", + ), + ) + + +@acp.on_task_cancel +async def handle_task_cancel(params: CancelTaskParams): + """Handle task cancellation.""" + logger.info(f"Creator task cancelled: {params.task.id}") + diff --git a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/critic.py b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/critic.py new file mode 100644 index 000000000..e58ea44ae --- /dev/null +++ b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/critic.py @@ -0,0 +1,312 @@ +# Critic Agent - Reviews content drafts against specified rules and provides feedback + +import os +import sys +import json +from typing import List +from pathlib import Path + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams +from agentex.lib.types.fastacp import AsyncACPConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.types.llm_messages import ( + Message, + LLMConfig, + UserMessage, + SystemMessage, + AssistantMessage, +) +from agentex.lib.sdk.fastacp.fastacp import FastACP + +# Add the current directory to the Python path to enable imports +current_dir = Path(__file__).parent +if str(current_dir) not in sys.path: + sys.path.append(str(current_dir)) + +from models import CriticRequest, CriticResponse + +from agentex.lib.utils.model_utils import BaseModel + +logger = make_logger(__name__) + +# Create an ACP server with base configuration +acp = FastACP.create( + acp_type="async", + config=AsyncACPConfig( + type="base", + ), +) + + +class CriticState(BaseModel): + messages: List[Message] + review_history: List[dict] = [] + + +@acp.on_task_create +async def handle_task_create(params: CreateTaskParams): + """Initialize the critic agent state.""" + logger.info(f"Critic task created: {params.task.id}") + + # Initialize state with system message + system_message = SystemMessage( + content="""You are a professional content critic and quality assurance specialist. Your job is to review content against specific rules and provide constructive feedback. + +Your responsibilities: +1. Review content against a set of rules +2. Provide specific, actionable feedback for each rule violation +3. Approve content only when all rules are met +4. Be objective and consistent in your reviews + +When reviewing content: +- Systematically check the content against each rule +- For each violation, explain clearly why it fails and suggest how to fix it +- If a rule is subjective (e.g., "friendly tone"), provide a brief justification for your assessment +- If all rules are met, provide an empty feedback list + +Return ONLY a JSON object in the specified format. Do not include any other text or explanations.""" + ) + + state = CriticState(messages=[system_message]) + await adk.state.create(task_id=params.task.id, agent_id=params.agent.id, state=state) + + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="🔍 **Critic Agent** - Content Quality Assurance\n\nI specialize in reviewing content against specific rules and providing constructive feedback.\n\nSend me a JSON request with:\n```json\n{\n \"draft\": \"Content to review\",\n \"rules\": [\"Rule 1\", \"Rule 2\", \"Rule 3\"]\n}\n```\n\nI'll respond with feedback JSON:\n```json\n{\n \"feedback\": [\"issue1\", \"issue2\"] // or [] if approved\n}\n```\n\nReady to ensure quality! 🎯", + ), + ) + + +@acp.on_task_event_send +async def handle_event_send(params: SendEventParams): + """Handle content review requests.""" + + if not params.event.content: + return + + if params.event.content.type != "text": + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="❌ I can only process text messages.", + ), + ) + return + + # Echo back the message (if from user) + if params.event.content.author == "user": + await adk.messages.create(task_id=params.task.id, content=params.event.content) + + # Check if OpenAI API key is available + if not os.environ.get("OPENAI_API_KEY"): + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="❌ OpenAI API key not found. Please set the OPENAI_API_KEY environment variable.", + ), + ) + return + + content = params.event.content.content + + try: + # Parse the JSON request + try: + request_data = json.loads(content) + except json.JSONDecodeError: + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="❌ Please provide a valid JSON request with 'draft' and 'rules' fields.", + ), + ) + return + + # Validate required fields + if "draft" not in request_data or "rules" not in request_data: + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="❌ Missing required fields: 'draft' and 'rules'", + ), + ) + return + + # Parse and validate request using Pydantic + try: + critic_request = CriticRequest.model_validate(request_data) + except ValueError as e: + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"❌ Invalid request format: {e}", + ), + ) + return + + draft = critic_request.draft + rules = critic_request.rules + orchestrator_task_id = critic_request.orchestrator_task_id + + if not isinstance(rules, list): + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="❌ 'rules' must be a list of strings", + ), + ) + return + + # Get current state + task_state = await adk.state.get_by_task_and_agent(task_id=params.task.id, agent_id=params.agent.id) + state = CriticState.model_validate(task_state.state) + + # Add this review to history + state.review_history.append({ + "draft": draft, + "rules": rules, + "timestamp": "now" # In real implementation, use proper timestamp + }) + + # Send status update + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"🔍 **Reviewing Content** (Review #{len(state.review_history)})\n\nChecking content against {len(rules)} rules...", + ), + ) + + # Create review prompt + rules_text = "\n".join([f"{i+1}. {rule}" for i, rule in enumerate(rules)]) + + user_message_content = f"""Please review the following content against the specified rules and provide feedback: + +CONTENT TO REVIEW: +{draft} + +RULES TO CHECK: +{rules_text} + +Review the content systematically against each rule. For each rule violation: +1. Identify which rule is violated +2. Explain why it violates the rule +3. Suggest how to fix it + +If the content meets all rules, return an empty feedback list. + +You MUST respond with a JSON object in this exact format: +{{ + "feedback": ["specific issue 1", "specific issue 2", ...] // or [] if all rules are met +}} + +Do not include any other text or explanations outside the JSON response.""" + + # Add user message to conversation + state.messages.append(UserMessage(content=user_message_content)) + + # Generate review using LLM + chat_completion = await adk.providers.litellm.chat_completion( + llm_config=LLMConfig(model="gpt-4o-mini", messages=state.messages), + trace_id=params.task.id, + ) + + if not chat_completion.choices or not chat_completion.choices[0].message: + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="❌ Failed to generate review. Please try again.", + ), + ) + return + + review_response = chat_completion.choices[0].message.content or "" + + # Add assistant response to conversation + state.messages.append(AssistantMessage(content=review_response)) + + # Parse the review response + try: + review_data = json.loads(review_response.strip()) + feedback = review_data.get("feedback", []) + except json.JSONDecodeError: + # Fallback if LLM doesn't return valid JSON + feedback = ["Unable to parse review response"] + + # Create result message + if feedback: + result_message = f"❌ **Content Needs Revision**\n\nIssues found:\n" + "\n".join([f"• {item}" for item in feedback]) + approval_status = "needs_revision" + else: + result_message = "✅ **Content Approved**\n\nAll rules have been met!" + approval_status = "approved" + + # Send the review result back to this task + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=result_message, + ), + ) + + # Also send the result back to the orchestrator agent if this request came from another agent + if params.event.content.author == "agent" and orchestrator_task_id: + try: + # Send result back to orchestrator using Pydantic model + result_data = CriticResponse( + feedback=feedback, + approval_status=approval_status, + task_id=params.task.id + ).model_dump() + + await adk.acp.send_event( + agent_name="ab090-orchestrator-agent", + task_id=orchestrator_task_id, # Use the orchestrator's original task ID + content=TextContent( + author="agent", + content=json.dumps(result_data) + ) + ) + logger.info(f"Sent review result back to orchestrator for task {orchestrator_task_id}") + + except Exception as e: + logger.error(f"Failed to send result to orchestrator: {e}") + + # Update state + await adk.state.update( + state_id=task_state.id, + task_id=params.task.id, + agent_id=params.agent.id, + state=state, + trace_id=params.task.id, + ) + + logger.info(f"Completed review for task {params.task.id}: {len(feedback)} issues found") + + except Exception as e: + logger.error(f"Error in content review: {e}") + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"❌ Error reviewing content: {e}", + ), + ) + + +@acp.on_task_cancel +async def handle_task_cancel(params: CancelTaskParams): + """Handle task cancellation.""" + logger.info(f"Critic task cancelled: {params.task.id}") diff --git a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/formatter.py b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/formatter.py new file mode 100644 index 000000000..3301d066b --- /dev/null +++ b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/formatter.py @@ -0,0 +1,327 @@ +# Formatter Agent - Converts approved content to various target formats + +import os +import sys +import json +from typing import List +from pathlib import Path + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams +from agentex.lib.types.fastacp import AsyncACPConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.types.llm_messages import ( + Message, + LLMConfig, + UserMessage, + SystemMessage, + AssistantMessage, +) +from agentex.lib.sdk.fastacp.fastacp import FastACP + +# Add the current directory to the Python path to enable imports +current_dir = Path(__file__).parent +if str(current_dir) not in sys.path: + sys.path.append(str(current_dir)) + +from models import FormatterRequest, FormatterResponse + +from agentex.lib.utils.model_utils import BaseModel + +logger = make_logger(__name__) + +# Create an ACP server with base configuration +acp = FastACP.create( + acp_type="async", + config=AsyncACPConfig( + type="base", + ), +) + + +class FormatterState(BaseModel): + messages: List[Message] + format_history: List[dict] = [] + + +@acp.on_task_create +async def handle_task_create(params: CreateTaskParams): + """Initialize the formatter agent state.""" + logger.info(f"Formatter task created: {params.task.id}") + + # Initialize state with system message + system_message = SystemMessage( + content="""You are a professional content formatter specialist. Your job is to convert approved content into various target formats while preserving the original message and quality. + +Your responsibilities: +1. Convert content to the specified target format (HTML, Markdown, JSON, etc.) +2. Apply proper formatting conventions for the target format +3. Preserve all content and meaning during conversion +4. Ensure the formatted output is valid and well-structured + +Supported formats: +- HTML: Convert to clean, semantic HTML with appropriate tags +- Markdown: Convert to properly formatted Markdown syntax +- JSON: Structure content in a meaningful JSON format +- Text: Clean plain text formatting +- Email: Format as professional email with proper structure + +When formatting: +1. Maintain the original content's meaning and tone +2. Apply format-specific best practices +3. Ensure proper structure and readability +4. Use semantic elements appropriate to the format + +You must respond with a JSON object in this exact format: +{ + "formatted_content": "the fully formatted content here" +} + +Do not include any other text, explanations, or formatting outside the JSON response.""" + ) + + state = FormatterState(messages=[system_message]) + await adk.state.create(task_id=params.task.id, agent_id=params.agent.id, state=state) + + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="🎨 **Formatter Agent** - Content Format Conversion\n\nI specialize in converting approved content to various target formats while preserving meaning and quality.\n\nSend me a JSON request with:\n```json\n{\n \"content\": \"Content to format\",\n \"target_format\": \"HTML|Markdown|JSON|Text|Email\"\n}\n```\n\nI'll respond with formatted content JSON:\n```json\n{\n \"formatted_content\": \"Your beautifully formatted content\"\n}\n```\n\nSupported formats: HTML, Markdown, JSON, Text, Email\nReady to make your content shine! ✨", + ), + ) + + +@acp.on_task_event_send +async def handle_event_send(params: SendEventParams): + """Handle content formatting requests.""" + + if not params.event.content: + return + + if params.event.content.type != "text": + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="❌ I can only process text messages.", + ), + ) + return + + # Echo back the message (if from user) + if params.event.content.author == "user": + await adk.messages.create(task_id=params.task.id, content=params.event.content) + + # Check if OpenAI API key is available + if not os.environ.get("OPENAI_API_KEY"): + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="❌ OpenAI API key not found. Please set the OPENAI_API_KEY environment variable.", + ), + ) + return + + content = params.event.content.content + + try: + # Parse the JSON request + try: + request_data = json.loads(content) + except json.JSONDecodeError: + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="❌ Please provide a valid JSON request with 'content' and 'target_format' fields.", + ), + ) + return + + # Validate required fields + if "content" not in request_data or "target_format" not in request_data: + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="❌ Missing required fields: 'content' and 'target_format'", + ), + ) + return + + # Parse and validate request using Pydantic + try: + formatter_request = FormatterRequest.model_validate(request_data) + except ValueError as e: + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"❌ Invalid request format: {e}", + ), + ) + return + + content_to_format = formatter_request.content + target_format = formatter_request.target_format.upper() + orchestrator_task_id = formatter_request.orchestrator_task_id + + # Validate target format + supported_formats = ["HTML", "MARKDOWN", "JSON", "TEXT", "EMAIL"] + if target_format not in supported_formats: + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"❌ Unsupported format: {target_format}. Supported formats: {', '.join(supported_formats)}", + ), + ) + return + + # Get current state + task_state = await adk.state.get_by_task_and_agent(task_id=params.task.id, agent_id=params.agent.id) + state = FormatterState.model_validate(task_state.state) + + # Add this format request to history + state.format_history.append({ + "content": content_to_format, + "target_format": target_format, + "timestamp": "now" # In real implementation, use proper timestamp + }) + + # Send status update + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"🎨 **Formatting Content** (Request #{len(state.format_history)})\n\nConverting to {target_format} format...", + ), + ) + + # Create formatting prompt based on target format + format_instructions = { + "HTML": "Convert to clean, semantic HTML with appropriate tags (headings, paragraphs, lists, etc.). Use proper HTML structure.", + "MARKDOWN": "Convert to properly formatted Markdown syntax with appropriate headers, emphasis, lists, and other Markdown elements.", + "JSON": "Structure the content in a meaningful JSON format with appropriate keys and values that represent the content structure.", + "TEXT": "Format as clean, well-structured plain text with proper line breaks and spacing.", + "EMAIL": "Format as a professional email with proper subject, greeting, body, and closing." + } + + user_message_content = f"""Please format the following content into {target_format} format: + +CONTENT TO FORMAT: +{content_to_format} + +FORMATTING INSTRUCTIONS: +{format_instructions[target_format]} + +Requirements: +1. Preserve all original meaning and content +2. Apply best practices for {target_format} formatting +3. Ensure the output is valid and well-structured +4. Maintain readability and professional appearance + +You MUST respond with a JSON object in this exact format: +{{ + "formatted_content": "the fully formatted content here" +}} + +Do not include any other text, explanations, or formatting outside the JSON response.""" + + # Add user message to conversation + state.messages.append(UserMessage(content=user_message_content)) + + # Generate formatted content using LLM + chat_completion = await adk.providers.litellm.chat_completion( + llm_config=LLMConfig(model="gpt-4o-mini", messages=state.messages), + trace_id=params.task.id, + ) + + if not chat_completion.choices or not chat_completion.choices[0].message: + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="❌ Failed to format content. Please try again.", + ), + ) + return + + format_response = chat_completion.choices[0].message.content or "" + + # Add assistant response to conversation + state.messages.append(AssistantMessage(content=format_response)) + + # Parse the format response + try: + format_data = json.loads(format_response.strip()) + formatted_content = format_data.get("formatted_content", "") + except json.JSONDecodeError: + # Fallback if LLM doesn't return valid JSON + formatted_content = format_response.strip() + + # Create result message + result_message = f"✅ **Content Formatted Successfully**\n\nFormat: {target_format}\n\n**Formatted Content:**\n```{target_format.lower()}\n{formatted_content}\n```" + + # Send the formatted content back to this task + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=result_message, + ), + ) + + # Also send the result back to the orchestrator agent if this request came from another agent + if params.event.content.author == "agent" and orchestrator_task_id: + try: + # Send result back to orchestrator + # Send result back to orchestrator using Pydantic model + result_data = FormatterResponse( + formatted_content=formatted_content, + target_format=target_format, + task_id=params.task.id + ).model_dump() + + await adk.acp.send_event( + agent_name="ab090-orchestrator-agent", + task_id=orchestrator_task_id, # Use the orchestrator's original task ID + content=TextContent( + author="agent", + content=json.dumps(result_data) + ) + ) + logger.info(f"Sent formatted content back to orchestrator for task {orchestrator_task_id}") + + except Exception as e: + logger.error(f"Failed to send result to orchestrator: {e}") + + # Update state + await adk.state.update( + state_id=task_state.id, + task_id=params.task.id, + agent_id=params.agent.id, + state=state, + trace_id=params.task.id, + ) + + logger.info(f"Completed formatting for task {params.task.id}: {target_format}") + + except Exception as e: + logger.error(f"Error in content formatting: {e}") + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"❌ Error formatting content: {e}", + ), + ) + + +@acp.on_task_cancel +async def handle_task_cancel(params: CancelTaskParams): + """Handle task cancellation.""" + logger.info(f"Formatter task cancelled: {params.task.id}") diff --git a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/models.py b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/models.py new file mode 100644 index 000000000..e9aef6d75 --- /dev/null +++ b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/models.py @@ -0,0 +1,80 @@ +""" +Pydantic models for request/response data structures across all agents. +This provides type safety and clear documentation of expected data formats. +""" + +from typing import List, Literal, Optional + +from pydantic import Field, BaseModel + +# Request Models + +class OrchestratorRequest(BaseModel): + """Request to the orchestrator agent to start a content creation workflow.""" + request: str = Field(..., description="The content creation request") + rules: Optional[List[str]] = Field(default=None, description="Rules for content validation") + target_format: Optional[str] = Field(default=None, description="Desired output format (HTML, MARKDOWN, JSON, TEXT, EMAIL)") + + +class CreatorRequest(BaseModel): + """Request to the creator agent for content generation or revision.""" + request: str = Field(..., description="The content creation request") + current_draft: Optional[str] = Field(default=None, description="Current draft for revision (if any)") + feedback: Optional[List[str]] = Field(default=None, description="Feedback from critic for revision") + orchestrator_task_id: Optional[str] = Field(default=None, description="Original orchestrator task ID for callback") + + +class CriticRequest(BaseModel): + """Request to the critic agent for content review.""" + draft: str = Field(..., description="Content draft to review") + rules: List[str] = Field(..., description="Rules to validate against") + orchestrator_task_id: Optional[str] = Field(default=None, description="Original orchestrator task ID for callback") + + +class FormatterRequest(BaseModel): + """Request to the formatter agent for content formatting.""" + content: str = Field(..., description="Content to format") + target_format: str = Field(..., description="Target format (HTML, MARKDOWN, JSON, TEXT, EMAIL)") + orchestrator_task_id: Optional[str] = Field(default=None, description="Original orchestrator task ID for callback") + + +# Response Models + +class CreatorResponse(BaseModel): + """Response from the creator agent.""" + agent: Literal["creator"] = Field(default="creator", description="Agent identifier") + content: str = Field(..., description="Generated or revised content") + task_id: str = Field(..., description="Task ID for this creation request") + + +class CriticResponse(BaseModel): + """Response from the critic agent.""" + agent: Literal["critic"] = Field(default="critic", description="Agent identifier") + feedback: List[str] = Field(..., description="List of feedback items (empty if approved)") + approval_status: str = Field(..., description="Approval status (approved/needs_revision)") + task_id: str = Field(..., description="Task ID for this review request") + + +class FormatterResponse(BaseModel): + """Response from the formatter agent.""" + agent: Literal["formatter"] = Field(default="formatter", description="Agent identifier") + formatted_content: str = Field(..., description="Content formatted in the target format") + target_format: str = Field(..., description="The format used for formatting") + task_id: str = Field(..., description="Task ID for this formatting request") + + +# Enums for validation + +class SupportedFormat(str): + """Supported output formats for the formatter.""" + HTML = "HTML" + MARKDOWN = "MARKDOWN" + JSON = "JSON" + TEXT = "TEXT" + EMAIL = "EMAIL" + + +class ApprovalStatus(str): + """Content approval status from critic.""" + APPROVED = "approved" + NEEDS_REVISION = "needs_revision" \ No newline at end of file diff --git a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/orchestrator.py b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/orchestrator.py new file mode 100644 index 000000000..f9aea8be4 --- /dev/null +++ b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/orchestrator.py @@ -0,0 +1,419 @@ +# Orchestrator Agent - Coordinates the multi-agent content creation workflow +from __future__ import annotations + +import sys +import json +from pathlib import Path + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams +from agentex.lib.types.fastacp import AsyncACPConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.sdk.fastacp.fastacp import FastACP + +# Add the current directory to the Python path to enable imports +current_dir = Path(__file__).parent +if str(current_dir) not in sys.path: + sys.path.append(str(current_dir)) + +from models import CriticResponse, CreatorResponse, FormatterResponse, OrchestratorRequest +from state_machines.content_workflow import WorkflowData, ContentWorkflowState, ContentWorkflowStateMachine + +logger = make_logger(__name__) + +# Create an ACP server with base configuration +acp = FastACP.create( + acp_type="async", + config=AsyncACPConfig( + type="base", + ), +) + +# Store active state machines by task_id +active_workflows: dict[str, ContentWorkflowStateMachine] = {} + + +@acp.on_task_create +async def handle_task_create(params: CreateTaskParams): + """Initialize the content workflow state machine when a task is created.""" + logger.info(f"Task created: {params.task.id}") + + # Acknowledge task creation + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="🎭 **Orchestrator Agent** - Content Assembly Line\n\nI coordinate a multi-agent workflow for content creation:\n• **Creator Agent** - Generates content\n• **Critic Agent** - Reviews against rules\n• **Formatter Agent** - Formats final output\n\nSend me a JSON request with:\n```json\n{\n \"request\": \"Your content request\",\n \"rules\": [\"Rule 1\", \"Rule 2\"],\n \"target_format\": \"HTML\"\n}\n```\n\nReady to orchestrate your content creation! 🚀", + ), + ) + + +@acp.on_task_event_send +async def handle_event_send(params: SendEventParams): + """Handle incoming events and coordinate the multi-agent workflow.""" + + if not params.event.content: + return + + if params.event.content.type != "text": + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content="❌ I can only process text messages.", + ), + ) + return + + # Echo back the user's message + if params.event.content.author == "user": + await adk.messages.create(task_id=params.task.id, content=params.event.content) + + content = params.event.content.content + + # Check if this is a response from another agent + if await handle_agent_response(params.task.id, content): + return + + # Otherwise, this is a user request to start a new workflow + if params.event.content.author == "user": + await start_content_workflow(params.task.id, content) + + +async def handle_agent_response(task_id: str, content: str) -> bool: + """Handle responses from other agents in the workflow. Returns True if this was an agent response.""" + try: + # Try to parse as JSON (agent responses should be JSON) + response_data = json.loads(content) + + # Check if this is a response from one of our agents + if "agent" in response_data and "task_id" in response_data: + agent_name = response_data["agent"] + + # Find the corresponding workflow + workflow = active_workflows.get(task_id) + if not workflow: + logger.warning(f"No active workflow found for task {task_id}") + return True + + logger.info(f"Received response from {agent_name} for task {task_id}") + + # Handle based on agent type + if agent_name == "creator": + try: + creator_response = CreatorResponse.model_validate(response_data) + await workflow.handle_creator_response(creator_response.content) + + # Send status update + await adk.messages.create( + task_id=task_id, + content=TextContent( + author="agent", + content=f"📝 **Creator Output:**\n{creator_response.content}\n\n🔍 Calling critic agent...", + ), + ) + except ValueError as e: + logger.error(f"Invalid creator response format: {e}") + return True + + # Advance the workflow to the next state + await advance_workflow(task_id, workflow) + + elif agent_name == "critic": + try: + critic_response = CriticResponse.model_validate(response_data) + feedback = critic_response.feedback + approval_status = critic_response.approval_status + except ValueError as e: + logger.error(f"Invalid critic response format: {e}") + return True + + # Create the response in the format expected by the state machine + critic_response = {"feedback": feedback} + await workflow.handle_critic_response(json.dumps(critic_response)) + + # Send status update + if feedback: + feedback_text = '\n• '.join(feedback) + await adk.messages.create( + task_id=task_id, + content=TextContent( + author="agent", + content=f"🎯 **Critic Feedback:**\n• {feedback_text}\n\n📝 Calling creator agent for revision...", + ), + ) + else: + await adk.messages.create( + task_id=task_id, + content=TextContent( + author="agent", + content=f"✅ **Content Approved by Critic!**\n\n🎨 Calling formatter agent...", + ), + ) + + # Advance the workflow to the next state + await advance_workflow(task_id, workflow) + + elif agent_name == "formatter": + try: + formatter_response = FormatterResponse.model_validate(response_data) + formatted_content = formatter_response.formatted_content + target_format = formatter_response.target_format + except ValueError as e: + logger.error(f"Invalid formatter response format: {e}") + return True + + # Create the response in the format expected by the state machine + formatter_response = {"formatted_content": formatted_content} + await workflow.handle_formatter_response(json.dumps(formatter_response)) + + # Workflow completion is handled in handle_formatter_response + await complete_workflow(task_id, workflow) + + # Send final result + await adk.messages.create( + task_id=task_id, + content=TextContent( + author="agent", + content=f"🎉 **Workflow Complete!**\n\nYour content has been successfully created, reviewed, and formatted.\n\n**Final Result ({target_format}):**\n```{target_format.lower()}\n{formatted_content}\n```", + ), + ) + + # Clean up completed workflow + if task_id in active_workflows: + del active_workflows[task_id] + logger.info(f"Cleaned up completed workflow for task {task_id}") + + # Continue workflow execution + if workflow and not await workflow.terminal_condition(): + await advance_workflow(task_id, workflow) + + return True + + except json.JSONDecodeError: + # Not a JSON response, might be a user message + return False + except Exception as e: + logger.error(f"Error handling agent response: {e}") + return True + + return False + + +async def start_content_workflow(task_id: str, content: str): + """Start a new content creation workflow.""" + try: + # Parse the user request + try: + request_data = json.loads(content) + except json.JSONDecodeError: + await adk.messages.create( + task_id=task_id, + content=TextContent( + author="agent", + content="❌ Please provide a valid JSON request with 'request', 'rules', and 'target_format' fields.\n\nExample:\n```json\n{\n \"request\": \"Write a welcome message\",\n \"rules\": [\"Under 50 words\", \"Friendly tone\"],\n \"target_format\": \"HTML\"\n}\n```", + ), + ) + return + + # Parse and validate request using Pydantic + try: + orchestrator_request = OrchestratorRequest.model_validate(request_data) + except ValueError as e: + await adk.messages.create( + task_id=task_id, + content=TextContent( + author="agent", + content=f"❌ Invalid request format: {e}", + ), + ) + return + + user_request = orchestrator_request.request + rules = orchestrator_request.rules + target_format = orchestrator_request.target_format + + if not isinstance(rules, list): + await adk.messages.create( + task_id=task_id, + content=TextContent( + author="agent", + content="❌ 'rules' must be a list of strings", + ), + ) + return + + # Create workflow data + workflow_data = WorkflowData( + user_request=user_request, + rules=rules, + target_format=target_format + ) + + # Create and start the state machine + workflow = ContentWorkflowStateMachine(task_id=task_id, initial_data=workflow_data) + active_workflows[task_id] = workflow + + # Send acknowledgment + await adk.messages.create( + task_id=task_id, + content=TextContent( + author="agent", + content=f"🚀 **Starting Content Workflow**\n\n**Request:** {user_request}\n**Rules:** {len(rules)} rule(s)\n**Target Format:** {target_format}\n\nInitializing multi-agent workflow...", + ), + ) + + # Start the workflow + await advance_workflow(task_id, workflow) + logger.info(f"Started content workflow for task {task_id}") + + except Exception as e: + logger.error(f"Error starting workflow: {e}") + await adk.messages.create( + task_id=task_id, + content=TextContent( + author="agent", + content=f"❌ Error starting workflow: {e}", + ), + ) + + +async def advance_workflow(task_id: str, workflow: ContentWorkflowStateMachine): + """Advance the workflow to the next state.""" + + try: + # Keep advancing until we reach a waiting state or complete + max_steps = 10 # Prevent infinite loops + step_count = 0 + + while step_count < max_steps and not await workflow.terminal_condition(): + current_state = workflow.get_current_state() + data = workflow.get_state_machine_data() + logger.info(f"Advancing workflow from state: {current_state} (step {step_count + 1})") + + # Execute the current state's workflow + logger.info(f"About to execute workflow step") + await workflow.step() + logger.info(f"Workflow step completed") + + new_state = workflow.get_current_state() + logger.info(f"New state after step: {new_state}") + + # Skip redundant status updates since we handle them in response handlers + # if current_state != new_state: + # await send_status_update(task_id, new_state, data) + + # Stop advancing if we're in a waiting state (waiting for external response) + if new_state in [ContentWorkflowState.WAITING_FOR_CREATOR, + ContentWorkflowState.WAITING_FOR_CRITIC, + ContentWorkflowState.WAITING_FOR_FORMATTER]: + logger.info(f"Workflow paused in waiting state: {new_state}") + break + + step_count += 1 + + # Check if workflow is complete + if await workflow.terminal_condition(): + final_state = workflow.get_current_state() + if final_state == ContentWorkflowState.COMPLETED: + await complete_workflow(task_id, workflow) + else: + await fail_workflow(task_id, workflow) + elif step_count >= max_steps: + logger.error(f"Workflow exceeded max steps ({max_steps}), stopping") + data = workflow.get_state_machine_data() + data.last_error = f"Workflow exceeded maximum steps ({max_steps})" + await workflow.transition(ContentWorkflowState.FAILED) + await fail_workflow(task_id, workflow) + + except Exception as e: + logger.error(f"Error advancing workflow: {e}") + await adk.messages.create( + task_id=task_id, + content=TextContent( + author="agent", + content=f"❌ Workflow error: {e}", + ), + ) + + +async def send_status_update(task_id: str, state: str, data: WorkflowData): + """Send status updates to the user based on the current state.""" + + message = "" + # Special handling for CREATING state to show feedback + if state == ContentWorkflowState.CREATING: + if data.iteration_count > 0 and data.feedback: + feedback_text = '\n- '.join(data.feedback) + message = f"🔄 **Revising Content** (Iteration {data.iteration_count + 1})\n\nCritic provided feedback:\n- {feedback_text}\n\nSending back to Creator Agent for revision..." + else: + message = f"📝 **Step 1/3: Creating Content** (Iteration {data.iteration_count + 1})\n\nSending request to Creator Agent..." + else: + status_messages = { + ContentWorkflowState.WAITING_FOR_CREATOR: "⏳ Waiting for Creator Agent to generate content...", + ContentWorkflowState.REVIEWING: f"🔍 **Step 2/3: Reviewing Content** (Iteration {data.iteration_count})\n\nSending draft to Critic Agent for review against {len(data.rules)} rule(s)...", + ContentWorkflowState.WAITING_FOR_CRITIC: f"⏳ Waiting for Critic Agent to review...\n\n**Draft:**\n{data.current_draft}\n\n**Rules:**\n- {', '.join(data.rules)}", + ContentWorkflowState.FORMATTING: f"🎨 **Step 3/3: Formatting Content**\n\nSending approved content to Formatter Agent for {data.target_format} formatting...", + ContentWorkflowState.WAITING_FOR_FORMATTER: "⏳ Waiting for Formatter Agent to format content...", + ContentWorkflowState.FAILED: f"❌ **Workflow Failed**\n\nError: {data.last_error}", + } + message = status_messages.get(state, f"📊 Current state: {state}") + + if not message: + return + + await adk.messages.create( + task_id=task_id, + content=TextContent( + author="agent", + content=message, + ), + ) + + +async def complete_workflow(task_id: str, workflow: ContentWorkflowStateMachine): + """Handle successful workflow completion.""" + + data = workflow.get_state_machine_data() + + await adk.messages.create( + task_id=task_id, + content=TextContent( + author="agent", + content=f"✅ **Content Creation Complete!**\n\n🎯 **Original Request:** {data.user_request}\n🔄 **Iterations:** {data.iteration_count}\n📋 **Rules Applied:** {len(data.rules)}\n🎨 **Format:** {data.target_format}\n\n📝 **Final Content:**\n\n{data.final_content}", + ), + ) + + # Clean up + if task_id in active_workflows: + del active_workflows[task_id] + + +async def fail_workflow(task_id: str, workflow: ContentWorkflowStateMachine): + """Handle workflow failure.""" + + data = workflow.get_state_machine_data() + + await adk.messages.create( + task_id=task_id, + content=TextContent( + author="agent", + content=f"❌ **Workflow Failed**\n\nAfter {data.iteration_count} iteration(s), the content creation workflow has failed.\n\n**Error:** {data.last_error}\n\nPlease try again with a simpler request or fewer rules.", + ), + ) + + # Clean up + if task_id in active_workflows: + del active_workflows[task_id] + + +@acp.on_task_cancel +async def handle_task_cancel(params: CancelTaskParams): + """Handle task cancellation.""" + logger.info(f"Orchestrator task cancelled: {params.task.id}") + + # Clean up any active workflow + if params.task.id in active_workflows: + del active_workflows[params.task.id] + logger.info(f"Cleaned up cancelled workflow for task {params.task.id}") diff --git a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/state_machines/__init__.py b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/state_machines/__init__.py new file mode 100644 index 000000000..1b5b70b5c --- /dev/null +++ b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/state_machines/__init__.py @@ -0,0 +1 @@ +# State machines package for multi-agent orchestration diff --git a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/state_machines/content_workflow.py b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/state_machines/content_workflow.py new file mode 100644 index 000000000..389b05751 --- /dev/null +++ b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/state_machines/content_workflow.py @@ -0,0 +1,307 @@ +# ruff: noqa: ARG002 +from __future__ import annotations + +import json +import asyncio +from enum import Enum +from typing import Optional + +from agentex.lib import adk +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.sdk.state_machine.state import State +from agentex.lib.sdk.state_machine.state_machine import StateMachine +from agentex.lib.sdk.state_machine.state_workflow import StateWorkflow + +logger = make_logger(__name__) + +# Use adk module for inter-agent communication + + +class ContentWorkflowState(str, Enum): + INITIALIZING = "initializing" + CREATING = "creating" + WAITING_FOR_CREATOR = "waiting_for_creator" + REVIEWING = "reviewing" + WAITING_FOR_CRITIC = "waiting_for_critic" + FORMATTING = "formatting" + WAITING_FOR_FORMATTER = "waiting_for_formatter" + COMPLETED = "completed" + FAILED = "failed" + + +class WorkflowData(BaseModel): + user_request: str = "" + rules: list[str] = [] + target_format: str = "text" + current_draft: str = "" + feedback: list[str] = [] + final_content: str = "" + iteration_count: int = 0 + max_iterations: int = 10 + + # Task tracking for async coordination + creator_task_id: Optional[str] = None + critic_task_id: Optional[str] = None + formatter_task_id: Optional[str] = None + + # Response tracking + pending_response_from: Optional[str] = None + last_error: Optional[str] = None + + +class InitializingWorkflow(StateWorkflow): + async def execute(self, state_machine: "ContentWorkflowStateMachine", state_machine_data: WorkflowData) -> str: + logger.info("Initializing content workflow") + return ContentWorkflowState.CREATING + + +class CreatingWorkflow(StateWorkflow): + async def execute(self, state_machine: "ContentWorkflowStateMachine", state_machine_data: WorkflowData) -> str: + logger.info("Starting content creation") + try: + # Create task for creator agent + creator_task = await adk.acp.create_task(agent_name="ab090-creator-agent") + task_id = creator_task.id + logger.info(f"Created task ID: {task_id}") + + state_machine_data.creator_task_id = task_id + state_machine_data.pending_response_from = "creator" + + # Send request to creator + request_data = { + "request": state_machine_data.user_request, + "current_draft": state_machine_data.current_draft, + "feedback": state_machine_data.feedback, + "orchestrator_task_id": state_machine._task_id # Tell creator which task to respond to + } + + # Send event to creator agent + await adk.acp.send_event( + task_id=task_id, + agent_name="ab090-creator-agent", + content=TextContent(author="agent", content=json.dumps(request_data)) + ) + + logger.info(f"Sent creation request to creator agent, task_id: {task_id}") + return ContentWorkflowState.WAITING_FOR_CREATOR + + except Exception as e: + logger.error(f"Error in creating workflow: {e}") + state_machine_data.last_error = str(e) + return ContentWorkflowState.FAILED + + +class WaitingForCreatorWorkflow(StateWorkflow): + async def execute(self, state_machine: "ContentWorkflowStateMachine", state_machine_data: WorkflowData) -> str: + # This state waits for creator response - transition happens in ACP event handler + logger.info("Waiting for creator response...") + + # Check if workflow should terminate + if await state_machine.terminal_condition(): + logger.info("Workflow terminated, stopping waiting loop") + return state_machine.get_current_state() + + await asyncio.sleep(1) # Prevent tight loop, allow other tasks to run + return ContentWorkflowState.WAITING_FOR_CREATOR + + +class ReviewingWorkflow(StateWorkflow): + async def execute(self, state_machine: "ContentWorkflowStateMachine", state_machine_data: WorkflowData) -> str: + logger.info("Starting content review") + try: + # Create task for critic agent + critic_task = await adk.acp.create_task(agent_name="ab090-critic-agent") + task_id = critic_task.id + logger.info(f"Created critic task ID: {task_id}") + + state_machine_data.critic_task_id = task_id + state_machine_data.pending_response_from = "critic" + + # Send request to critic + request_data = { + "draft": state_machine_data.current_draft, + "rules": state_machine_data.rules, + "orchestrator_task_id": state_machine._task_id # Tell critic which task to respond to + } + + # Send event to critic agent + await adk.acp.send_event( + task_id=task_id, + agent_name="ab090-critic-agent", + content=TextContent(author="agent", content=json.dumps(request_data)) + ) + + logger.info(f"Sent review request to critic agent, task_id: {task_id}") + return ContentWorkflowState.WAITING_FOR_CRITIC + + except Exception as e: + logger.error(f"Error in reviewing workflow: {e}") + state_machine_data.last_error = str(e) + return ContentWorkflowState.FAILED + + +class WaitingForCriticWorkflow(StateWorkflow): + async def execute(self, state_machine: "ContentWorkflowStateMachine", state_machine_data: WorkflowData) -> str: + # This state waits for critic response - transition happens in ACP event handler + logger.info("Waiting for critic response...") + + # Check if workflow should terminate + if await state_machine.terminal_condition(): + logger.info("Workflow terminated, stopping waiting loop") + return state_machine.get_current_state() + + await asyncio.sleep(1) # Prevent tight loop, allow other tasks to run + return ContentWorkflowState.WAITING_FOR_CRITIC + + +class FormattingWorkflow(StateWorkflow): + async def execute(self, state_machine: "ContentWorkflowStateMachine", state_machine_data: WorkflowData) -> str: + logger.info("Starting content formatting") + try: + # Create task for formatter agent + formatter_task = await adk.acp.create_task(agent_name="ab090-formatter-agent") + task_id = formatter_task.id + logger.info(f"Created formatter task ID: {task_id}") + + state_machine_data.formatter_task_id = task_id + state_machine_data.pending_response_from = "formatter" + + # Send request to formatter + request_data = { + "content": state_machine_data.current_draft, # Fixed field name + "target_format": state_machine_data.target_format, + "orchestrator_task_id": state_machine._task_id # Tell formatter which task to respond to + } + + # Send event to formatter agent + await adk.acp.send_event( + task_id=task_id, + agent_name="ab090-formatter-agent", + content=TextContent(author="agent", content=json.dumps(request_data)) + ) + + logger.info(f"Sent format request to formatter agent, task_id: {task_id}") + return ContentWorkflowState.WAITING_FOR_FORMATTER + + except Exception as e: + logger.error(f"Error in formatting workflow: {e}") + state_machine_data.last_error = str(e) + return ContentWorkflowState.FAILED + + +class WaitingForFormatterWorkflow(StateWorkflow): + async def execute(self, state_machine: "ContentWorkflowStateMachine", state_machine_data: WorkflowData) -> str: + # This state waits for formatter response - transition happens in ACP event handler + logger.info("Waiting for formatter response...") + + # Check if workflow should terminate + if await state_machine.terminal_condition(): + logger.info("Workflow terminated, stopping waiting loop") + return state_machine.get_current_state() + + await asyncio.sleep(1) # Prevent tight loop, allow other tasks to run + return ContentWorkflowState.WAITING_FOR_FORMATTER + + +class CompletedWorkflow(StateWorkflow): + async def execute(self, state_machine: "ContentWorkflowStateMachine", state_machine_data: WorkflowData) -> str: + logger.info("Content workflow completed successfully") + return ContentWorkflowState.COMPLETED + + +class FailedWorkflow(StateWorkflow): + async def execute(self, state_machine: "ContentWorkflowStateMachine", state_machine_data: WorkflowData) -> str: + logger.error(f"Content workflow failed: {state_machine_data.last_error}") + return ContentWorkflowState.FAILED + + +class ContentWorkflowStateMachine(StateMachine[WorkflowData]): + def __init__(self, task_id: str | None = None, initial_data: WorkflowData | None = None): + states = [ + State(name=ContentWorkflowState.INITIALIZING, workflow=InitializingWorkflow()), + State(name=ContentWorkflowState.CREATING, workflow=CreatingWorkflow()), + State(name=ContentWorkflowState.WAITING_FOR_CREATOR, workflow=WaitingForCreatorWorkflow()), + State(name=ContentWorkflowState.REVIEWING, workflow=ReviewingWorkflow()), + State(name=ContentWorkflowState.WAITING_FOR_CRITIC, workflow=WaitingForCriticWorkflow()), + State(name=ContentWorkflowState.FORMATTING, workflow=FormattingWorkflow()), + State(name=ContentWorkflowState.WAITING_FOR_FORMATTER, workflow=WaitingForFormatterWorkflow()), + State(name=ContentWorkflowState.COMPLETED, workflow=CompletedWorkflow()), + State(name=ContentWorkflowState.FAILED, workflow=FailedWorkflow()), + ] + + super().__init__( + initial_state=ContentWorkflowState.INITIALIZING, + states=states, + task_id=task_id, + state_machine_data=initial_data or WorkflowData(), + trace_transitions=True + ) + + async def terminal_condition(self) -> bool: + current_state = self.get_current_state() + return current_state in [ContentWorkflowState.COMPLETED, ContentWorkflowState.FAILED] + + async def handle_creator_response(self, response_content: str): + """Handle response from creator agent""" + try: + data = self.get_state_machine_data() + data.current_draft = response_content + data.pending_response_from = None + + # Move to reviewing state + await self.transition(ContentWorkflowState.REVIEWING) + logger.info("Received creator response, transitioning to reviewing") + + except Exception as e: + logger.error(f"Error handling creator response: {e}") + data = self.get_state_machine_data() + data.last_error = str(e) + await self.transition(ContentWorkflowState.FAILED) + + async def handle_critic_response(self, response_content: str): + """Handle response from critic agent""" + try: + response_data = json.loads(response_content) + data = self.get_state_machine_data() + data.feedback = response_data.get("feedback") + data.pending_response_from = None + + if data.feedback: + # Has feedback, need to revise + data.iteration_count += 1 + if data.iteration_count >= data.max_iterations: + data.last_error = f"Max iterations ({data.max_iterations}) reached" + await self.transition(ContentWorkflowState.FAILED) + else: + await self.transition(ContentWorkflowState.CREATING) + logger.info(f"Received critic feedback, iteration {data.iteration_count}, transitioning to creating") + else: + # No feedback, content approved + await self.transition(ContentWorkflowState.FORMATTING) + logger.info("Content approved by critic, transitioning to formatting") + + except Exception as e: + logger.error(f"Error handling critic response: {e}") + data = self.get_state_machine_data() + data.last_error = str(e) + await self.transition(ContentWorkflowState.FAILED) + + async def handle_formatter_response(self, response_content: str): + """Handle response from formatter agent""" + try: + response_data = json.loads(response_content) + data = self.get_state_machine_data() + data.final_content = response_data.get("formatted_content") + data.pending_response_from = None + + # Move to completed state + await self.transition(ContentWorkflowState.COMPLETED) + logger.info("Received formatter response, workflow completed") + + except Exception as e: + logger.error(f"Error handling formatter response: {e}") + data = self.get_state_machine_data() + data.last_error = str(e) + await self.transition(ContentWorkflowState.FAILED) diff --git a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/pyproject.toml b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/pyproject.toml new file mode 100644 index 000000000..97c221dc2 --- /dev/null +++ b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/pyproject.toml @@ -0,0 +1,35 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "ab090-multi-agent-content-assembly" +version = "0.1.0" +description = "A multi-agent system that creates content through a collaborative workflow with creator, critic, formatter, and orchestrator agents." +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["manifests"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/start-agents.sh b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/start-agents.sh new file mode 100755 index 000000000..783463635 --- /dev/null +++ b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/start-agents.sh @@ -0,0 +1,327 @@ +#!/bin/bash +# Multi-Agent Content Assembly Line - Start All Agents (Flattened Structure) +# This script starts all 4 agents in the simplified flattened structure + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuration +ORCHESTRATOR_PORT=8000 +CREATOR_PORT=8001 +CRITIC_PORT=8002 +FORMATTER_PORT=8003 + +# Base directory +BASE_DIR="examples/tutorials/10_async/00_base/090_multi_agent_non_temporal" + +echo -e "${BLUE}🎭 Multi-Agent Content Assembly Line (Flattened)${NC}" +echo -e "${BLUE}===============================================${NC}" +echo "" + +# Function to check if port is available +check_port() { + local port=$1 + if lsof -Pi :$port -sTCP:LISTEN -t >/dev/null 2>&1; then + echo -e "${RED}❌ Port $port is already in use${NC}" + echo "Please stop the process using port $port or change the port in the manifest files" + return 1 + fi + return 0 +} + +# Function to check prerequisites +check_prerequisites() { + echo -e "${YELLOW}🔍 Checking prerequisites...${NC}" + + # Check if we're in the right directory + if [[ ! -f "pyproject.toml" ]] || [[ ! -d "src/agentex" ]]; then + echo -e "${RED}❌ Please run this script from the agentex-sdk-python repository root${NC}" + exit 1 + fi + + # Check if flattened directory exists + if [[ ! -d "$BASE_DIR" ]]; then + echo -e "${RED}❌ Flattened multi-agent directory not found: $BASE_DIR${NC}" + exit 1 + fi + + # Check if project directory exists + if [[ ! -d "$BASE_DIR/project" ]]; then + echo -e "${RED}❌ Project directory not found: $BASE_DIR/project${NC}" + exit 1 + fi + + # Check if manifest files exist + if [[ ! -f "$BASE_DIR/orchestrator.yaml" ]]; then + echo -e "${RED}❌ Orchestrator manifest not found: $BASE_DIR/orchestrator.yaml${NC}" + exit 1 + fi + + # Check if uv is available + if ! command -v uv &> /dev/null; then + echo -e "${RED}❌ uv is required but not installed${NC}" + echo "Please install uv: curl -LsSf https://astral.sh/uv/install.sh | sh" + exit 1 + fi + + # Check if OPENAI_API_KEY is set + if [[ -z "${OPENAI_API_KEY}" ]]; then + echo -e "${YELLOW}⚠️ OPENAI_API_KEY not found in environment${NC}" + if [[ -f ".env" ]]; then + echo -e "${GREEN}✅ Found .env file - agents will load it automatically${NC}" + else + echo -e "${RED}❌ No .env file found and OPENAI_API_KEY not set${NC}" + echo "Please create a .env file with OPENAI_API_KEY=your_key_here" + exit 1 + fi + else + echo -e "${GREEN}✅ OPENAI_API_KEY found in environment${NC}" + fi + + # Check ports + echo -e "${YELLOW}🔍 Checking ports...${NC}" + check_port $ORCHESTRATOR_PORT || exit 1 + check_port $CREATOR_PORT || exit 1 + check_port $CRITIC_PORT || exit 1 + check_port $FORMATTER_PORT || exit 1 + + echo -e "${GREEN}✅ All prerequisites met${NC}" + echo "" +} + +# Function to start agent in background +start_agent() { + local name=$1 + local manifest=$2 + local port=$3 + local logfile="/tmp/agentex-${name}.log" + + echo -e "${YELLOW}🚀 Starting ${name} agent on port ${port}...${NC}" + + # Start the agent in background and capture PID + uv run agentex agents run --manifest "$manifest" > "$logfile" 2>&1 & + local pid=$! + + echo "$pid" > "/tmp/agentex-${name}.pid" + echo -e "${GREEN}✅ ${name} agent started (PID: $pid, logs: $logfile)${NC}" + + # Give it a moment to start + sleep 2 + + # Check if process is still running + if ! kill -0 $pid 2>/dev/null; then + echo -e "${RED}❌ ${name} agent failed to start${NC}" + echo "Check logs: tail -f $logfile" + return 1 + fi + + return 0 +} + +# Function to stop all agents +stop_agents() { + echo -e "${YELLOW}🛑 Stopping all agents...${NC}" + + for agent in orchestrator creator critic formatter; do + pidfile="/tmp/agentex-${agent}.pid" + if [[ -f "$pidfile" ]]; then + pid=$(cat "$pidfile") + if kill -0 "$pid" 2>/dev/null; then + echo -e "${YELLOW}🛑 Stopping ${agent} agent (PID: $pid)${NC}" + kill "$pid" + rm -f "$pidfile" + else + echo -e "${YELLOW}⚠️ ${agent} agent was not running${NC}" + rm -f "$pidfile" + fi + fi + done + + echo -e "${GREEN}✅ All agents stopped${NC}" +} + +# Function to show agent status +show_status() { + echo -e "${BLUE}📊 Agent Status${NC}" + echo -e "${BLUE}==============${NC}" + + for agent in orchestrator creator critic formatter; do + pidfile="/tmp/agentex-${agent}.pid" + if [[ -f "$pidfile" ]]; then + pid=$(cat "$pidfile") + if kill -0 "$pid" 2>/dev/null; then + case $agent in + orchestrator) port=$ORCHESTRATOR_PORT ;; + creator) port=$CREATOR_PORT ;; + critic) port=$CRITIC_PORT ;; + formatter) port=$FORMATTER_PORT ;; + esac + echo -e "${GREEN}✅ ${agent} agent running (PID: $pid, Port: $port)${NC}" + else + echo -e "${RED}❌ ${agent} agent not running (stale PID file)${NC}" + rm -f "$pidfile" + fi + else + echo -e "${RED}❌ ${agent} agent not running${NC}" + fi + done +} + +# Function to show logs +show_logs() { + local agent=${1:-"all"} + + if [[ "$agent" == "all" ]]; then + echo -e "${BLUE}📝 Showing logs for all agents (press Ctrl+C to stop)${NC}" + tail -f /tmp/agentex-*.log 2>/dev/null || echo "No log files found" + else + local logfile="/tmp/agentex-${agent}.log" + if [[ -f "$logfile" ]]; then + echo -e "${BLUE}📝 Showing logs for ${agent} agent (press Ctrl+C to stop)${NC}" + tail -f "$logfile" + else + echo -e "${RED}❌ Log file not found: $logfile${NC}" + fi + fi +} + +# Function to test agent connectivity +test_system() { + echo -e "${BLUE}🧪 Testing agent connectivity${NC}" + echo -e "${BLUE}=============================${NC}" + + # Check if agents are responding on their ports + echo -e "${YELLOW}🔍 Testing agent connectivity...${NC}" + + ports=(8000 8001 8002 8003) + agents=("orchestrator" "creator" "critic" "formatter") + all_responding=true + + for i in "${!ports[@]}"; do + port=${ports[$i]} + agent=${agents[$i]} + if nc -z localhost $port 2>/dev/null; then + echo -e "${GREEN}✅ ${agent} agent responding on port $port${NC}" + else + echo -e "${RED}❌ ${agent} agent not responding on port $port${NC}" + all_responding=false + fi + done + + echo "" + if $all_responding; then + echo -e "${GREEN}🎉 All agents are ready and responding!${NC}" + echo -e "${BLUE}💡 You can now:${NC}" + echo " 1. Monitor logs: $0 logs" + echo " 2. Send requests through the AgentEx platform UI" + echo " 3. Use direct HTTP calls to test individual agents" + echo "" + echo -e "${BLUE}🔗 Agent Endpoints:${NC}" + echo " • Orchestrator: http://localhost:8000" + echo " • Creator: http://localhost:8001" + echo " • Critic: http://localhost:8002" + echo " • Formatter: http://localhost:8003" + echo "" + echo -e "${BLUE}📝 Sample Request (send via AgentEx UI):${NC}" + echo '{"request": "Write a brief welcome message for our new AI assistant", "rules": ["Under 100 words", "Friendly tone", "Include emoji"], "target_format": "HTML"}' + else + echo -e "${RED}❌ Some agents are not responding${NC}" + echo "Check status: $0 status" + echo "Check logs: $0 logs" + fi +} + +# Main script logic +case "${1:-start}" in + "start") + check_prerequisites + + echo -e "${YELLOW}🚀 Starting all agents in flattened structure...${NC}" + echo "" + + # Start all agents using the flattened manifests + start_agent "orchestrator" "$BASE_DIR/orchestrator.yaml" $ORCHESTRATOR_PORT || exit 1 + start_agent "creator" "$BASE_DIR/creator.yaml" $CREATOR_PORT || exit 1 + start_agent "critic" "$BASE_DIR/critic.yaml" $CRITIC_PORT || exit 1 + start_agent "formatter" "$BASE_DIR/formatter.yaml" $FORMATTER_PORT || exit 1 + + echo "" + echo -e "${GREEN}🎉 All agents started successfully!${NC}" + echo "" + echo -e "${BLUE}📝 Available commands:${NC}" + echo " $0 status - Show agent status" + echo " $0 logs - Show all agent logs" + echo " $0 logs - Show specific agent logs (orchestrator|creator|critic|formatter)" + echo " $0 test - Test agent connectivity" + echo " $0 stop - Stop all agents" + echo "" + echo -e "${BLUE}📤 Agent Endpoints:${NC}" + echo " • Orchestrator: http://localhost:8000" + echo " • Creator: http://localhost:8001" + echo " • Critic: http://localhost:8002" + echo " • Formatter: http://localhost:8003" + echo "" + echo -e "${BLUE}💡 To interact with agents:${NC}" + echo " 1. Use the AgentEx platform to send tasks" + echo " 2. Send HTTP requests directly to agent endpoints" + echo " 3. Monitor workflow progress with: $0 logs" + echo "" + ;; + + "stop") + stop_agents + ;; + + "status") + show_status + ;; + + "logs") + show_logs "$2" + ;; + + "test") + test_system + ;; + + "help"|"-h"|"--help") + echo -e "${BLUE}🎭 Multi-Agent Content Assembly Line (Flattened Structure)${NC}" + echo "" + echo "Usage: $0 [command]" + echo "" + echo "Commands:" + echo " start Start all agents (default)" + echo " stop Stop all agents" + echo " status Show agent status" + echo " logs Show all agent logs" + echo " logs Show specific agent logs" + echo " test Test agent connectivity" + echo " help Show this help" + echo "" + echo "Examples:" + echo " $0 start # Start all agents" + echo " $0 status # Check if agents are running" + echo " $0 logs # Monitor all logs" + echo " $0 logs orchestrator # Monitor orchestrator logs only" + echo " $0 test # Check agent connectivity" + echo " $0 stop # Stop all agents" + echo "" + echo "Architecture Benefits:" + echo " • 90% less boilerplate (12 files vs ~40 files)" + echo " • Single shared Dockerfile and pyproject.toml" + echo " • All agent code in one directory" + echo " • Maintains AgentEx CLI compatibility" + ;; + + *) + echo -e "${RED}❌ Unknown command: $1${NC}" + echo "Use '$0 help' for usage information" + exit 1 + ;; +esac diff --git a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/tests/test_agent.py b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/tests/test_agent.py new file mode 100644 index 000000000..8af941a81 --- /dev/null +++ b/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/tests/test_agent.py @@ -0,0 +1,250 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: ab090-orchestrator-agent) +""" + +import os +import uuid +import asyncio + +import pytest +import pytest_asyncio +from test_utils.async_utils import ( + stream_agent_response, + send_event_and_poll_yielding, +) + +from agentex import AsyncAgentex +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "ab090-orchestrator-agent") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_multi_agent_workflow_complete(self, client: AsyncAgentex, agent_id: str): + """Test the complete multi-agent workflow with all agents using polling that yields messages.""" + # Create a task for the orchestrator + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Send a content creation request as JSON + request_json = { + "request": "Write a welcome message for our AI assistant", + "rules": ["Under 50 words", "Friendly tone", "Include emoji"], + "target_format": "HTML", + } + + import json + + # Collect messages as they arrive from polling + messages = [] + print("\n🔄 Polling for multi-agent workflow responses...") + + # Track which agents have completed their work + workflow_markers = { + "orchestrator_started": False, + "creator_called": False, + "critic_called": False, + "formatter_called": False, + "workflow_completed": False, + } + + all_agents_done = False + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=json.dumps(request_json), + timeout=120, # Longer timeout for multi-agent workflow + sleep_interval=2.0, + ): + messages.append(message) + # Print messages as they arrive to show real-time progress + msg_text = getattr(message.content, "content", None) if message.content else None + if isinstance(msg_text, str) and msg_text: + # Track agent participation as messages arrive + content = msg_text.lower() + + if "starting content workflow" in content: + workflow_markers["orchestrator_started"] = True + + if "creator output" in content: + workflow_markers["creator_called"] = True + + if "critic feedback" in content or "content approved by critic" in content: + workflow_markers["critic_called"] = True + + if "calling formatter agent" in content: + workflow_markers["formatter_called"] = True + + if "workflow complete" in content or "content creation complete" in content: + workflow_markers["workflow_completed"] = True + + # Check if all agents have participated + all_agents_done = all(workflow_markers.values()) + if all_agents_done: + break + + # Assert all agents participated + assert workflow_markers["orchestrator_started"], "Orchestrator did not start workflow" + assert workflow_markers["creator_called"], "Creator agent was not called" + assert workflow_markers["critic_called"], "Critic agent was not called" + assert workflow_markers["formatter_called"], "Formatter agent was not called" + assert workflow_markers["workflow_completed"], "Workflow did not complete successfully" + + assert all_agents_done, "Not all agents completed their work before timeout" + + # Verify the final output contains HTML (since we requested HTML format) + all_messages_text = " ".join([msg.content.content for msg in messages if msg.content]) + assert "" in all_messages_text.lower() or " None: + nonlocal creator_iterations, critic_feedback_count + async for event in stream_agent_response( + client=client, + task_id=task.id, + timeout=120, + ): + # Handle different event types + if event.get("type") == "full": + content = event.get("content", {}) + if content.get("type") == "text" and content.get("author") == "agent": + message_text = content.get("content", "") + all_messages.append(message_text) + + # Track agent participation + content_lower = message_text.lower() + + if "starting content workflow" in content_lower: + workflow_markers["orchestrator_started"] = True + + if "creator output" in content_lower: + creator_iterations += 1 + workflow_markers["creator_called"] = True + + if "critic feedback" in content_lower or "content approved by critic" in content_lower: + if "critic feedback" in content_lower: + critic_feedback_count += 1 + workflow_markers["critic_called"] = True + + if "calling formatter agent" in content_lower: + workflow_markers["formatter_called"] = True + + if "workflow complete" in content_lower or "content creation complete" in content_lower: + workflow_markers["workflow_completed"] = True + + if event.get("type") == "done": + break + + # Check if all agents have participated + if all(workflow_markers.values()): + break + + stream_task = asyncio.create_task(stream_messages()) + + # Send the event to trigger the agent workflow + event_content = TextContentParam(type="text", author="user", content=json.dumps(request_json)) + await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + await stream_task + + # Validate we got streaming responses + assert len(all_messages) > 0, "No messages received from streaming" + + # Assert all agents participated + assert workflow_markers["orchestrator_started"], "Orchestrator did not start workflow" + assert workflow_markers["creator_called"], "Creator agent was not called" + assert workflow_markers["critic_called"], "Critic agent was not called" + assert workflow_markers["formatter_called"], "Formatter agent was not called" + assert workflow_markers["workflow_completed"], "Workflow did not complete successfully" + + # Verify the final output contains Markdown (since we requested Markdown format) + combined_response = " ".join(all_messages) + assert "markdown" in combined_response.lower() or "#" in combined_response, ( + "Final output does not contain Markdown formatting" + ) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/00_base/100_langgraph/.dockerignore b/examples/tutorials/10_async/00_base/100_langgraph/.dockerignore new file mode 100644 index 000000000..c49489471 --- /dev/null +++ b/examples/tutorials/10_async/00_base/100_langgraph/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/00_base/100_langgraph/Dockerfile b/examples/tutorials/10_async/00_base/100_langgraph/Dockerfile new file mode 100644 index 000000000..c2e4b464c --- /dev/null +++ b/examples/tutorials/10_async/00_base/100_langgraph/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 10_async/00_base/100_langgraph/pyproject.toml /app/100_langgraph/pyproject.toml +COPY 10_async/00_base/100_langgraph/README.md /app/100_langgraph/README.md + +WORKDIR /app/100_langgraph + +# Copy the project code +COPY 10_async/00_base/100_langgraph/project /app/100_langgraph/project + +# Copy the test files +COPY 10_async/00_base/100_langgraph/tests /app/100_langgraph/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies +RUN uv pip install --system .[dev] pytest-asyncio httpx + +# Set environment variables +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=ab100-langgraph + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_async/00_base/100_langgraph/README.md b/examples/tutorials/10_async/00_base/100_langgraph/README.md new file mode 100644 index 000000000..cd2fa6dd6 --- /dev/null +++ b/examples/tutorials/10_async/00_base/100_langgraph/README.md @@ -0,0 +1,57 @@ +# Tutorial: Async LangGraph Agent + +This tutorial demonstrates how to build an **async** LangGraph agent on AgentEx +using the **unified harness surface**: + +```python +turn = LangGraphTurn(stream, model=None) +emitter = UnifiedEmitter(task_id=task_id, trace_id=task_id, ...) +result = await emitter.auto_send_turn(turn) +``` + +The `LangGraphTurn` + `UnifiedEmitter.auto_send_turn` path replaces calling the +lower-level ``stream_langgraph_events`` helper directly. + +## Key Concepts + +### Unified Harness + +`LangGraphTurn` implements the `HarnessTurn` protocol: it wraps the raw +LangGraph `astream()` generator and exposes `events` (an async generator of +`TaskMessageUpdate`) and `usage()` (token counts captured from the final +`AIMessage`). + +`UnifiedEmitter.auto_send_turn(turn)` pushes each event to Redis via +`streaming_task_message_context`, accumulates the final text, and returns a +`TurnResult(final_text=..., usage=...)`. + +The same `LangGraphTurn` object can also be passed to +`UnifiedEmitter.yield_turn` in the sync channel. + +### AGX1-377 Note + +LangGraph emits tool requests as `StreamTaskMessageFull` events (from "updates" +node outputs). The `SpanDeriver` does not open tool spans from Full events +today; that gap is tracked in AGX1-373. + +## Files + +| File | Description | +|------|-------------| +| `project/acp.py` | ACP server using unified harness (LangGraphTurn + auto_send_turn) | +| `project/graph.py` | LangGraph state graph (weather example) | +| `project/tools.py` | Tool definitions (weather example) | +| `tests/test_agent.py` | Integration tests | +| `manifest.yaml` | Agent configuration (name: ab100-langgraph) | + +## Running Locally + +```bash +agentex agents run +``` + +## Running Tests + +```bash +pytest tests/test_agent.py -v +``` diff --git a/examples/tutorials/10_async/00_base/100_langgraph/manifest.yaml b/examples/tutorials/10_async/00_base/100_langgraph/manifest.yaml new file mode 100644 index 000000000..13d64f524 --- /dev/null +++ b/examples/tutorials/10_async/00_base/100_langgraph/manifest.yaml @@ -0,0 +1,58 @@ +build: + context: + root: ../../../ + include_paths: + - 10_async/00_base/100_langgraph + - test_utils + dockerfile: 10_async/00_base/100_langgraph/Dockerfile + dockerignore: 10_async/00_base/100_langgraph/.dockerignore + +local_development: + agent: + port: 8000 + host_address: host.docker.internal + paths: + acp: project/acp.py + +agent: + acp_type: async + name: ab100-langgraph + description: An async LangGraph agent using the unified harness surface (LangGraphTurn + UnifiedEmitter.auto_send_turn) + + temporal: + enabled: false + + credentials: + - env_var_name: OPENAI_API_KEY + secret_name: openai-api-key + secret_key: api-key + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + - env_var_name: SGP_ACCOUNT_ID + secret_name: sgp-account-id + secret_key: account-id + - env_var_name: SGP_CLIENT_BASE_URL + secret_name: sgp-client-base-url + secret_key: url + +deployment: + image: + repository: "" + tag: "latest" + + global: + agent: + name: "ab100-langgraph" + description: "An async LangGraph agent using the unified harness surface" + replicaCount: 1 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/10_async/00_base/100_langgraph/project/__init__.py b/examples/tutorials/10_async/00_base/100_langgraph/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/00_base/100_langgraph/project/acp.py b/examples/tutorials/10_async/00_base/100_langgraph/project/acp.py new file mode 100644 index 000000000..198446607 --- /dev/null +++ b/examples/tutorials/10_async/00_base/100_langgraph/project/acp.py @@ -0,0 +1,109 @@ +"""ACP handler for the async LangGraph agent. + +Uses the unified harness surface: ``LangGraphTurn`` wraps the LangGraph +``astream()`` generator, and ``UnifiedEmitter.auto_send_turn`` streams events +to Redis and returns a ``TurnResult`` with the accumulated final text. + +Properties of the unified surface: +- Tracing is wired through the tracing manager (no bespoke handler boilerplate). +- A single ``UnifiedEmitter.auto_send_turn(LangGraphTurn(stream))`` call + replaces bespoke event-streaming helpers. +- Tool calls/responses go through ``streaming_task_message_context`` + (same code path as text deltas), making the event stream channel-agnostic. +- Usage data (token counts) is captured on ``LangGraphTurn.usage()`` after + ``auto_send_turn`` returns. + +AGX1-377 note: LangGraph emits tool requests as ``StreamTaskMessageFull`` +events (from "updates"). The ``SpanDeriver`` does not open tool spans from +Full events today; that gap is tracked in AGX1-373. +""" + +from __future__ import annotations + +import os + +from dotenv import load_dotenv + +load_dotenv() + +import agentex.lib.adk as adk +from project.graph import create_graph +from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams +from agentex.lib.types.fastacp import AsyncACPConfig +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.lib.adk._modules._langgraph_turn import LangGraphTurn +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config + +logger = make_logger(__name__) + +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) +) + +acp = FastACP.create( + acp_type="async", + config=AsyncACPConfig(type="base"), +) + +_graph = None + + +async def get_graph(): + global _graph + if _graph is None: + _graph = await create_graph() + return _graph + + +@acp.on_task_event_send +async def handle_task_event_send(params: SendEventParams): + """Handle incoming events, streaming tokens and tool calls via unified harness.""" + graph = await get_graph() + task_id = params.task.id + user_message = params.event.content.content + + logger.info(f"Processing message for thread {task_id}") + + await adk.messages.create(task_id=task_id, content=params.event.content) + + async with adk.tracing.span( + trace_id=task_id, + task_id=task_id, + name="message", + input={"message": user_message}, + data={"__span_type__": "AGENT_WORKFLOW"}, + ) as turn_span: + stream = graph.astream( + {"messages": [{"role": "user", "content": user_message}]}, + config={"configurable": {"thread_id": task_id}}, + stream_mode=["messages", "updates"], + ) + + turn = LangGraphTurn(stream, model=None) + emitter = UnifiedEmitter( + task_id=task_id, + trace_id=task_id, + parent_span_id=turn_span.id if turn_span else None, + ) + + result = await emitter.auto_send_turn(turn) + + if turn_span: + turn_span.output = {"final_output": result.final_text} + + +@acp.on_task_create +async def handle_task_create(params: CreateTaskParams): + logger.info(f"Task created: {params.task.id}") + + +@acp.on_task_cancel +async def handle_task_canceled(params: CancelTaskParams): + logger.info(f"Task canceled: {params.task.id}") diff --git a/examples/tutorials/10_async/00_base/100_langgraph/project/graph.py b/examples/tutorials/10_async/00_base/100_langgraph/project/graph.py new file mode 100644 index 000000000..d63f28390 --- /dev/null +++ b/examples/tutorials/10_async/00_base/100_langgraph/project/graph.py @@ -0,0 +1,67 @@ +"""LangGraph graph definition for the 100_langgraph async agent. + +Identical to ``100_langgraph/project/graph.py`` — the graph definition is not +affected by the harness migration. Only ``acp.py`` changes. +""" + +from __future__ import annotations + +from typing import Any, Annotated +from datetime import datetime +from typing_extensions import TypedDict + +from langgraph.graph import START, StateGraph +from langchain_openai import ChatOpenAI +from langgraph.prebuilt import ToolNode, tools_condition +from langchain_core.messages import SystemMessage +from langgraph.graph.message import add_messages + +from project.tools import TOOLS +from agentex.lib.adk import create_checkpointer + +MODEL_NAME = "gpt-5" +SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. + +Current date and time: {timestamp} + +Guidelines: +- Be concise and helpful +- Use tools when they would help answer the user's question +- If you're unsure, ask clarifying questions +- Always provide accurate information +""" + + +class AgentState(TypedDict): + """State schema for the agent graph.""" + + messages: Annotated[list[Any], add_messages] + + +async def create_graph(): + """Create and compile the agent graph with checkpointer.""" + llm = ChatOpenAI( + model=MODEL_NAME, + reasoning={"effort": "high", "summary": "auto"}, + ) + llm_with_tools = llm.bind_tools(TOOLS) + + checkpointer = await create_checkpointer() + + def agent_node(state: AgentState) -> dict[str, Any]: + """Process the current state and generate a response.""" + messages = state["messages"] + if not messages or not isinstance(messages[0], SystemMessage): + system_content = SYSTEM_PROMPT.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")) + messages = [SystemMessage(content=system_content)] + messages + response = llm_with_tools.invoke(messages) + return {"messages": [response]} + + builder = StateGraph(AgentState) + builder.add_node("agent", agent_node) + builder.add_node("tools", ToolNode(tools=TOOLS)) + builder.add_edge(START, "agent") + builder.add_conditional_edges("agent", tools_condition, "tools") + builder.add_edge("tools", "agent") + + return builder.compile(checkpointer=checkpointer) diff --git a/examples/tutorials/10_async/00_base/100_langgraph/project/tools.py b/examples/tutorials/10_async/00_base/100_langgraph/project/tools.py new file mode 100644 index 000000000..e421528fc --- /dev/null +++ b/examples/tutorials/10_async/00_base/100_langgraph/project/tools.py @@ -0,0 +1,24 @@ +"""Tool definitions for the 100_langgraph async agent.""" + +from langchain_core.tools import Tool + + +def get_weather(city: str) -> str: + """Get the current weather for a city. + + Args: + city: The name of the city to get weather for. + + Returns: + A string describing the weather conditions. + """ + return f"The weather in {city} is sunny and 72°F" + + +weather_tool = Tool( + name="get_weather", + func=get_weather, + description="Get the current weather for a city. Input should be a city name.", +) + +TOOLS = [weather_tool] diff --git a/examples/tutorials/10_async/00_base/100_langgraph/pyproject.toml b/examples/tutorials/10_async/00_base/100_langgraph/pyproject.toml new file mode 100644 index 000000000..715477bac --- /dev/null +++ b/examples/tutorials/10_async/00_base/100_langgraph/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "ab100-langgraph" +version = "0.1.0" +description = "An async LangGraph agent using the unified harness surface" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "langgraph", + "langchain-openai", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/examples/tutorials/10_async/00_base/100_langgraph/tests/test_agent.py b/examples/tutorials/10_async/00_base/100_langgraph/tests/test_agent.py new file mode 100644 index 000000000..b80d7a8f9 --- /dev/null +++ b/examples/tutorials/10_async/00_base/100_langgraph/tests/test_agent.py @@ -0,0 +1,100 @@ +""" +Tests for the async harness LangGraph agent. + +Validates the unified harness surface (LangGraphTurn + UnifiedEmitter.auto_send_turn) +end-to-end against a live AgentEx server. + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: ab100-langgraph) +""" + +import os + +import pytest +import pytest_asyncio + +from agentex import AsyncAgentex +from agentex.types import TextContentParam +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.lib.sdk.fastacp.base.base_acp_server import uuid + +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "ab100-langgraph") + + +@pytest_asyncio.fixture +async def client(): + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + @pytest.mark.asyncio + async def test_send_event(self, client: AsyncAgentex, agent_id: str): + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + event_content = TextContentParam( + type="text", + author="user", + content="Hello! What can you help me with?", + ) + await client.agents.send_event( + agent_id=agent_id, + params={"task_id": task.id, "content": event_content}, + ) + + @pytest.mark.asyncio + async def test_tool_calling(self, client: AsyncAgentex, agent_id: str): + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + event_content = TextContentParam( + type="text", + author="user", + content="What's the weather in San Francisco?", + ) + await client.agents.send_event( + agent_id=agent_id, + params={"task_id": task.id, "content": event_content}, + ) + + +class TestStreamingEvents: + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_id: str): + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + event_content = TextContentParam( + type="text", + author="user", + content="Tell me a short joke.", + ) + await client.agents.send_event( + agent_id=agent_id, + params={"task_id": task.id, "content": event_content}, + ) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/00_base/110_pydantic_ai/.dockerignore b/examples/tutorials/10_async/00_base/110_pydantic_ai/.dockerignore new file mode 100644 index 000000000..c49489471 --- /dev/null +++ b/examples/tutorials/10_async/00_base/110_pydantic_ai/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/00_base/110_pydantic_ai/Dockerfile b/examples/tutorials/10_async/00_base/110_pydantic_ai/Dockerfile new file mode 100644 index 000000000..906d62068 --- /dev/null +++ b/examples/tutorials/10_async/00_base/110_pydantic_ai/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 10_async/00_base/110_pydantic_ai/pyproject.toml /app/110_pydantic_ai/pyproject.toml +COPY 10_async/00_base/110_pydantic_ai/README.md /app/110_pydantic_ai/README.md + +WORKDIR /app/110_pydantic_ai + +# Copy the project code +COPY 10_async/00_base/110_pydantic_ai/project /app/110_pydantic_ai/project + +# Copy the test files +COPY 10_async/00_base/110_pydantic_ai/tests /app/110_pydantic_ai/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies +RUN uv pip install --system .[dev] pytest-asyncio httpx + +# Set environment variables +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=ab110-pydantic-ai + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_async/00_base/110_pydantic_ai/README.md b/examples/tutorials/10_async/00_base/110_pydantic_ai/README.md new file mode 100644 index 000000000..db56979cc --- /dev/null +++ b/examples/tutorials/10_async/00_base/110_pydantic_ai/README.md @@ -0,0 +1,52 @@ +# Async Pydantic AI Agent + +A minimal **async** (Redis-streaming) Pydantic AI agent that drives the +**unified harness surface** (`UnifiedEmitter.auto_send_turn` + `PydanticAITurn`) +directly. + +## Why this agent exists + +This agent calls `emitter.auto_send_turn(...)` **explicitly** at the +agent-author level, making the unified-surface wiring visible and giving the +async channel direct coverage. + +## How it wires the unified surface + +In `project/acp.py`: + +```python +emitter = UnifiedEmitter( + task_id=task_id, + trace_id=task_id, + parent_span_id=turn_span.id if turn_span else None, +) +async with agent.run_stream_events(user_message, message_history=previous_messages) as stream: + turn = PydanticAITurn(tee_messages(stream), model=MODEL_NAME, coalesce_tool_requests=True) + result = await emitter.auto_send_turn(turn) +``` + +- `coalesce_tool_requests=True` is required on the async/auto_send path until + AGX1-377 lands: tool requests are delivered as a single `Full(tool_request)` + rather than streamed `Start + Delta + Done`. +- The `UnifiedEmitter` is constructed from the ACP context (`task_id` + + `trace_id` + `parent_span_id`) so messages auto-send to the task stream + (Redis) and tracing is automatic. +- Multi-turn memory is persisted via `adk.state` (pydantic-ai message history + round-tripped through `ModelMessagesTypeAdapter`). + +## Files + +- `project/acp.py` — async ACP handler using `emitter.auto_send_turn(...)`. +- `project/agent.py` — builds the `pydantic_ai.Agent` with one tool. +- `project/tools.py` — `get_weather(city)` returning a constant. +- `tests/test_agent.py` — live integration test (requires a running agent). + +## Tools + +- `get_weather(city: str) -> str`: returns a fixed "sunny and 72°F" string. + +## Offline coverage + +Offline integration tests for the same wiring (pydantic-ai `TestModel` + fake +streaming/tracing, no network) live in the SDK repo under +`tests/lib/core/harness/` (the pydantic-ai async suite). diff --git a/examples/tutorials/10_async/00_base/110_pydantic_ai/manifest.yaml b/examples/tutorials/10_async/00_base/110_pydantic_ai/manifest.yaml new file mode 100644 index 000000000..4aca13d44 --- /dev/null +++ b/examples/tutorials/10_async/00_base/110_pydantic_ai/manifest.yaml @@ -0,0 +1,58 @@ +build: + context: + root: ../../../ + include_paths: + - 10_async/00_base/110_pydantic_ai + - test_utils + dockerfile: 10_async/00_base/110_pydantic_ai/Dockerfile + dockerignore: 10_async/00_base/110_pydantic_ai/.dockerignore + +local_development: + agent: + port: 8000 + host_address: host.docker.internal + paths: + acp: project/acp.py + +agent: + acp_type: async + name: ab110-pydantic-ai + description: An async Pydantic AI harness test agent using the unified emitter surface + + temporal: + enabled: false + + credentials: + - env_var_name: OPENAI_API_KEY + secret_name: openai-api-key + secret_key: api-key + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + - env_var_name: SGP_ACCOUNT_ID + secret_name: sgp-account-id + secret_key: account-id + - env_var_name: SGP_CLIENT_BASE_URL + secret_name: sgp-client-base-url + secret_key: url + +deployment: + image: + repository: "" + tag: "latest" + + global: + agent: + name: "ab110-pydantic-ai" + description: "An async Pydantic AI harness test agent using the unified emitter surface" + replicaCount: 1 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/10_async/00_base/110_pydantic_ai/project/__init__.py b/examples/tutorials/10_async/00_base/110_pydantic_ai/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/00_base/110_pydantic_ai/project/acp.py b/examples/tutorials/10_async/00_base/110_pydantic_ai/project/acp.py new file mode 100644 index 000000000..95b638f8b --- /dev/null +++ b/examples/tutorials/10_async/00_base/110_pydantic_ai/project/acp.py @@ -0,0 +1,159 @@ +"""ACP handler for the async harness Pydantic AI test agent. + +This agent exercises the UNIFIED HARNESS SURFACE on the async (Redis-streaming) +channel — ``UnifiedEmitter.auto_send_turn(PydanticAITurn(...))`` +— calling it directly rather than via the ``stream_pydantic_ai_events`` helper +(which the ``110_pydantic_ai`` tutorial uses). This makes the unified-surface +wiring explicit at the agent-author level. + +Multi-turn memory is persisted via ``adk.state``: on each turn we load the +previous pydantic-ai ``message_history`` from state, run the agent with it, +then save the updated history back. +""" + +from __future__ import annotations + +import os +from typing import Any, AsyncIterator + +from dotenv import load_dotenv + +load_dotenv() + +from pydantic_ai.run import AgentRunResultEvent +from pydantic_ai.messages import ModelMessagesTypeAdapter + +import agentex.lib.adk as adk +from project.agent import MODEL_NAME, create_agent +from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams +from agentex.lib.core.harness import UnifiedEmitter +from agentex.lib.types.fastacp import AsyncACPConfig +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config + +logger = make_logger(__name__) + +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) +) + +acp = FastACP.create( + acp_type="async", + config=AsyncACPConfig(type="base"), +) + +_agent = None + + +def get_agent(): + global _agent + if _agent is None: + _agent = create_agent() + return _agent + + +class ConversationState(BaseModel): + """Per-task conversation state persisted via ``adk.state``. + + ``history_json`` holds the pydantic-ai message history serialized by + ``ModelMessagesTypeAdapter`` — pydantic-ai's official way to round-trip + ``ModelMessage`` objects through JSON. + """ + + history_json: str = "[]" + turn_number: int = 0 + + +@acp.on_task_create +async def handle_task_create(params: CreateTaskParams): + """Initialize per-task state on task creation.""" + logger.info(f"Task created: {params.task.id}") + await adk.state.create( + task_id=params.task.id, + agent_id=params.agent.id, + state=ConversationState(), + ) + + +@acp.on_task_event_send +async def handle_task_event_send(params: SendEventParams): + """Handle each user message through the unified auto_send_turn path.""" + agent = get_agent() + task_id = params.task.id + agent_id = params.agent.id + user_message = params.event.content.content + + logger.info(f"Processing message for thread {task_id}") + + # Echo the user's message into the task history. + await adk.messages.create(task_id=task_id, content=params.event.content) + + # Load the previous conversation history from state (fall back to fresh). + task_state = await adk.state.get_by_task_and_agent(task_id=task_id, agent_id=agent_id) + if task_state is None: + state = ConversationState() + task_state = await adk.state.create(task_id=task_id, agent_id=agent_id, state=state) + else: + state = ConversationState.model_validate(task_state.state) + + state.turn_number += 1 + previous_messages = ModelMessagesTypeAdapter.validate_json(state.history_json) + + async with adk.tracing.span( + trace_id=task_id, + task_id=task_id, + name=f"Turn {state.turn_number}", + input={"message": user_message}, + data={"__span_type__": "AGENT_WORKFLOW"}, + ) as turn_span: + # Construct the UnifiedEmitter from the ACP context so tracing is + # automatic and messages are auto-sent to the task stream (Redis). + emitter = UnifiedEmitter( + task_id=task_id, + trace_id=task_id, + parent_span_id=turn_span.id if turn_span else None, + ) + + # Capture the terminal AgentRunResultEvent to persist message history. + captured_messages: list[Any] = [] + + async def tee_messages(upstream) -> AsyncIterator[Any]: + async for event in upstream: + if isinstance(event, AgentRunResultEvent): + captured_messages[:] = list(event.result.all_messages()) + yield event + + async with agent.run_stream_events(user_message, message_history=previous_messages) as stream: + # The unified auto_send path delivers streamed tool requests natively + # (Start+Delta+Done), so no coalescing workaround is needed. + turn = PydanticAITurn( + tee_messages(stream), + model=MODEL_NAME, + ) + result = await emitter.auto_send_turn(turn) + + # Save the updated message history so the next turn picks up here. + if captured_messages: + state.history_json = ModelMessagesTypeAdapter.dump_json(captured_messages).decode() + await adk.state.update( + state_id=task_state.id, + task_id=task_id, + agent_id=agent_id, + state=state, + ) + + if turn_span: + turn_span.output = {"final_output": result.final_text} + + +@acp.on_task_cancel +async def handle_task_canceled(params: CancelTaskParams): + logger.info(f"Task canceled: {params.task.id}") diff --git a/examples/tutorials/10_async/00_base/110_pydantic_ai/project/agent.py b/examples/tutorials/10_async/00_base/110_pydantic_ai/project/agent.py new file mode 100644 index 000000000..e7b764d82 --- /dev/null +++ b/examples/tutorials/10_async/00_base/110_pydantic_ai/project/agent.py @@ -0,0 +1,39 @@ +"""Pydantic AI agent definition for the async harness test agent. + +The Agent is the boundary between this module and the API layer (acp.py). +Pydantic AI handles its own tool-call loop internally — no graph required. +""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic_ai import Agent + +from project.tools import get_weather + +__all__ = ["create_agent", "MODEL_NAME"] + +MODEL_NAME = "openai:gpt-4o-mini" +SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. + +Current date and time: {timestamp} + +Guidelines: +- Be concise and helpful +- Use tools when they would help answer the user's question +- If you're unsure, ask clarifying questions +- Always provide accurate information +""" + + +def create_agent() -> Agent: + """Build and return the Pydantic AI agent with tools registered.""" + agent = Agent( + MODEL_NAME, + system_prompt=SYSTEM_PROMPT.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + ) + + agent.tool_plain(get_weather) + + return agent diff --git a/examples/tutorials/10_async/00_base/110_pydantic_ai/project/tools.py b/examples/tutorials/10_async/00_base/110_pydantic_ai/project/tools.py new file mode 100644 index 000000000..0f16a7cb0 --- /dev/null +++ b/examples/tutorials/10_async/00_base/110_pydantic_ai/project/tools.py @@ -0,0 +1,20 @@ +"""Tool definitions for the async harness Pydantic AI agent. + +Pydantic AI tools are registered directly on the Agent via decorators +(see project.agent). This module hosts the bare function so it is easy to +unit-test in isolation. +""" + +from __future__ import annotations + + +def get_weather(city: str) -> str: + """Get the current weather for a city. + + Args: + city: The name of the city to get weather for. + + Returns: + A string describing the weather conditions. + """ + return f"The weather in {city} is sunny and 72°F" diff --git a/examples/tutorials/10_async/00_base/110_pydantic_ai/pyproject.toml b/examples/tutorials/10_async/00_base/110_pydantic_ai/pyproject.toml new file mode 100644 index 000000000..257918014 --- /dev/null +++ b/examples/tutorials/10_async/00_base/110_pydantic_ai/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "ab110-pydantic-ai" +version = "0.1.0" +description = "An async Pydantic AI harness test agent using the unified emitter surface" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "pydantic-ai-slim[openai]>=1.0,<2", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/examples/tutorials/10_async/00_base/110_pydantic_ai/tests/test_agent.py b/examples/tutorials/10_async/00_base/110_pydantic_ai/tests/test_agent.py new file mode 100644 index 000000000..ce573a697 --- /dev/null +++ b/examples/tutorials/10_async/00_base/110_pydantic_ai/tests/test_agent.py @@ -0,0 +1,117 @@ +"""Live tests for the async Pydantic AI agent. + +These tests require a running agent (server + deployed agent) and exercise the +unified-surface async handler end-to-end over the wire. + +Offline coverage of the same wiring (TestModel + fake streaming/tracing) lives +in the SDK repo under ``tests/lib/core/harness/`` (the pydantic-ai async suite). + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: ab110-pydantic-ai) +""" + +import os + +import pytest +import pytest_asyncio + +from agentex import AsyncAgentex +from agentex.types import TextContentParam +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.lib.sdk.fastacp.base.base_acp_server import uuid + +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "ab110-pydantic-ai") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending through the unified auto_send_turn path.""" + + @pytest.mark.asyncio + async def test_send_event(self, client: AsyncAgentex, agent_id: str): + """Test sending an event to the async harness Pydantic AI agent.""" + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + event_content = TextContentParam( + type="text", + author="user", + content="Hello! What can you help me with?", + ) + await client.agents.send_event( + agent_id=agent_id, + params={"task_id": task.id, "content": event_content}, + ) + + @pytest.mark.asyncio + async def test_tool_calling(self, client: AsyncAgentex, agent_id: str): + """Test that the agent can use tools (e.g., weather tool).""" + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + event_content = TextContentParam( + type="text", + author="user", + content="What's the weather in San Francisco?", + ) + await client.agents.send_event( + agent_id=agent_id, + params={"task_id": task.id, "content": event_content}, + ) + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_id: str): + """Test sending an event and streaming the response.""" + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + event_content = TextContentParam( + type="text", + author="user", + content="Tell me a short joke.", + ) + await client.agents.send_event( + agent_id=agent_id, + params={"task_id": task.id, "content": event_content}, + ) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/00_base/120_openai_agents/.dockerignore b/examples/tutorials/10_async/00_base/120_openai_agents/.dockerignore new file mode 100644 index 000000000..c49489471 --- /dev/null +++ b/examples/tutorials/10_async/00_base/120_openai_agents/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/00_base/120_openai_agents/Dockerfile b/examples/tutorials/10_async/00_base/120_openai_agents/Dockerfile new file mode 100644 index 000000000..76fe0fdef --- /dev/null +++ b/examples/tutorials/10_async/00_base/120_openai_agents/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 10_async/00_base/120_openai_agents/pyproject.toml /app/120_openai_agents/pyproject.toml +COPY 10_async/00_base/120_openai_agents/README.md /app/120_openai_agents/README.md + +WORKDIR /app/120_openai_agents + +# Copy the project code +COPY 10_async/00_base/120_openai_agents/project /app/120_openai_agents/project + +# Copy the test files +COPY 10_async/00_base/120_openai_agents/tests /app/120_openai_agents/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies +RUN uv pip install --system .[dev] pytest-asyncio httpx + +# Set environment variables +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=ab120-openai-agents + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_async/00_base/120_openai_agents/README.md b/examples/tutorials/10_async/00_base/120_openai_agents/README.md new file mode 100644 index 000000000..0b55b00a2 --- /dev/null +++ b/examples/tutorials/10_async/00_base/120_openai_agents/README.md @@ -0,0 +1,33 @@ +# Async OpenAI Agents on the unified harness surface + +An async (Redis-streaming) Agentex agent that runs the OpenAI Agents SDK and +delivers its output through the **unified harness surface**. + +## What this demonstrates + +Same `OpenAITurn` adapter as the sync tutorial (`050_openai_agents`), but the +async ACP pushes the turn to the task stream via +`UnifiedEmitter.auto_send_turn` instead of yielding over HTTP. `auto_send_turn` +returns a `TurnResult` with the accumulated final text and normalized usage. + +```python +result = Runner.run_streamed(starting_agent=agent, input=user_message) +turn = OpenAITurn(result=result, model="gpt-4o") +emitter = UnifiedEmitter(task_id=task_id, trace_id=task_id, parent_span_id=parent_span_id) +turn_result = await emitter.auto_send_turn(turn) +``` + +## Run it + +```bash +agentex agents run --manifest manifest.yaml +``` + +## Test it + +The offline test exercises the auto-send delivery path with an injected fake +streaming backend (no server, Redis, or API key required): + +```bash +pytest tests/test_agent.py -v +``` diff --git a/examples/tutorials/10_async/00_base/120_openai_agents/manifest.yaml b/examples/tutorials/10_async/00_base/120_openai_agents/manifest.yaml new file mode 100644 index 000000000..bd8d5cce5 --- /dev/null +++ b/examples/tutorials/10_async/00_base/120_openai_agents/manifest.yaml @@ -0,0 +1,58 @@ +build: + context: + root: ../../../ + include_paths: + - 10_async/00_base/120_openai_agents + - test_utils + dockerfile: 10_async/00_base/120_openai_agents/Dockerfile + dockerignore: 10_async/00_base/120_openai_agents/.dockerignore + +local_development: + agent: + port: 8000 + host_address: host.docker.internal + paths: + acp: project/acp.py + +agent: + acp_type: async + name: ab120-openai-agents + description: An async OpenAI Agents SDK agent on the unified harness surface + + temporal: + enabled: false + + credentials: + - env_var_name: OPENAI_API_KEY + secret_name: openai-api-key + secret_key: api-key + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + - env_var_name: SGP_ACCOUNT_ID + secret_name: sgp-account-id + secret_key: account-id + - env_var_name: SGP_CLIENT_BASE_URL + secret_name: sgp-client-base-url + secret_key: url + +deployment: + image: + repository: "" + tag: "latest" + + global: + agent: + name: "ab120-openai-agents" + description: "An async OpenAI Agents SDK agent on the unified harness surface" + replicaCount: 1 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/10_async/00_base/120_openai_agents/project/__init__.py b/examples/tutorials/10_async/00_base/120_openai_agents/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/00_base/120_openai_agents/project/acp.py b/examples/tutorials/10_async/00_base/120_openai_agents/project/acp.py new file mode 100644 index 000000000..fcd10cc62 --- /dev/null +++ b/examples/tutorials/10_async/00_base/120_openai_agents/project/acp.py @@ -0,0 +1,98 @@ +"""ACP handler for the async OpenAI Agents harness tutorial. + +Uses the async ACP model with Redis streaming instead of HTTP yields. The +OpenAI Agents SDK run is wrapped in an ``OpenAITurn`` and pushed to the task +stream via ``UnifiedEmitter.auto_send_turn`` — the async/temporal delivery path +of the unified harness surface. ``auto_send_turn`` returns a ``TurnResult`` +carrying the accumulated final text and normalized usage. +""" + +from __future__ import annotations + +import os + +from dotenv import load_dotenv + +load_dotenv() + +from agents import Runner + +from agentex.lib import adk +from project.agent import MODEL_NAME, create_agent +from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams +from agentex.lib.types.fastacp import AsyncACPConfig +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.lib.adk.providers._modules.openai_turn import OpenAITurn +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config + +logger = make_logger(__name__) + +_litellm_key = os.environ.get("LITELLM_API_KEY") +if _litellm_key and not os.environ.get("OPENAI_API_KEY"): + os.environ["OPENAI_API_KEY"] = _litellm_key + +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) +) + +acp = FastACP.create( + acp_type="async", + config=AsyncACPConfig(type="base"), +) + +_agent = None + + +def get_agent(): + global _agent + if _agent is None: + _agent = create_agent() + return _agent + + +@acp.on_task_create +async def handle_task_create(params: CreateTaskParams): + logger.info(f"Task created: {params.task.id}") + + +@acp.on_task_event_send +async def handle_task_event_send(params: SendEventParams): + """Handle each user message: run the agent and auto-send its turn.""" + agent = get_agent() + task_id = params.task.id + user_message = params.event.content.content + + logger.info(f"Processing message for task {task_id}") + + # Echo the user's message into the task history. + await adk.messages.create(task_id=task_id, content=params.event.content) + + async with adk.tracing.span( + trace_id=task_id, + task_id=task_id, + name="message", + input={"message": user_message}, + data={"__span_type__": "AGENT_WORKFLOW"}, + ) as turn_span: + result = Runner.run_streamed(starting_agent=agent, input=user_message) + turn = OpenAITurn(result=result, model=MODEL_NAME) + emitter = UnifiedEmitter( + task_id=task_id, + trace_id=task_id, + parent_span_id=turn_span.id if turn_span else None, + ) + turn_result = await emitter.auto_send_turn(turn) + if turn_span: + turn_span.output = {"final_output": turn_result.final_text} + + +@acp.on_task_cancel +async def handle_task_canceled(params: CancelTaskParams): + logger.info(f"Task canceled: {params.task.id}") diff --git a/examples/tutorials/10_async/00_base/120_openai_agents/project/agent.py b/examples/tutorials/10_async/00_base/120_openai_agents/project/agent.py new file mode 100644 index 000000000..5b83c5aab --- /dev/null +++ b/examples/tutorials/10_async/00_base/120_openai_agents/project/agent.py @@ -0,0 +1,43 @@ +"""OpenAI Agents SDK agent definition for the async harness tutorial. + +Identical agent shape to the sync tutorial (060). The only difference is the +delivery path in acp.py: the async ACP uses ``UnifiedEmitter.auto_send_turn`` +(Redis streaming) instead of yielding events over an HTTP response. +""" + +from __future__ import annotations + +from datetime import datetime + +from agents import Agent, function_tool, set_tracing_disabled + +from project.tools import get_weather + +set_tracing_disabled(True) + +MODEL_NAME = "gpt-4o" +INSTRUCTIONS = """You are a helpful AI assistant with access to tools. + +Current date and time: {timestamp} + +Guidelines: +- Be concise and helpful +- Use the weather tool when the user asks about the weather +- Always report the real tool output back to the user +""" + + +@function_tool +def weather(city: str) -> str: + """Get the current weather for a city.""" + return get_weather(city) + + +def create_agent() -> Agent: + """Build and return the OpenAI Agents SDK agent with the weather tool.""" + return Agent( + name="Harness OpenAI Assistant", + model=MODEL_NAME, + instructions=INSTRUCTIONS.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + tools=[weather], + ) diff --git a/examples/tutorials/10_async/00_base/120_openai_agents/project/tools.py b/examples/tutorials/10_async/00_base/120_openai_agents/project/tools.py new file mode 100644 index 000000000..d2e5468c9 --- /dev/null +++ b/examples/tutorials/10_async/00_base/120_openai_agents/project/tools.py @@ -0,0 +1,15 @@ +"""Tool definitions for the async OpenAI Agents harness tutorial.""" + +from __future__ import annotations + + +def get_weather(city: str) -> str: + """Get the current weather for a city. + + Args: + city: The name of the city to get weather for. + + Returns: + A string describing the weather conditions. + """ + return f"The weather in {city} is sunny and 72°F" diff --git a/examples/tutorials/10_async/00_base/120_openai_agents/pyproject.toml b/examples/tutorials/10_async/00_base/120_openai_agents/pyproject.toml new file mode 100644 index 000000000..f48fab49f --- /dev/null +++ b/examples/tutorials/10_async/00_base/120_openai_agents/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "ab120-openai-agents" +version = "0.1.0" +description = "An async OpenAI Agents SDK agent on the unified harness surface" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "openai-agents", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/examples/tutorials/10_async/00_base/120_openai_agents/tests/test_agent.py b/examples/tutorials/10_async/00_base/120_openai_agents/tests/test_agent.py new file mode 100644 index 000000000..ceb95dbab --- /dev/null +++ b/examples/tutorials/10_async/00_base/120_openai_agents/tests/test_agent.py @@ -0,0 +1,77 @@ +"""Offline test for the async OpenAI Agents harness tutorial. + +This test does NOT require a running Agentex server, Redis, or an OpenAI API +key. It verifies the async delivery path this tutorial demonstrates: an +``OpenAITurn`` built from an injected canonical stream, pushed through +``UnifiedEmitter.auto_send_turn`` with an injected fake streaming backend, +returns the accumulated final text. + +To run: ``pytest tests/test_agent.py -v`` +""" + +from __future__ import annotations + +import pytest + +from agentex.types.task_message import TaskMessage +from agentex.types.text_content import TextContent +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.types.task_message_delta import TextDelta +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.lib.adk.providers._modules.openai_turn import OpenAITurn + + +class _FakeCtx: + def __init__(self, initial_content): + self.task_message = TaskMessage(id="m-1", task_id="task-1", content=initial_content) + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + await self.close() + return False + + async def close(self): + pass + + async def stream_update(self, update): + return update + + +class _FakeStreaming: + def streaming_task_message_context(self, task_id, initial_content, **_kwargs): # noqa: ARG002 + return _FakeCtx(initial_content) + + +async def _canonical_stream(events): + for e in events: + yield e + + +@pytest.mark.asyncio +async def test_auto_send_turn_returns_final_text(): + events = [ + StreamTaskMessageStart(type="start", index=0, content=TextContent(type="text", author="agent", content="")), + StreamTaskMessageDelta(type="delta", index=0, delta=TextDelta(type="text", text_delta="Hel")), + StreamTaskMessageDelta(type="delta", index=0, delta=TextDelta(type="text", text_delta="lo")), + StreamTaskMessageDone(type="done", index=0), + ] + turn = OpenAITurn(stream=_canonical_stream(events), model="gpt-4o") + emitter = UnifiedEmitter( + task_id="task-1", + trace_id=None, + parent_span_id=None, + streaming=_FakeStreaming(), + ) + + result = await emitter.auto_send_turn(turn) + assert result.final_text == "Hello" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/00_base/130_claude_code/.dockerignore b/examples/tutorials/10_async/00_base/130_claude_code/.dockerignore new file mode 100644 index 000000000..c49489471 --- /dev/null +++ b/examples/tutorials/10_async/00_base/130_claude_code/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/00_base/130_claude_code/Dockerfile b/examples/tutorials/10_async/00_base/130_claude_code/Dockerfile new file mode 100644 index 000000000..e36b9e56d --- /dev/null +++ b/examples/tutorials/10_async/00_base/130_claude_code/Dockerfile @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +RUN npm install -g @anthropic-ai/claude-code || true + +ENV UV_HTTP_TIMEOUT=1000 + +COPY 10_async/00_base/130_claude_code/pyproject.toml /app/130_claude_code/pyproject.toml +COPY 10_async/00_base/130_claude_code/README.md /app/130_claude_code/README.md + +WORKDIR /app/130_claude_code + +COPY 10_async/00_base/130_claude_code/project /app/130_claude_code/project +COPY 10_async/00_base/130_claude_code/tests /app/130_claude_code/tests +COPY test_utils /app/test_utils + +RUN uv pip install --system .[dev] + +ENV PYTHONPATH=/app + +ENV AGENT_NAME=ab130-claude-code + +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_async/00_base/130_claude_code/README.md b/examples/tutorials/10_async/00_base/130_claude_code/README.md new file mode 100644 index 000000000..695207c57 --- /dev/null +++ b/examples/tutorials/10_async/00_base/130_claude_code/README.md @@ -0,0 +1,76 @@ +# Tutorial 130 (async/base): Async Claude Code Agent + +This tutorial demonstrates how to build an **async (non-Temporal)** agent that +spawns the Claude Code CLI as a local subprocess and delivers its output through +the Agentex unified harness surface via ``ClaudeCodeTurn`` and +``UnifiedEmitter.auto_send_turn``. + +## Key Concepts + +### Async delivery path + +Unlike the sync tutorial (060), this agent uses the async ACP model. The +``@acp.on_task_event_send`` handler does not return a generator -- instead, +``UnifiedEmitter.auto_send_turn(turn)`` pushes events to the task's Redis +stream in real time and returns a ``TurnResult`` when the turn is complete. +The UI polls or streams that Redis channel independently. + +### ClaudeCodeTurn + UnifiedEmitter + +Same tap as the sync tutorial: +- ``ClaudeCodeTurn`` wraps ``convert_claude_code_to_agentex_events``. +- ``UnifiedEmitter`` wires trace context + chosen delivery. +- ``auto_send_turn`` is the async push path. + +### Local subprocess spawn + +``_spawn_claude`` in ``project/acp.py`` uses ``asyncio.create_subprocess_exec`` +to run: + +``` +claude -p --output-format stream-json --verbose +``` + +The prompt is written to stdin. Stdout is read line by line. + +Production isolation (Scale sandbox, secret injection, MCP configuration) +is the golden agent's concern at +``teams/sgp/agents/golden_agent/project/harness/providers/claude.py``. + +### Injectable spawn seam + +``_spawn_claude`` is a top-level async generator. Tests monkeypatch it to +inject pre-recorded stream-json lines so offline unit tests run without the CLI. + +## Files + +| File | Description | +|------|-------------| +| ``project/acp.py`` | ACP server, ``_spawn_claude`` seam, and event handler | +| ``tests/test_agent.py`` | Live integration tests (needs CLI + API key) | +| ``tests/test_agent_offline.py`` | Offline unit tests with injected fake subprocess | +| ``manifest.yaml`` | Agent configuration | + +## Running Locally (live) + +Requires the ``claude`` CLI installed and ``ANTHROPIC_API_KEY`` set: + +```bash +npm install -g @anthropic-ai/claude-code +export ANTHROPIC_API_KEY=sk-ant-... +agentex agents run +``` + +## Running Offline Tests + +No CLI or API key needed: + +```bash +uv run pytest tests/test_agent_offline.py -v +``` + +## Notes + +- Production isolation (sandbox, secrets, MCP) is the golden agent's concern. +- For multi-turn memory, persist the Claude Code session_id from the + ``result`` envelope and pass it to ``claude -r `` on the next turn. diff --git a/examples/tutorials/10_async/00_base/130_claude_code/manifest.yaml b/examples/tutorials/10_async/00_base/130_claude_code/manifest.yaml new file mode 100644 index 000000000..7d74de7c6 --- /dev/null +++ b/examples/tutorials/10_async/00_base/130_claude_code/manifest.yaml @@ -0,0 +1,58 @@ +build: + context: + root: ../../../ + include_paths: + - 10_async/00_base/130_claude_code + - test_utils + dockerfile: 10_async/00_base/130_claude_code/Dockerfile + dockerignore: 10_async/00_base/130_claude_code/.dockerignore + +local_development: + agent: + port: 8000 + host_address: host.docker.internal + paths: + acp: project/acp.py + +agent: + acp_type: async + name: ab130-claude-code + description: An async Claude Code agent streaming the unified harness surface via a local CLI subprocess + + temporal: + enabled: false + + credentials: + - env_var_name: ANTHROPIC_API_KEY + secret_name: anthropic-api-key + secret_key: api-key + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + - env_var_name: SGP_ACCOUNT_ID + secret_name: sgp-account-id + secret_key: account-id + - env_var_name: SGP_CLIENT_BASE_URL + secret_name: sgp-client-base-url + secret_key: url + +deployment: + image: + repository: "" + tag: "latest" + + global: + agent: + name: "ab130-claude-code" + description: "An async Claude Code agent streaming via local CLI subprocess" + replicaCount: 1 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/10_async/00_base/130_claude_code/project/__init__.py b/examples/tutorials/10_async/00_base/130_claude_code/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/00_base/130_claude_code/project/acp.py b/examples/tutorials/10_async/00_base/130_claude_code/project/acp.py new file mode 100644 index 000000000..b6681f6a8 --- /dev/null +++ b/examples/tutorials/10_async/00_base/130_claude_code/project/acp.py @@ -0,0 +1,149 @@ +"""ACP handler for the async Claude Code tutorial. + +Spawns ``claude -p --output-format stream-json --verbose`` as a LOCAL +asyncio subprocess (no Scale sandbox -- that is the golden agent's +production concern). Stdout lines are fed into ``ClaudeCodeTurn``. Events +are delivered via ``UnifiedEmitter.auto_send_turn``, the async Redis push +path. + +Live runs require the ``claude`` CLI to be installed and an +ANTHROPIC_API_KEY (or equivalent credential) in the environment. +For offline testing, see ``tests/test_agent_offline.py``. +""" + +from __future__ import annotations + +import os +import asyncio +from typing import AsyncIterator + +from dotenv import load_dotenv + +load_dotenv() + +import agentex.lib.adk as adk +from agentex.lib.adk import ClaudeCodeTurn +from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams +from agentex.lib.core.harness import UnifiedEmitter +from agentex.lib.types.fastacp import AsyncACPConfig +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config + +logger = make_logger(__name__) + +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) +) + +acp = FastACP.create( + acp_type="async", + config=AsyncACPConfig(type="base"), +) + + +async def _spawn_claude(prompt: str) -> AsyncIterator[str]: + """Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines. + + Injectable seam: tests monkeypatch this with a fake async iterator of + pre-recorded lines so no real CLI invocation is needed offline. + """ + proc = await asyncio.create_subprocess_exec( + "claude", + "-p", + "--output-format", + "stream-json", + "--verbose", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + assert proc.stdout is not None + assert proc.stdin is not None + + proc.stdin.write(prompt.encode()) + proc.stdin.close() + + # Drain stderr concurrently. With --verbose, Claude Code can write enough to + # stderr to fill the OS pipe buffer; if we only read stdout, the CLI blocks + # on its stderr write while we block reading stdout — a deadlock. A + # background task keeps stderr flowing so stdout never stalls. + async def _drain_stderr() -> None: + assert proc.stderr is not None + async for _ in proc.stderr: + pass + + stderr_task = asyncio.create_task(_drain_stderr()) + + try: + buffer = "" + async for chunk in proc.stdout: + buffer += chunk.decode("utf-8", errors="replace") + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if line: + yield line + + if buffer.strip(): + yield buffer.strip() + + await proc.wait() + finally: + # Release the subprocess and stderr drain task even if the consumer + # abandons the generator early (task cancellation / client disconnect): + # cancel the drain task and terminate+reap the process if it is still + # running, so neither is leaked. + stderr_task.cancel() + try: + await stderr_task + except asyncio.CancelledError: + pass + if proc.returncode is None: + try: + proc.terminate() + except ProcessLookupError: + pass + await proc.wait() + + +@acp.on_task_create +async def handle_task_create(params: CreateTaskParams): + logger.info("Task created: %s", params.task.id) + + +@acp.on_task_event_send +async def handle_task_event_send(params: SendEventParams): + """Handle a user message: spawn Claude Code locally and push events to the task stream.""" + task_id = params.task.id + prompt = params.event.content.content + logger.info("Processing message for task %s", task_id) + + await adk.messages.create(task_id=task_id, content=params.event.content) + + async with adk.tracing.span( + trace_id=task_id, + task_id=task_id, + name="message", + input={"message": prompt}, + data={"__span_type__": "AGENT_WORKFLOW"}, + ) as turn_span: + emitter = UnifiedEmitter( + task_id=task_id, + trace_id=task_id, + parent_span_id=turn_span.id if turn_span else None, + ) + turn = ClaudeCodeTurn(_spawn_claude(prompt)) + result = await emitter.auto_send_turn(turn) + if turn_span: + turn_span.output = {"final_text": result.final_text} + + +@acp.on_task_cancel +async def handle_task_canceled(params: CancelTaskParams): + logger.info("Task canceled: %s", params.task.id) diff --git a/examples/tutorials/10_async/00_base/130_claude_code/pyproject.toml b/examples/tutorials/10_async/00_base/130_claude_code/pyproject.toml new file mode 100644 index 000000000..66c3cdaf3 --- /dev/null +++ b/examples/tutorials/10_async/00_base/130_claude_code/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "ab130-claude-code" +version = "0.1.0" +description = "An async Claude Code agent streaming the unified harness surface via a local CLI subprocess" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "python-dotenv>=1.0,<2", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] diff --git a/examples/tutorials/10_async/00_base/130_claude_code/tests/test_agent.py b/examples/tutorials/10_async/00_base/130_claude_code/tests/test_agent.py new file mode 100644 index 000000000..ee254da23 --- /dev/null +++ b/examples/tutorials/10_async/00_base/130_claude_code/tests/test_agent.py @@ -0,0 +1,250 @@ +"""Tests for the async Claude Code tutorial agent. + +LIVE tests (``TestClaudeCodeLive``): + - Require the ``claude`` CLI on PATH and ``ANTHROPIC_API_KEY`` set. + - Run the full agent end-to-end against a live Agentex server. + - Skipped automatically when ``CLAUDE_LIVE_TESTS`` is not set to ``1``. + +OFFLINE unit tests (``TestClaudeCodeOffline``): + - Inject a fake async iterator of pre-recorded stream-json lines. + - Assert the ``ClaudeCodeTurn`` + ``UnifiedEmitter`` pipeline drives + ``auto_send_turn``, populates usage, and satisfies the ``HarnessTurn`` + protocol. + - Always run -- no CLI or API key needed. +""" + +from __future__ import annotations + +import os +import json +from typing import AsyncIterator + +import pytest + +from agentex.types.task_message import TaskMessage + +# --------------------------------------------------------------------------- +# Recorded stream-json fixtures +# --------------------------------------------------------------------------- + +_TEXT_ONLY_LINES: list[str] = [ + json.dumps({"type": "system", "subtype": "init", "session_id": "sess-offline-async-1"}), + json.dumps( + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "Hello from async Claude Code!"}]}, + } + ), + json.dumps( + { + "type": "result", + "usage": {"input_tokens": 12, "output_tokens": 6}, + "cost_usd": 0.0001, + "duration_ms": 300, + "num_turns": 1, + } + ), +] + + +async def _fake_lines(lines: list[str]) -> AsyncIterator[str]: + """Async iterator of pre-recorded stream-json lines (no subprocess).""" + for line in lines: + yield line + + +# --------------------------------------------------------------------------- +# Fake streaming backend +# --------------------------------------------------------------------------- + + +class _FakeCtx: + def __init__(self, sink, content_type, initial_content): + self.sink = sink + self.content_type = content_type + self.task_message = TaskMessage(id="msg-1", task_id="task-offline", content=initial_content) + + async def __aenter__(self): + self.sink.append(("open", self.content_type)) + return self + + async def __aexit__(self, *a): + await self.close() + return False + + async def close(self): + self.sink.append(("close", self.content_type)) + + async def stream_update(self, update): + self.sink.append(("update", update)) + return update + + +class _FakeStreaming: + def __init__(self): + self.sink: list = [] + + def streaming_task_message_context(self, task_id, initial_content, streaming_mode="coalesced", created_at=None): # noqa: ARG002 + ctype = getattr(initial_content, "type", None) + self.sink.append(("ctx", ctype)) + return _FakeCtx(self.sink, ctype, initial_content) + + +# --------------------------------------------------------------------------- +# Offline tests (always run -- no CLI or API key needed) +# --------------------------------------------------------------------------- + + +class TestClaudeCodeOffline: + """Unit tests that run without a real claude CLI or network.""" + + @pytest.mark.asyncio + async def test_auto_send_text_only_opens_and_closes_context(self): + """auto_send_turn opens and closes exactly one streaming context.""" + from agentex.lib.adk import ClaudeCodeTurn + from agentex.lib.core.harness import UnifiedEmitter + + fake_streaming = _FakeStreaming() + turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) + emitter = UnifiedEmitter( + task_id="offline-task", + trace_id=None, + parent_span_id=None, + tracer=False, + streaming=fake_streaming, + ) + result = await emitter.auto_send_turn(turn) + + opened = [s for s in fake_streaming.sink if s[0] == "open"] + closed = [s for s in fake_streaming.sink if s[0] == "close"] + assert len(opened) == 1 + assert len(closed) == 1 + assert opened[0][1] == "text" + + @pytest.mark.asyncio + async def test_auto_send_populates_final_text(self): + """auto_send_turn result carries the agent's reply text.""" + from agentex.lib.adk import ClaudeCodeTurn + from agentex.lib.core.harness import UnifiedEmitter + + fake_streaming = _FakeStreaming() + turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) + emitter = UnifiedEmitter( + task_id="offline-task", + trace_id=None, + parent_span_id=None, + tracer=False, + streaming=fake_streaming, + ) + result = await emitter.auto_send_turn(turn) + assert "Hello from async Claude Code" in result.final_text + + @pytest.mark.asyncio + async def test_usage_populated_after_stream_exhausted(self): + """Usage is populated after the events stream is exhausted.""" + from agentex.lib.adk import ClaudeCodeTurn + from agentex.lib.core.harness import UnifiedEmitter + + fake_streaming = _FakeStreaming() + turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) + emitter = UnifiedEmitter( + task_id="t", + trace_id=None, + parent_span_id=None, + tracer=False, + streaming=fake_streaming, + ) + await emitter.auto_send_turn(turn) + usage = turn.usage() + assert usage.input_tokens == 12 + assert usage.output_tokens == 6 + assert usage.num_llm_calls == 1 + + @pytest.mark.asyncio + async def test_stream_task_message_done_present(self): + """StreamTaskMessageDone must appear via yield_turn on a ClaudeCodeTurn.""" + from agentex.lib.adk import ClaudeCodeTurn + from agentex.lib.core.harness import UnifiedEmitter + from agentex.types.task_message_update import StreamTaskMessageDone + + turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) + emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) + events = [e async for e in emitter.yield_turn(turn)] + assert any(isinstance(e, StreamTaskMessageDone) for e in events), ( + "Expected at least one StreamTaskMessageDone event" + ) + + +# --------------------------------------------------------------------------- +# Live tests (skipped unless CLAUDE_LIVE_TESTS=1) +# --------------------------------------------------------------------------- + +pytestmark_live = pytest.mark.skipif( + not os.environ.get("CLAUDE_LIVE_TESTS"), + reason="Set CLAUDE_LIVE_TESTS=1 and ensure the `claude` CLI + ANTHROPIC_API_KEY are available", +) + +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "ab130-claude-code") + + +@pytestmark_live +class TestClaudeCodeLive: + """Live async tests -- needs the claude CLI + ANTHROPIC_API_KEY.""" + + @pytest.fixture + def client(self): + from agentex import Agentex + + return Agentex(base_url=AGENTEX_API_BASE_URL) + + @pytest.fixture + def agent_name(self): + return AGENT_NAME + + @pytest.fixture + def agent_id(self, client, agent_name): + agents = client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent {agent_name!r} not found.") + + def test_send_simple_message(self, client, agent_id: str): + """Create a task, send a message, and poll until a response appears.""" + import time + import uuid + + from agentex.types import TextContentParam + from agentex.types.agent_rpc_params import ParamsSendEventRequest, ParamsCreateTaskRequest + + task = client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)).result + assert task is not None + task_id = task.id + + client.agents.send_event( + agent_id=agent_id, + params=ParamsSendEventRequest( + task_id=task_id, + content=TextContentParam( + author="user", + content="Reply with exactly three words: hello from claude", + type="text", + ), + ), + ) + + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + msgs = client.messages.list(task_id=task_id) + agent_msgs = [m for m in msgs if getattr(m.content, "author", None) == "agent"] + if agent_msgs: + assert len(agent_msgs) >= 1 + return + time.sleep(2) + + raise AssertionError("No agent response received within 60 s") + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/00_base/130_claude_code/tests/test_agent_offline.py b/examples/tutorials/10_async/00_base/130_claude_code/tests/test_agent_offline.py new file mode 100644 index 000000000..ac48474ee --- /dev/null +++ b/examples/tutorials/10_async/00_base/130_claude_code/tests/test_agent_offline.py @@ -0,0 +1,243 @@ +"""Offline unit tests for the async Claude Code tutorial agent. + +These tests do NOT require the ``claude`` CLI or an ANTHROPIC_API_KEY. +They inject a fake async iterator of pre-recorded stream-json lines in +place of the real subprocess spawn and a fake streaming backend, then +assert that the handler drives ``UnifiedEmitter.auto_send_turn`` correctly. + +The injection seam is the ``_spawn_claude`` function in ``project/acp.py``. +""" + +from __future__ import annotations + +import json +from typing import AsyncIterator + +import pytest + +from agentex.lib.adk import ClaudeCodeTurn +from agentex.lib.core.harness import UnifiedEmitter +from agentex.types.task_message import TaskMessage + +# --------------------------------------------------------------------------- +# Recorded fixtures +# --------------------------------------------------------------------------- + +_TEXT_ONLY_LINES: list[str] = [ + json.dumps({"type": "system", "subtype": "init", "session_id": "sess-1"}), + json.dumps( + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "Hello from async Claude Code!"}]}, + } + ), + json.dumps( + { + "type": "result", + "usage": {"input_tokens": 12, "output_tokens": 6}, + "cost_usd": 0.0001, + "duration_ms": 300, + "num_turns": 1, + } + ), +] + +_TOOL_CALL_LINES: list[str] = [ + json.dumps({"type": "system", "subtype": "init", "session_id": "sess-2"}), + json.dumps( + { + "type": "assistant", + "message": { + "content": [ + { + "type": "tool_use", + "id": "tool_xyz", + "name": "Read", + "input": {"file_path": "/tmp/foo.txt"}, + } + ] + }, + } + ), + json.dumps( + { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "tool_xyz", + "content": "file contents", + "is_error": False, + } + ] + }, + } + ), + json.dumps( + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "Read the file."}]}, + } + ), + json.dumps( + { + "type": "result", + "usage": {"input_tokens": 25, "output_tokens": 10}, + "cost_usd": 0.0003, + "duration_ms": 500, + "num_turns": 1, + } + ), +] + + +# --------------------------------------------------------------------------- +# Fake streaming backend +# --------------------------------------------------------------------------- + + +class _FakeCtx: + def __init__(self, sink, content_type, initial_content): + self.sink = sink + self.content_type = content_type + self.task_message = TaskMessage(id="msg-1", task_id="task-offline", content=initial_content) + + async def __aenter__(self): + self.sink.append(("open", self.content_type)) + return self + + async def __aexit__(self, *a): + await self.close() + return False + + async def close(self): + self.sink.append(("close", self.content_type)) + + async def stream_update(self, update): + self.sink.append(("update", update)) + return update + + +class _FakeStreaming: + def __init__(self): + self.sink: list = [] + + def streaming_task_message_context(self, task_id, initial_content, streaming_mode="coalesced", created_at=None): # noqa: ARG002 + ctype = getattr(initial_content, "type", None) + self.sink.append(("ctx", ctype)) + return _FakeCtx(self.sink, ctype, initial_content) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _fake_lines(lines: list[str]) -> AsyncIterator[str]: + for line in lines: + yield line + + +async def _run_auto_send(lines: list[str]): + """Drive ClaudeCodeTurn through auto_send_turn with a fake streaming backend.""" + fake_streaming = _FakeStreaming() + turn = ClaudeCodeTurn(_fake_lines(lines)) + emitter = UnifiedEmitter( + task_id="offline-task", + trace_id=None, + parent_span_id=None, + tracer=False, + streaming=fake_streaming, + ) + result = await emitter.auto_send_turn(turn) + return result, fake_streaming.sink + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_auto_send_text_only_opens_and_closes_context(): + result, sink = await _run_auto_send(_TEXT_ONLY_LINES) + opened = [s for s in sink if s[0] == "open"] + closed = [s for s in sink if s[0] == "close"] + assert len(opened) == 1 + assert len(closed) == 1 + assert opened[0][1] == "text" + + +@pytest.mark.asyncio +async def test_auto_send_populates_final_text(): + result, _ = await _run_auto_send(_TEXT_ONLY_LINES) + assert "Hello from async Claude Code" in result.final_text + + +@pytest.mark.asyncio +async def test_auto_send_usage_is_populated(): + """Usage is populated after the events stream is exhausted. + + UnifiedEmitter.auto_send_turn evaluates turn.usage() eagerly (before + the events are consumed) so the TurnResult.usage reflects a pre-exhaust + snapshot. Test usage directly from the turn after auto_send_turn completes + instead -- the result envelope is populated by the generator being consumed + inside auto_send. + """ + turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) + fake_streaming = _FakeStreaming() + emitter = UnifiedEmitter( + task_id="t", + trace_id=None, + parent_span_id=None, + tracer=False, + streaming=fake_streaming, + ) + await emitter.auto_send_turn(turn) + # After auto_send_turn, the events generator is exhausted and + # ClaudeCodeTurn._on_result has been called with the result envelope. + usage = turn.usage() + assert usage.input_tokens == 12 + assert usage.output_tokens == 6 + assert usage.num_llm_calls == 1 + + +@pytest.mark.asyncio +async def test_auto_send_tool_call_opens_two_contexts(): + result, sink = await _run_auto_send(_TOOL_CALL_LINES) + opened = [s for s in sink if s[0] == "open"] + content_types = [s[1] for s in opened] + assert "tool_request" in content_types + assert "text" in content_types + + +@pytest.mark.asyncio +async def test_spawn_seam_concept(): + """Demonstrate the injectable spawn seam pattern used in project/acp.py. + + The ``_spawn_claude`` function is a top-level async generator. A drop-in + replacement can be injected (e.g. via monkeypatch) to supply pre-recorded + lines without spawning the real CLI. This test proves the pattern works + end-to-end without importing the full ACP module. + """ + called: list[str] = [] + + async def _fake_spawn(prompt: str) -> AsyncIterator[str]: + called.append(prompt) + for line in _TEXT_ONLY_LINES: + yield line + + fake_streaming = _FakeStreaming() + turn = ClaudeCodeTurn(_fake_spawn("ping")) + emitter = UnifiedEmitter( + task_id="t", + trace_id=None, + parent_span_id=None, + tracer=False, + streaming=fake_streaming, + ) + result = await emitter.auto_send_turn(turn) + + assert called == ["ping"] + assert "Hello from async Claude Code" in result.final_text diff --git a/examples/tutorials/10_async/00_base/140_codex/.dockerignore b/examples/tutorials/10_async/00_base/140_codex/.dockerignore new file mode 100644 index 000000000..c49489471 --- /dev/null +++ b/examples/tutorials/10_async/00_base/140_codex/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/00_base/140_codex/Dockerfile b/examples/tutorials/10_async/00_base/140_codex/Dockerfile new file mode 100644 index 000000000..0dd839d8c --- /dev/null +++ b/examples/tutorials/10_async/00_base/140_codex/Dockerfile @@ -0,0 +1,45 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install the codex CLI: the agent spawns `codex exec --json`, so the binary +# must be present on PATH in the image. +RUN npm install -g @openai/codex + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +COPY 10_async/00_base/140_codex/pyproject.toml /app/140_codex/pyproject.toml +COPY 10_async/00_base/140_codex/README.md /app/140_codex/README.md + +WORKDIR /app/140_codex + +COPY 10_async/00_base/140_codex/project /app/140_codex/project +COPY 10_async/00_base/140_codex/tests /app/140_codex/tests +COPY test_utils /app/test_utils + +RUN uv pip install --system .[dev] + +ENV PYTHONPATH=/app +ENV AGENT_NAME=ab140-codex + +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_async/00_base/140_codex/README.md b/examples/tutorials/10_async/00_base/140_codex/README.md new file mode 100644 index 000000000..a00ddb562 --- /dev/null +++ b/examples/tutorials/10_async/00_base/140_codex/README.md @@ -0,0 +1,40 @@ +# 140_codex (async base) + +Tutorial agent demonstrating the `convert_codex_to_agentex_events` tap, +`CodexTurn`, and `UnifiedEmitter` for an **async** (Redis-streaming, no Temporal) +ACP agent. + +## What this tutorial shows + +- Spawning `codex exec --json` as a **local asyncio subprocess** (no Scale sandbox). +- Wrapping the stdout line stream in a `CodexTurn`. +- Delivering every canonical `StreamTaskMessage*` event to Redis via + `UnifiedEmitter.auto_send_turn`, so the UI receives tokens in real time. +- Persisting the codex thread ID in `adk.state` so subsequent turns resume the + same codex session via `codex exec resume `. + +> **Production isolation note:** A tutorial agent runs the Codex CLI locally. +> Production-grade isolation (Scale sandbox, secret injection, MCP configuration) +> is handled by the golden agent at +> `teams/sgp/agents/golden_agent/project/harness/providers/codex.py`. + +## Live runs + +Live runs require: +1. The `codex` CLI on PATH: `npm install -g @openai/codex` +2. `OPENAI_API_KEY` set in the environment. + +## Running offline unit tests + +```bash +cd /path/to/scale-agentex-python +uv run --all-packages --all-extras pytest examples/tutorials/10_async/00_base/140_codex/tests/test_agent.py -q +``` + +## Running live integration tests + +```bash +export CODEX_LIVE_TESTS=1 +export OPENAI_API_KEY=sk-... +pytest tests/test_agent.py -v +``` diff --git a/examples/tutorials/10_async/00_base/140_codex/conftest.py b/examples/tutorials/10_async/00_base/140_codex/conftest.py new file mode 100644 index 000000000..bdd78994b --- /dev/null +++ b/examples/tutorials/10_async/00_base/140_codex/conftest.py @@ -0,0 +1,12 @@ +"""Add the agent's project root to sys.path so ``import project`` works. + +Also sets minimal environment variables so the FastACP and tracing modules +can be imported without a running agent server. +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) + +os.environ.setdefault("ACP_URL", "http://localhost:8000") diff --git a/examples/tutorials/10_async/00_base/140_codex/manifest.yaml b/examples/tutorials/10_async/00_base/140_codex/manifest.yaml new file mode 100644 index 000000000..be020b141 --- /dev/null +++ b/examples/tutorials/10_async/00_base/140_codex/manifest.yaml @@ -0,0 +1,58 @@ +build: + context: + root: ../../../ + include_paths: + - 10_async/00_base/140_codex + - test_utils + dockerfile: 10_async/00_base/140_codex/Dockerfile + dockerignore: 10_async/00_base/140_codex/.dockerignore + +local_development: + agent: + port: 8000 + host_address: host.docker.internal + paths: + acp: project/acp.py + +agent: + acp_type: async + name: ab140-codex + description: Async (base) tutorial agent driving the unified harness surface via local codex CLI subprocess + + temporal: + enabled: false + + credentials: + - env_var_name: OPENAI_API_KEY + secret_name: openai-api-key + secret_key: api-key + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + - env_var_name: SGP_ACCOUNT_ID + secret_name: sgp-account-id + secret_key: account-id + - env_var_name: SGP_CLIENT_BASE_URL + secret_name: sgp-client-base-url + secret_key: url + +deployment: + image: + repository: "" + tag: "latest" + + global: + agent: + name: "ab140-codex" + description: "Async (base) tutorial agent driving the unified harness surface via local codex CLI subprocess" + replicaCount: 1 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/10_async/00_base/140_codex/project/__init__.py b/examples/tutorials/10_async/00_base/140_codex/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/00_base/140_codex/project/acp.py b/examples/tutorials/10_async/00_base/140_codex/project/acp.py new file mode 100644 index 000000000..0233c49ab --- /dev/null +++ b/examples/tutorials/10_async/00_base/140_codex/project/acp.py @@ -0,0 +1,230 @@ +"""Async (base) ACP handler for the Codex CLI harness tutorial. + +Demonstrates the ``convert_codex_to_agentex_events`` tap + ``CodexTurn`` + +``UnifiedEmitter`` for an async (Redis-streaming) ACP agent without Temporal. + +The handler: +1. Spawns ``codex exec --json`` as a LOCAL asyncio subprocess (no sandbox). + This is correct for tutorials and local development; production isolation + is handled by the golden agent's Scale sandbox at + ``teams/sgp/agents/golden_agent/project/harness/providers/codex.py``. +2. Wraps the stdout line stream in a ``CodexTurn``. +3. Delivers every canonical ``StreamTaskMessage*`` event to Redis via + ``UnifiedEmitter.auto_send_turn``, so the UI receives tokens in real time. +4. Multi-turn memory is persisted via ``adk.state``. + +Live runs require: +- ``codex`` CLI on PATH (``npm install -g @openai/codex``) +- ``OPENAI_API_KEY`` set in the environment +""" + +from __future__ import annotations + +import os +import time +import codecs +import asyncio +from collections.abc import AsyncIterator + +from dotenv import load_dotenv + +load_dotenv() + +import agentex.lib.adk as adk +from agentex.lib.adk import CodexTurn +from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams +from agentex.lib.core.harness import UnifiedEmitter +from agentex.lib.types.fastacp import AsyncACPConfig +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config + +logger = make_logger(__name__) + +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) +) + +acp = FastACP.create( + acp_type="async", + config=AsyncACPConfig(type="base"), +) + +MODEL = os.environ.get("CODEX_MODEL", "o4-mini") + + +class ConversationState(BaseModel): + """Per-task conversation state persisted via ``adk.state``. + + We store the codex session/thread ID so subsequent turns can resume the + same codex session via ``codex exec resume ``. + """ + + codex_thread_id: str | None = None + turn_number: int = 0 + + +async def _spawn_codex( + model: str, + thread_id: str | None = None, +) -> asyncio.subprocess.Process: + """Spawn ``codex exec --json`` locally and return the live process. + + Injection seam: tests replace this function with a fake that returns a + mock process whose stdout yields pre-recorded event lines. + + When ``thread_id`` is provided the subcommand becomes + ``codex exec ... resume -`` so codex continues the prior + conversation thread. + + The caller writes the prompt to stdin after the process starts, then + closes stdin so codex knows input is complete. + """ + base_flags = [ + "--json", + "--skip-git-repo-check", + "--dangerously-bypass-approvals-and-sandbox", + "--model", + model, + ] + + if thread_id: + cmd = ["codex", "exec", *base_flags, "resume", thread_id, "-"] + else: + cmd = ["codex", "exec", *base_flags, "-"] + + return await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + # Discard stderr: codex --json writes events to stdout; its stderr is + # progress/debug noise. Capturing it with PIPE but never reading it + # would deadlock once codex fills the OS pipe buffer (~64 KB). + stderr=asyncio.subprocess.DEVNULL, + env={**os.environ}, + ) + + +async def _process_stdout(process: asyncio.subprocess.Process) -> AsyncIterator[str]: + """Yield newline-delimited JSON lines from the process stdout. + + Uses an incremental UTF-8 decoder so a multibyte character split across two + 4 KB reads is decoded correctly instead of being corrupted at the boundary. + """ + assert process.stdout is not None + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + buffer = "" + while True: + chunk = await process.stdout.read(4096) + if not chunk: + break + buffer += decoder.decode(chunk) + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if line: + yield line + buffer += decoder.decode(b"", final=True) + if buffer.strip(): + yield buffer.strip() + + +@acp.on_task_create +async def handle_task_create(params: CreateTaskParams): + """Initialize per-task state on task creation.""" + logger.info("Task created: %s", params.task.id) + await adk.state.create( + task_id=params.task.id, + agent_id=params.agent.id, + state=ConversationState(), + ) + + +@acp.on_task_event_send +async def handle_task_event_send(params: SendEventParams): + """Handle each user message: spawn codex, stream events, save thread ID.""" + task_id = params.task.id + agent_id = params.agent.id + user_message = params.event.content.content + + logger.info("Processing message for task %s", task_id) + + await adk.messages.create(task_id=task_id, content=params.event.content) + + task_state = await adk.state.get_by_task_and_agent(task_id=task_id, agent_id=agent_id) + if task_state is None: + state = ConversationState() + task_state = await adk.state.create(task_id=task_id, agent_id=agent_id, state=state) + else: + state = ConversationState.model_validate(task_state.state) + + state.turn_number += 1 + + async with adk.tracing.span( + trace_id=task_id, + task_id=task_id, + name=f"Turn {state.turn_number}", + input={"message": user_message}, + data={"__span_type__": "AGENT_WORKFLOW"}, + ) as turn_span: + start_ms = int(time.monotonic() * 1000) + + process = await _spawn_codex(MODEL, thread_id=state.codex_thread_id) + + assert process.stdin is not None + process.stdin.write(user_message.encode("utf-8")) + await process.stdin.drain() + process.stdin.close() + + turn = CodexTurn( + events=_process_stdout(process), + model=MODEL, + ) + + emitter = UnifiedEmitter( + task_id=task_id, + trace_id=task_id, + parent_span_id=turn_span.id if turn_span else None, + ) + + result = await emitter.auto_send_turn(turn) + + await process.wait() + + # Record the real wall-clock duration AFTER streaming completes; setting + # it before the stream ran would capture only subprocess spawn overhead. + turn.duration_ms = int(time.monotonic() * 1000) - start_ms + + # Persist the new thread ID so subsequent turns resume the same session. + usage = turn.usage() + if usage.model: + # usage() is valid now that the stream is exhausted + pass + # Persist the codex session id (public accessor; valid post-stream) so the + # next turn resumes the same session. + if turn.session_id: + state.codex_thread_id = turn.session_id + + await adk.state.update( + state_id=task_state.id, + task_id=task_id, + agent_id=agent_id, + state=state, + ) + + if turn_span: + turn_span.output = { + "final_text": result.final_text, + "model": usage.model, + } + + +@acp.on_task_cancel +async def handle_task_canceled(params: CancelTaskParams): + logger.info("Task canceled: %s", params.task.id) diff --git a/examples/tutorials/10_async/00_base/140_codex/pyproject.toml b/examples/tutorials/10_async/00_base/140_codex/pyproject.toml new file mode 100644 index 000000000..bdf7c462f --- /dev/null +++ b/examples/tutorials/10_async/00_base/140_codex/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "ab140-codex" +version = "0.1.0" +description = "Async (base) tutorial agent driving the unified harness surface via local codex CLI subprocess" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/examples/tutorials/10_async/00_base/140_codex/tests/test_agent.py b/examples/tutorials/10_async/00_base/140_codex/tests/test_agent.py new file mode 100644 index 000000000..68ca5aded --- /dev/null +++ b/examples/tutorials/10_async/00_base/140_codex/tests/test_agent.py @@ -0,0 +1,188 @@ +"""Tests for the async (base) Codex harness tutorial agent. + +LIVE tests (``TestLiveCodexAgent``): + - Require the ``codex`` CLI on PATH and ``OPENAI_API_KEY`` set. + - Skipped automatically when ``CODEX_LIVE_TESTS`` is not set to ``1``. + +OFFLINE unit tests (``TestOfflineCodexHandler``): + - Inject a fake async iterator of pre-recorded codex event lines. + - Assert ``CodexTurn`` + ``UnifiedEmitter.auto_send_turn`` is driven correctly. + - Always run. +""" + +from __future__ import annotations + +import os +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +SAMPLE_EVENTS: list[dict[str, Any]] = [ + {"type": "thread.started", "thread_id": "thread-xyz"}, + {"type": "turn.started"}, + { + "type": "item.started", + "item": {"id": "msg-1", "type": "agent_message", "text": "Hi"}, + }, + { + "type": "item.completed", + "item": {"id": "msg-1", "type": "agent_message", "text": "Hi there!"}, + }, + { + "type": "turn.completed", + "usage": {"input_tokens": 8, "output_tokens": 4, "total_tokens": 12}, + }, +] + + +async def _fake_event_stream(): + """Async iterator of pre-recorded codex event JSON lines (no subprocess).""" + for evt in SAMPLE_EVENTS: + yield json.dumps(evt) + + +class TestOfflineCodexHandler: + """Unit tests that run without a real codex CLI or network.""" + + @pytest.mark.asyncio + async def test_usage_populated_after_stream_exhausted(self): + """CodexTurn.usage() returns non-None tokens after stream is exhausted.""" + from agentex.lib.adk import CodexTurn + + turn = CodexTurn(events=_fake_event_stream(), model="o4-mini") + + collected = [e async for e in turn.events] + + usage = turn.usage() + assert usage.input_tokens == 8 + assert usage.output_tokens == 4 + assert usage.model == "o4-mini" + + @pytest.mark.asyncio + async def test_auto_send_turn_drives_unified_surface(self): + """auto_send_turn returns a TurnResult with the final text.""" + from agentex.lib.adk import CodexTurn + from agentex.lib.core.harness import UnifiedEmitter + from agentex.types.task_message import TaskMessage + from agentex.types.text_content import TextContent + + turn = CodexTurn(events=_fake_event_stream(), model="o4-mini") + + real_task_msg = TaskMessage( + id="msg-fake", + task_id="t", + content=TextContent(type="text", author="agent", content=""), + ) + + fake_streaming = MagicMock() + fake_ctx = AsyncMock() + fake_ctx.__aenter__ = AsyncMock(return_value=fake_ctx) + fake_ctx.__aexit__ = AsyncMock(return_value=False) + fake_ctx.stream_update = AsyncMock(return_value=MagicMock()) + fake_ctx.close = AsyncMock() + fake_ctx.task_message = real_task_msg + fake_streaming.streaming_task_message_context = MagicMock(return_value=fake_ctx) + + emitter = UnifiedEmitter( + task_id="t", + trace_id=None, + parent_span_id=None, + streaming=fake_streaming, + ) + + result = await emitter.auto_send_turn(turn) + assert result is not None + + @pytest.mark.asyncio + async def test_session_id_captured_after_stream(self): + """CodexTurn._result captures the session_id from thread.started.""" + from agentex.lib.adk import CodexTurn + + turn = CodexTurn(events=_fake_event_stream(), model="o4-mini") + _ = [e async for e in turn.events] + + assert turn._result is not None + assert turn._result["session_id"] == "thread-xyz" + + @pytest.mark.asyncio + async def test_yield_turn_is_passthrough(self): + """yield_turn mode also works with CodexTurn (no streaming infra needed).""" + from agentex.lib.adk import CodexTurn + from agentex.lib.core.harness import UnifiedEmitter + + turn = CodexTurn(events=_fake_event_stream(), model="o4-mini") + emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) + + events = [e async for e in emitter.yield_turn(turn)] + assert len(events) > 0 + + +# --------------------------------------------------------------------------- +# Live tests +# --------------------------------------------------------------------------- + +LIVE = os.environ.get("CODEX_LIVE_TESTS", "") == "1" +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "ab140-codex") + + +@pytest.mark.skipif( + not LIVE, + reason="Set CODEX_LIVE_TESTS=1 and ensure codex CLI + OPENAI_API_KEY are available", +) +class TestLiveCodexAgent: + """End-to-end tests that require the real codex CLI and a running Agentex server.""" + + @pytest.fixture + def client(self): + from agentex import Agentex + + return Agentex(base_url=AGENTEX_API_BASE_URL) + + @pytest.fixture + def agent_id(self, client): + for agent in client.agents.list(): + if agent.name == AGENT_NAME: + return agent.id + raise ValueError(f"Agent {AGENT_NAME!r} not found.") + + def test_send_simple_message(self, client, agent_id: str): + """Async agents process events out of band, so create a task, send an + event, and poll the task's messages for the agent's response.""" + import time + import uuid + + from agentex.types import TextContentParam + from agentex.types.agent_rpc_params import ParamsSendEventRequest, ParamsCreateTaskRequest + + task = client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)).result + assert task is not None + + client.agents.send_event( + agent_id=agent_id, + params=ParamsSendEventRequest( + task_id=task.id, + content=TextContentParam( + author="user", + content="What is 3+3? Reply with just the number.", + type="text", + ), + ), + ) + + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + msgs = client.messages.list(task_id=task.id) + agent_msgs = [m for m in msgs if getattr(m.content, "author", None) == "agent"] + if agent_msgs: + assert len(agent_msgs) >= 1 + return + time.sleep(2) + + raise AssertionError("No agent response received within 60 s") diff --git a/examples/tutorials/10_async/10_temporal/000_hello_acp/.dockerignore b/examples/tutorials/10_async/10_temporal/000_hello_acp/.dockerignore new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/000_hello_acp/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/10_temporal/000_hello_acp/Dockerfile b/examples/tutorials/10_async/10_temporal/000_hello_acp/Dockerfile new file mode 100644 index 000000000..e739eb4a8 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/000_hello_acp/Dockerfile @@ -0,0 +1,59 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install tctl (Temporal CLI) +RUN curl -L https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_arm64.tar.gz -o /tmp/tctl.tar.gz && \ + tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ + chmod +x /usr/local/bin/tctl && \ + rm /tmp/tctl.tar.gz + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 10_async/10_temporal/000_hello_acp/pyproject.toml /app/000_hello_acp/pyproject.toml +COPY 10_async/10_temporal/000_hello_acp/README.md /app/000_hello_acp/README.md + +WORKDIR /app/000_hello_acp + +# Copy the project code +COPY 10_async/10_temporal/000_hello_acp/project /app/000_hello_acp/project + +# Copy the test files +COPY 10_async/10_temporal/000_hello_acp/tests /app/000_hello_acp/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies (includes pytest) +RUN uv pip install --system .[dev] pytest-asyncio httpx + +# Set environment variables +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=at000-hello-acp + +# Run the ACP server using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/000_hello_acp/README.md b/examples/tutorials/10_async/10_temporal/000_hello_acp/README.md new file mode 100644 index 000000000..95d8f8527 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/000_hello_acp/README.md @@ -0,0 +1,55 @@ +# [Temporal] Hello ACP + +Temporal workflows make agents durable - they survive restarts and can run indefinitely without consuming resources while idle. Instead of handlers, you define a workflow class with `@workflow.run` and `@workflow.signal` methods. + +## What You'll Learn +- Building durable agents with Temporal workflows +- The workflow and signal pattern +- How workflows survive failures and resume automatically +- When to use Temporal vs base async agents + +## Prerequisites +- Development environment set up (see [main repo README](https://github.com/scaleapi/scale-agentex)) +- Backend services running: `make dev` from repository root (includes Temporal) +- Temporal UI available at http://localhost:8233 +- Understanding of base async agents (see [../../00_base/080_batch_events](../../00_base/080_batch_events/) to understand why Temporal) + +## Quick Start + +```bash +cd examples/tutorials/10_async/10_temporal/000_hello_acp +uv run agentex agents run --manifest manifest.yaml +``` + +**Monitor:** Check Temporal UI at http://localhost:8233 to see your durable workflow running. + +## Key Pattern + +```python +@workflow.defn(name="my-workflow") +class MyWorkflow(BaseWorkflow): + @workflow.run + async def on_task_create(self, params: CreateTaskParams): + # Wait indefinitely for events - workflow stays alive + await workflow.wait_condition(lambda: self._complete) + + @workflow.signal(name=SignalName.RECEIVE_EVENT) + async def on_task_event_send(self, params: SendEventParams): + # Handle events as signals to the workflow +``` + +## When to Use +- Production agents that need guaranteed execution +- Long-running tasks (hours, days, weeks, or longer) +- Operations that must survive system failures +- Agents with concurrent event handling requirements +- When you need durability and observability + +## Why This Matters +**Without Temporal:** If your worker crashes, the agent loses all state and has to start over. + +**With Temporal:** The workflow resumes exactly where it left off. If it crashes mid-conversation, Temporal brings it back up with full context intact. Can run for years if needed, only consuming resources when actively processing. + +This is the foundation for production-ready agents that handle real-world reliability requirements. + +**Next:** [010_agent_chat](../010_agent_chat/) - Build a complete conversational agent with tools diff --git a/examples/tutorials/10_async/10_temporal/000_hello_acp/dev.ipynb b/examples/tutorials/10_async/10_temporal/000_hello_acp/dev.ipynb new file mode 100644 index 000000000..f8a66a0ff --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/000_hello_acp/dev.ipynb @@ -0,0 +1,126 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "0", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"at000-hello-acp\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "# (REQUIRED) Create a new task. For Agentic agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/tutorials/10_async/10_temporal/000_hello_acp/manifest.yaml b/examples/tutorials/10_async/10_temporal/000_hello_acp/manifest.yaml new file mode 100644 index 000000000..e93fe8eca --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/000_hello_acp/manifest.yaml @@ -0,0 +1,139 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../../../ # Up to tutorials level to include test_utils + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - 10_async/10_temporal/000_hello_acp + - test_utils + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: 10_async/10_temporal/000_hello_acp/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: 10_async/10_temporal/000_hello_acp/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + # Path to temporal worker file + # Examples: + # project/run_worker.py (standard) + # workers/temporal.py (custom structure) + # ../shared/worker.py (shared across projects) + worker: project/run_worker.py + + +# Agent Configuration +# ----------------- +agent: + # Type of agent - either sync or async + acp_type: async + + # Unique name for your agent + # Used for task routing and monitoring + name: at000-hello-acp + + # Description of what your agent does + # Helps with documentation and discovery + description: An AgentEx agent that shows how ACP works with Temporal + + # Temporal workflow configuration + # This enables your agent to run as a Temporal workflow for long-running tasks + temporal: + enabled: true + workflows: + # Name of the workflow class + # Must match the @workflow.defn name in your workflow.py + - name: at000-hello-acp + + # Queue name for task distribution + # Used by Temporal to route tasks to your agent + # Convention: _task_queue + queue_name: 000_hello_acp_queue + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + # credentials: + # - env_var_name: OPENAI_API_KEY + # secret_name: openai-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + # env: + # - name: OPENAI_BASE_URL + # value: "https://api.openai.com/v1" + # - name: ACCOUNT_ID + # value: "your_account_id_here" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: + - name: my-registry-secret # Update with your image pull secret name + + # Global deployment settings that apply to all clusters + # These can be overridden using --override-file with custom configuration files + global: + agent: + name: "at000-hello-acp" + description: "An AgentEx agent that shows how ACP works with Temporal" + + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/000_hello_acp/project/__init__.py b/examples/tutorials/10_async/10_temporal/000_hello_acp/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/10_temporal/000_hello_acp/project/acp.py b/examples/tutorials/10_async/10_temporal/000_hello_acp/project/acp.py new file mode 100644 index 000000000..744068d77 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/000_hello_acp/project/acp.py @@ -0,0 +1,30 @@ +import os + +from agentex.lib.types.fastacp import TemporalACPConfig +from agentex.lib.sdk.fastacp.fastacp import FastACP + +# Create the ACP server +acp = FastACP.create( + acp_type="async", + config=TemporalACPConfig( + # When deployed to the cluster, the Temporal address will automatically be set to the cluster address + # For local development, we set the address manually to talk to the local Temporal service set up via docker compose + type="temporal", + temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233") + ) +) + + +# Notice that we don't need to register any handlers when we use type="temporal" +# If you look at the code in agentex.sdk.fastacp.impl.temporal_acp +# You can see that these handlers are automatically registered when the ACP is created + +# @acp.on_task_create +# This will be handled by the method in your workflow that is decorated with @workflow.run + +# @acp.on_task_event_send +# This will be handled by the method in your workflow that is decorated with @workflow.signal(name=SignalName.RECEIVE_MESSAGE) + +# @acp.on_task_cancel +# This does not need to be handled by your workflow. +# It is automatically handled by the temporal client which cancels the workflow directly \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/000_hello_acp/project/run_worker.py b/examples/tutorials/10_async/10_temporal/000_hello_acp/project/run_worker.py new file mode 100644 index 000000000..7db2fcdc8 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/000_hello_acp/project/run_worker.py @@ -0,0 +1,34 @@ +import asyncio + +from project.workflow import At000HelloAcpWorkflow +from agentex.lib.utils.debug import setup_debug_if_enabled +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.activities import get_all_activities +from agentex.lib.core.temporal.workers.worker import AgentexWorker + +environment_variables = EnvironmentVariables.refresh() + +logger = make_logger(__name__) + + +async def main(): + # Setup debug mode if enabled + setup_debug_if_enabled() + + task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE + if task_queue_name is None: + raise ValueError("WORKFLOW_TASK_QUEUE is not set") + + # Create a worker with automatic tracing + worker = AgentexWorker( + task_queue=task_queue_name, + ) + + await worker.run( + activities=get_all_activities(), + workflow=At000HelloAcpWorkflow, + ) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/000_hello_acp/project/workflow.py b/examples/tutorials/10_async/10_temporal/000_hello_acp/project/workflow.py new file mode 100644 index 000000000..e45ecd897 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/000_hello_acp/project/workflow.py @@ -0,0 +1,79 @@ +import json +from typing import override + +from temporalio import workflow + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CreateTaskParams +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 +from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow + +environment_variables = EnvironmentVariables.refresh() + +if environment_variables.WORKFLOW_NAME is None: + raise ValueError("Environment variable WORKFLOW_NAME is not set") + +if environment_variables.AGENT_NAME is None: + raise ValueError("Environment variable AGENT_NAME is not set") + +logger = make_logger(__name__) + +@workflow.defn(name=environment_variables.WORKFLOW_NAME) +class At000HelloAcpWorkflow(BaseWorkflow): + """ + Minimal async workflow template for AgentEx Temporal agents. + """ + def __init__(self): + super().__init__(display_name=environment_variables.AGENT_NAME) + self._complete_task = False + + @workflow.signal(name=SignalName.RECEIVE_EVENT) + @override + async def on_task_event_send(self, params: SendEventParams) -> None: + logger.info(f"Received task message instruction: {params}") + + # 2. Echo back the client's message to show it in the UI. This is not done by default so the agent developer has full control over what is shown to the user. + await adk.messages.create(task_id=params.task.id, content=params.event.content) + + # 3. Send a simple response message. + # In future tutorials, this is where we'll add more sophisticated response logic. + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"Hello! I've received your message. I can't respond right now, but in future tutorials we'll see how you can get me to intelligently respond to your message.", + ), + ) + + @workflow.run + @override + async def on_task_create(self, params: CreateTaskParams) -> None: + logger.info(f"Received task create params: {params}") + + # 1. Acknowledge that the task has been created. Gate this one-time prologue + # on is_continued_run(): run_until_complete below recycles the workflow via + # continue-as-new, which re-enters on_task_create from the top — without this + # guard the "you should only see this once" welcome would re-fire on every + # recycle. Original run -> emit; continued (recycled) run -> skip. + if not self.is_continued_run(): + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"Hello! I've received your task. Normally you can do some state initialization here, or just pass and do nothing until you get your first event. For now I'm just acknowledging that I've received a task with the following params:\n\n{json.dumps(params.params, indent=2)}.\n\nYou should only see this message once, when the task is created. All subsequent events will be handled by the `on_task_event_send` handler.", + ), + ) + + # 2. Keep the workflow open to field events. We use run_until_complete + # instead of a bare wait_condition: it still waits indefinitely, but also + # recycles the Temporal event history via continue-as-new before it hits the + # ~50k-event / 50MB limit, so this chat can stay open forever. Adopting + # run_until_complete IS the opt-in — agents that keep the old wait_condition + # never recycle. This agent keeps no cross-turn state, so nothing needs + # restoring across a recycle and `params` is the only carry-forward. (Agents + # that DO keep state restore it at the top of @workflow.run on a recycled + # run — framework-specific, landing per-integration in follow-up PRs.) + await self.run_until_complete(params, is_complete=lambda: self._complete_task) diff --git a/examples/tutorials/10_async/10_temporal/000_hello_acp/pyproject.toml b/examples/tutorials/10_async/10_temporal/000_hello_acp/pyproject.toml new file mode 100644 index 000000000..ace358668 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/000_hello_acp/pyproject.toml @@ -0,0 +1,34 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "at000-hello-acp" +version = "0.1.0" +description = "An AgentEx agent that shows how ACP works with Temporal" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", + "debugpy>=1.8.15", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/000_hello_acp/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/000_hello_acp/tests/test_agent.py new file mode 100644 index 000000000..65204ac36 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/000_hello_acp/tests/test_agent.py @@ -0,0 +1,189 @@ +""" +Sample tests for AgentEx ACP agent (Temporal version). + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: at000-hello-acp) +""" + +import os +import uuid +import asyncio +from typing import Any + +import pytest +import pytest_asyncio +from test_utils.async_utils import ( + poll_messages, + stream_agent_response, + send_event_and_poll_yielding, +) + +from agentex import AsyncAgentex +from agentex.types import TaskMessage +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "at000-hello-acp") + + +@pytest_asyncio.fixture +async def client(): + """Create an AgentEx client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client: AsyncAgentex, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_id: str): + """Test sending an event and polling for the response.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + task_creation_found = False + async for message in poll_messages( + client=client, + task_id=task.id, + timeout=30, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + if message.content and message.content.type == "text" and message.content.author == "agent": + assert "Hello! I've received your task" in message.content.content + task_creation_found = True + break + + assert task_creation_found, "Task creation message not found" + await asyncio.sleep(1.5) + + # Send an event and poll for response + user_message = "Hello, this is a test message!" + agent_response_found = False + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message, + timeout=30, + sleep_interval=1.0, + ): + if message.content and message.content.type == "text" and message.content.author == "agent": + assert "Hello! I've received your message" in message.content.content + agent_response_found = True + break + + assert agent_response_found, "Agent response not found" + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_id: str): + """Test sending an event and streaming the response.""" + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + task_creation_found = False + async for message in poll_messages( + client=client, + task_id=task.id, + timeout=30, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + if message.content and message.content.type == "text" and message.content.author == "agent": + assert "Hello! I've received your task" in message.content.content + task_creation_found = True + break + + assert task_creation_found, "Task creation message not found" + + user_message = "Hello, this is a test message!" + + # Collect events from stream + all_events: list[dict[str, Any]] = [] + + # Flags to track what we've received + user_echo_found = False + agent_response_found = False + stream_timeout = 30 + + async def stream_messages() -> None: + nonlocal user_echo_found, agent_response_found + async for event in stream_agent_response( + client=client, + task_id=task.id, + timeout=stream_timeout, + ): + # Check events as they arrive + event_type = event.get("type") + if event_type == "full": + content = event.get("content", {}) + if content.get("content") is None: + continue # Skip empty content + if content.get("type") == "text" and content.get("author") == "agent": + # Check for agent response to user message + if "Hello! I've received your message" in content.get("content", ""): + # Agent response should come after user echo + assert user_echo_found, "Agent response arrived before user message echo (incorrect order)" + agent_response_found = True + elif content.get("type") == "text" and content.get("author") == "user": + # Check for user message echo + if content.get("content") == user_message: + user_echo_found = True + elif event_type == "done": + break + + # Exit early if we've found all expected messages + if user_echo_found and agent_response_found: + break + + stream_task = asyncio.create_task(stream_messages()) + + # Send the event + event_content = TextContentParam(type="text", author="user", content=user_message) + await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + await stream_task + + # Verify all expected messages were received (fail if stream ended without finding them) + + assert user_echo_found, "User message echo not found in stream" + assert agent_response_found, "Agent response not found in stream" + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/010_agent_chat/.dockerignore b/examples/tutorials/10_async/10_temporal/010_agent_chat/.dockerignore new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/010_agent_chat/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/10_temporal/010_agent_chat/Dockerfile b/examples/tutorials/10_async/10_temporal/010_agent_chat/Dockerfile new file mode 100644 index 000000000..5ecf911b0 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/010_agent_chat/Dockerfile @@ -0,0 +1,59 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install tctl (Temporal CLI) +RUN curl -L https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_arm64.tar.gz -o /tmp/tctl.tar.gz && \ + tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ + chmod +x /usr/local/bin/tctl && \ + rm /tmp/tctl.tar.gz + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 10_async/10_temporal/010_agent_chat/pyproject.toml /app/010_agent_chat/pyproject.toml +COPY 10_async/10_temporal/010_agent_chat/README.md /app/010_agent_chat/README.md + +WORKDIR /app/010_agent_chat + +# Copy the project code +COPY 10_async/10_temporal/010_agent_chat/project /app/010_agent_chat/project + +# Copy the test files +COPY 10_async/10_temporal/010_agent_chat/tests /app/010_agent_chat/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies (includes pytest) +RUN uv pip install --system .[dev] pytest-asyncio httpx + +# Set environment variables +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=at010-agent-chat + +# Run the ACP server using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/010_agent_chat/README.md b/examples/tutorials/10_async/10_temporal/010_agent_chat/README.md new file mode 100644 index 000000000..37c31f135 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/010_agent_chat/README.md @@ -0,0 +1,47 @@ +# [Temporal] Agent Chat + +Combine streaming responses, multi-turn chat, tool calling, and tracing - all with Temporal's durability guarantees. This shows how to build a complete conversational agent that can survive failures. + +## What You'll Learn +- Building a complete conversational agent with Temporal +- Combining streaming, multiturn, tools, and tracing +- How all agent capabilities work together with durability +- Production-ready conversational patterns + +## Prerequisites +- Development environment set up (see [main repo README](https://github.com/scaleapi/scale-agentex)) +- Backend services running: `make dev` from repository root +- Temporal UI available at http://localhost:8233 +- Understanding of Temporal basics (see [000_hello_acp](../000_hello_acp/)) + +## Quick Start + +```bash +cd examples/tutorials/10_async/10_temporal/010_agent_chat +uv run agentex agents run --manifest manifest.yaml +``` + +## Key Pattern + +- **Streaming**: Progressive response generation with `adk.messages.create()` +- **Multi-turn**: Conversation history maintained in durable workflow state +- **Tools**: Agent can call functions to perform actions +- **Tracing**: Full observability of tool calls and LLM interactions +- **Durability**: All of the above survives worker restarts + +**Monitor:** Open Temporal UI at http://localhost:8233 to see the workflow and all tool call activities. + +## Key Insight + +In base async agents, all this state lives in memory and is lost on crash. With Temporal, the entire conversation - history, tool calls, intermediate state - is durably persisted. The agent can pick up a conversation that paused days ago as if no time passed. + +## When to Use +- Production chatbots with tool capabilities +- Long-running customer service conversations +- Agents that need both reliability and rich features +- Any conversational agent handling real user traffic + +## Why This Matters +This is the pattern for real production agents. By combining all capabilities (streaming, tools, tracing) with Temporal's durability, you get an agent that's both feature-rich and reliable. This is what enterprise conversational AI looks like. + +**Next:** [020_state_machine](../020_state_machine/) - Add complex multi-phase workflows diff --git a/examples/tutorials/10_async/10_temporal/010_agent_chat/dev.ipynb b/examples/tutorials/10_async/10_temporal/010_agent_chat/dev.ipynb new file mode 100644 index 000000000..3cb9b822e --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/010_agent_chat/dev.ipynb @@ -0,0 +1,1562 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "0", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"at010-agent-chat\"" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "2", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Task(id='e5333f10-5fe2-4862-8c89-7b422e27a471', created_at=datetime.datetime(2025, 10, 2, 0, 17, 9, 695914, tzinfo=TzInfo(UTC)), name='ffba53be-task', params={}, status='RUNNING', status_reason='Task created, forwarding to ACP server', task_metadata=None, updated_at=datetime.datetime(2025, 10, 2, 0, 17, 9, 695914, tzinfo=TzInfo(UTC)))\n" + ] + } + ], + "source": [ + "# (REQUIRED) Create a new task. For Agentic agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "3", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Event(id='76ab0b9a-b107-4199-a5f6-31315dc43ee2', agent_id='2f4d3b3d-6a59-46ff-993e-afd6c1f1c7ab', sequence_id=131, task_id='e5333f10-5fe2-4862-8c89-7b422e27a471', content=TextContent(author='user', content='Tell me about recent AI news for today only.', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 10, 2, 0, 17, 9, 776113, tzinfo=TzInfo(UTC)))\n" + ] + } + ], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Tell me about recent AI news for today only.\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "4", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
╭───────────────────────── USER [10/02/2025 00:17:09] ─────────────────────────╮\n",
+       "│ Tell me about recent AI news for today only.                                 │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[96m╭─\u001b[0m\u001b[96m────────────────────────\u001b[0m\u001b[96m \u001b[0m\u001b[1;96mUSER\u001b[0m\u001b[96m [10/02/2025 00:17:09] \u001b[0m\u001b[96m────────────────────────\u001b[0m\u001b[96m─╮\u001b[0m\n", + "\u001b[96m│\u001b[0m Tell me about recent AI news for today only. \u001b[96m│\u001b[0m\n", + "\u001b[96m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \n" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [10/02/2025 00:17:13] ─────────────────────────╮\n",
+       "│ 🧠 Reasoning                                                                 │\n",
+       "│                                                                              │\n",
+       "│ Searching for AI news                                                        │\n",
+       "│                                                                              │\n",
+       "│ The user wants an update on recent AI news specifically for today, October   │\n",
+       "│ 2, 2025. I’ll use the web search tool to find this information quickly,      │\n",
+       "│ aiming for outlets that are major or well-known. I plan to use a query like  │\n",
+       "│ \"AI news October 2 2025\" or \"AI news today October 2 2025\". It’ll help to    │\n",
+       "│ set the search context size high to ensure thorough coverage on this topic.  │\n",
+       "│ Let's go ahead and get this done!                                            │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[95m╭─\u001b[0m\u001b[95m───────────────────────\u001b[0m\u001b[95m \u001b[0m\u001b[1;95mAGENT\u001b[0m\u001b[95m [10/02/2025 00:17:13] \u001b[0m\u001b[95m────────────────────────\u001b[0m\u001b[95m─╮\u001b[0m\n", + "\u001b[95m│\u001b[0m 🧠 \u001b[1mReasoning\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[1mSearching for AI news\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m The user wants an update on recent AI news specifically for today, October \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m 2, 2025. I’ll use the web search tool to find this information quickly, \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m aiming for outlets that are major or well-known. I plan to use a query like \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \"AI news October 2 2025\" or \"AI news today October 2 2025\". It’ll help to \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m set the search context size high to ensure thorough coverage on this topic. \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m Let's go ahead and get this done! \u001b[95m│\u001b[0m\n", + "\u001b[95m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \n" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [10/02/2025 00:17:16] ─────────────────────────╮\n",
+       "│ 🔧 Tool Request: openai_web_search                                           │\n",
+       "│                                                                              │\n",
+       "│ Arguments:                                                                   │\n",
+       "│                                                                              │\n",
+       "│                                                                              │\n",
+       "│  {                                                                           │\n",
+       "│    \"arguments\": \"{\\\"input\\\":\\\"AI news October 2 2025 or 'today' Oct 2 2025   │\n",
+       "│  major AI                                                                    │\n",
+       "│  announcements\\\",\\\"model\\\":\\\"gpt-5-mini\\\",\\\"reasoning_effort\\\":\\\"low\\\",\\\"ty  │\n",
+       "│  \\\":\\\"web_search_preview\\\",\\\"search_context_size\\\":\\\"high\\\"}\",               │\n",
+       "│    \"call_id\": \"call_tKw9WT0BYQyCqEcgJ2rNjoy3\",                               │\n",
+       "│    \"name\": \"openai_web_search\",                                              │\n",
+       "│    \"type\": \"function_call\",                                                  │\n",
+       "│    \"id\": \"fc_021a54d0dc6d53340068ddc48d04c481a393f03e37e300827e\",            │\n",
+       "│    \"status\": \"completed\"                                                     │\n",
+       "│  }                                                                           │\n",
+       "│                                                                              │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[33m╭─\u001b[0m\u001b[33m───────────────────────\u001b[0m\u001b[33m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[33m [10/02/2025 00:17:16] \u001b[0m\u001b[33m────────────────────────\u001b[0m\u001b[33m─╮\u001b[0m\n", + "\u001b[33m│\u001b[0m 🔧 \u001b[1mTool Request: openai_web_search\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[1mArguments:\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m{\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"arguments\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"{\\\"input\\\":\\\"AI news October 2 2025 or 'today' Oct 2 2025 \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mmajor AI \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mannouncements\\\",\\\"model\\\":\\\"gpt-5-mini\\\",\\\"reasoning_effort\\\":\\\"low\\\",\\\"ty\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\\\":\\\"web_search_preview\\\",\\\"search_context_size\\\":\\\"high\\\"}\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"call_id\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"call_tKw9WT0BYQyCqEcgJ2rNjoy3\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"name\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"openai_web_search\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"type\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"function_call\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"id\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"fc_021a54d0dc6d53340068ddc48d04c481a393f03e37e300827e\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"status\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"completed\"\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m}\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \n" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [10/02/2025 00:17:34] ─────────────────────────╮\n",
+       "│ ✅ Tool Response: openai_web_search                                          │\n",
+       "│                                                                              │\n",
+       "│ Response:                                                                    │\n",
+       "│                                                                              │\n",
+       "│                                                                              │\n",
+       "│  {                                                                           │\n",
+       "│    \"type\": \"text\",                                                           │\n",
+       "│    \"text\": \"Here are the major AI-related announcements and headlines aroun  │\n",
+       "│  today (October 2, 2025). I focused on the biggest product, infrastructure,  │\n",
+       "│  and partnership news; sources are listed after each item.\\n\\n- Microsoft    │\n",
+       "│  launches Microsoft 365 Premium (Copilot-integrated) for individuals \\u2014  │\n",
+       "│  new $19.99/month plan that bundles Copilot across Outlook/Word/Excel, rais  │\n",
+       "│  Copilot usage limits for some existing Personal/Family customers, and       │\n",
+       "│  replaces Copilot Pro. (Reported Oct 1\\u20132, 2025).                        │\n",
+       "│  ([reuters.com](https://www.reuters.com/technology/microsoft-launches-ai-po  │\n",
+       "│  red-365-premium-bundle-1999-per-month-2025-10-01/?utm_source=openai))\\n\\n-  │\n",
+       "│  OpenAI announces expanded Stargate partnerships in South Korea with Samsun  │\n",
+       "│  and SK (memory and data\\u2011center collaboration) as part of its large     │\n",
+       "│  \\u201cStargate\\u201d infrastructure push and related global datacenter      │\n",
+       "│  plans. (Reported Oct 2, 2025).                                              │\n",
+       "│  ([apnews.com](https://apnews.com/article/a65fd1a21a8587c991cc30b94b1dfe89?  │\n",
+       "│  m_source=openai))\\n\\n- Google launches new Nest cameras and a redesigned    │\n",
+       "│  Google Home app built for Gemini for Home, introducing Gemini-integrated    │\n",
+       "│  home features (descriptive alerts, \\u201cHome Brief,\\u201d Ask Home chatbo  │\n",
+       "│  and new subscription tiers. (Reported Oct 1\\u20132, 2025).                  │\n",
+       "│  ([theverge.com](https://www.theverge.com/news/789412/new-nest-cams-nest-do  │\n",
+       "│  bell-launch-price-specs-release-date?utm_source=openai))\\n\\n- NVIDIA        │\n",
+       "│  continues major infrastructure/product rollouts: recent announcements arou  │\n",
+       "│  Rubin CPX (GPU class for massive-context inference), Dynamo                 │\n",
+       "│  inference-serving software, Blackwell Ultra/AI Factory platform, and        │\n",
+       "│  availability plans for AI foundation models on RTX AI PCs \\u2014 signaling  │\n",
+       "│  heavy pushes on inference scale, large-context models, and on\\u2011device   │\n",
+       "│  for creators/enterprises. (Company releases Sept\\u2013Oct 2025).            │\n",
+       "│  ([investor.nvidia.com](https://investor.nvidia.com/news/press-release-deta  │\n",
+       "│  s/2025/NVIDIA-Unveils-Rubin-CPX-A-New-Class-of-GPU-Designed-for-Massive-Co  │\n",
+       "│  ext-Inference/default.aspx?utm_source=openai))\\n\\n- Industry events /       │\n",
+       "│  company summits with product demos and roadmaps: OpenAI DevDay scheduled O  │\n",
+       "│  6, 2025 (preview teasers and DevDay announcements expected), and Anthropic  │\n",
+       "│  held a London Builder Summit Oct 1 with demos of Claude and discussions of  │\n",
+       "│  autonomous-agent work. These events are driving near-term product and       │\n",
+       "│  developer announcements.                                                    │\n",
+       "│  ([openai.com](https://openai.com/index/announcing-devday-2025/?utm_source=  │\n",
+       "│  enai))\\n\\nIf you want, I can:\\n- Pull full articles and timelines for any   │\n",
+       "│  the items above (e.g., full Reuters/AP/Verge/NVIDIA coverage).\\n- Summariz  │\n",
+       "│  the expected user impact, pricing, or migration steps (e.g., what Microsof  │\n",
+       "│  365 Premium means if you\\u2019re a Copilot Pro or Personal subscriber).\\n-  │\n",
+       "│  Track other outlets for any breaking follow-ups today (Oct 2,               │\n",
+       "│  2025).\\n\\nWhich of these would you like more detail on?\",                   │\n",
+       "│    \"annotations\": null,                                                      │\n",
+       "│    \"meta\": null                                                              │\n",
+       "│  }                                                                           │\n",
+       "│                                                                              │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[92m╭─\u001b[0m\u001b[92m───────────────────────\u001b[0m\u001b[92m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[92m [10/02/2025 00:17:34] \u001b[0m\u001b[92m────────────────────────\u001b[0m\u001b[92m─╮\u001b[0m\n", + "\u001b[92m│\u001b[0m ✅ \u001b[1mTool Response: openai_web_search\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[1mResponse:\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m{\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"type\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"text\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"text\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"Here are the major AI-related announcements and headlines aroun\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mtoday (October 2, 2025). I focused on the biggest product, infrastructure,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mand partnership news; sources are listed after each item.\\n\\n- Microsoft \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mlaunches Microsoft 365 Premium (Copilot-integrated) for individuals \\u2014\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mnew $19.99/month plan that bundles Copilot across Outlook/Word/Excel, rais\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mCopilot usage limits for some existing Personal/Family customers, and \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mreplaces Copilot Pro. (Reported Oct 1\\u20132, 2025). \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([reuters.com](https://www.reuters.com/technology/microsoft-launches-ai-po\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mred-365-premium-bundle-1999-per-month-2025-10-01/?utm_source=openai))\\n\\n-\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mOpenAI announces expanded Stargate partnerships in South Korea with Samsun\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mand SK (memory and data\\u2011center collaboration) as part of its large \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\\u201cStargate\\u201d infrastructure push and related global datacenter \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mplans. (Reported Oct 2, 2025). \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([apnews.com](https://apnews.com/article/a65fd1a21a8587c991cc30b94b1dfe89?\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mm_source=openai))\\n\\n- Google launches new Nest cameras and a redesigned \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mGoogle Home app built for Gemini for Home, introducing Gemini-integrated \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mhome features (descriptive alerts, \\u201cHome Brief,\\u201d Ask Home chatbo\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mand new subscription tiers. (Reported Oct 1\\u20132, 2025). \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([theverge.com](https://www.theverge.com/news/789412/new-nest-cams-nest-do\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mbell-launch-price-specs-release-date?utm_source=openai))\\n\\n- NVIDIA \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mcontinues major infrastructure/product rollouts: recent announcements arou\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mRubin CPX (GPU class for massive-context inference), Dynamo \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34minference-serving software, Blackwell Ultra/AI Factory platform, and \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mavailability plans for AI foundation models on RTX AI PCs \\u2014 signaling\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mheavy pushes on inference scale, large-context models, and on\\u2011device \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mfor creators/enterprises. (Company releases Sept\\u2013Oct 2025). \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([investor.nvidia.com](https://investor.nvidia.com/news/press-release-deta\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34ms/2025/NVIDIA-Unveils-Rubin-CPX-A-New-Class-of-GPU-Designed-for-Massive-Co\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mext-Inference/default.aspx?utm_source=openai))\\n\\n- Industry events / \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mcompany summits with product demos and roadmaps: OpenAI DevDay scheduled O\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m6, 2025 (preview teasers and DevDay announcements expected), and Anthropic\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mheld a London Builder Summit Oct 1 with demos of Claude and discussions of\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mautonomous-agent work. These events are driving near-term product and \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mdeveloper announcements. \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([openai.com](https://openai.com/index/announcing-devday-2025/?utm_source=\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34menai))\\n\\nIf you want, I can:\\n- Pull full articles and timelines for any \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mthe items above (e.g., full Reuters/AP/Verge/NVIDIA coverage).\\n- Summariz\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mthe expected user impact, pricing, or migration steps (e.g., what Microsof\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m365 Premium means if you\\u2019re a Copilot Pro or Personal subscriber).\\n-\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mTrack other outlets for any breaking follow-ups today (Oct 2, \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m2025).\\n\\nWhich of these would you like more detail on?\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"annotations\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;102;217;239;48;2;39;40;34mnull\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"meta\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;102;217;239;48;2;39;40;34mnull\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m}\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \n" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [10/02/2025 00:17:37] ─────────────────────────╮\n",
+       "│ 🧠 Reasoning                                                                 │\n",
+       "│                                                                              │\n",
+       "│ Ensuring news accuracy                                                       │\n",
+       "│                                                                              │\n",
+       "│ I found a summary that included items from both October 1 and 2, but since   │\n",
+       "│ the user specifically asked for news from October 2, I'm needing to clarify  │\n",
+       "│ that. The summary flagged items related to OpenAI partnerships specifically  │\n",
+       "│ from October 2, while Microsoft and Google Nest news fell within the range   │\n",
+       "│ of October 1–2. To refine this, I should run a new search focused on         │\n",
+       "│ \"October 2, 2025 AI news\" for accuracy. Let’s go for high-context results!   │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[95m╭─\u001b[0m\u001b[95m───────────────────────\u001b[0m\u001b[95m \u001b[0m\u001b[1;95mAGENT\u001b[0m\u001b[95m [10/02/2025 00:17:37] \u001b[0m\u001b[95m────────────────────────\u001b[0m\u001b[95m─╮\u001b[0m\n", + "\u001b[95m│\u001b[0m 🧠 \u001b[1mReasoning\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[1mEnsuring news accuracy\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m I found a summary that included items from both October 1 and 2, but since \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m the user specifically asked for news from October 2, I'm needing to clarify \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m that. The summary flagged items related to OpenAI partnerships specifically \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m from October 2, while Microsoft and Google Nest news fell within the range \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m of October 1–2. To refine this, I should run a new search focused on \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \"October 2, 2025 AI news\" for accuracy. Let’s go for high-context results! \u001b[95m│\u001b[0m\n", + "\u001b[95m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \n" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [10/02/2025 00:17:39] ─────────────────────────╮\n",
+       "│ 🔧 Tool Request: openai_web_search                                           │\n",
+       "│                                                                              │\n",
+       "│ Arguments:                                                                   │\n",
+       "│                                                                              │\n",
+       "│                                                                              │\n",
+       "│  {                                                                           │\n",
+       "│    \"arguments\": \"{\\\"input\\\":\\\"October 2 2025 AI news \\\\\\\"Oct 2\\\\\\\" 2025 'AI  │\n",
+       "│  'October 2, 2025'                                                           │\n",
+       "│  headlines\\\",\\\"model\\\":\\\"gpt-5-mini\\\",\\\"reasoning_effort\\\":\\\"low\\\",\\\"type\\\"  │\n",
+       "│  \"web_search_preview\\\",\\\"search_context_size\\\":\\\"high\\\"}\",                   │\n",
+       "│    \"call_id\": \"call_yPkg4tWTQozDGti1938f9WNV\",                               │\n",
+       "│    \"name\": \"openai_web_search\",                                              │\n",
+       "│    \"type\": \"function_call\",                                                  │\n",
+       "│    \"id\": \"fc_021a54d0dc6d53340068ddc4a4413881a3a6b57998c7e7a360\",            │\n",
+       "│    \"status\": \"completed\"                                                     │\n",
+       "│  }                                                                           │\n",
+       "│                                                                              │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[33m╭─\u001b[0m\u001b[33m───────────────────────\u001b[0m\u001b[33m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[33m [10/02/2025 00:17:39] \u001b[0m\u001b[33m────────────────────────\u001b[0m\u001b[33m─╮\u001b[0m\n", + "\u001b[33m│\u001b[0m 🔧 \u001b[1mTool Request: openai_web_search\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[1mArguments:\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m{\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"arguments\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"{\\\"input\\\":\\\"October 2 2025 AI news \\\\\\\"Oct 2\\\\\\\" 2025 'AI\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m'October 2, 2025' \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mheadlines\\\",\\\"model\\\":\\\"gpt-5-mini\\\",\\\"reasoning_effort\\\":\\\"low\\\",\\\"type\\\"\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"web_search_preview\\\",\\\"search_context_size\\\":\\\"high\\\"}\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"call_id\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"call_yPkg4tWTQozDGti1938f9WNV\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"name\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"openai_web_search\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"type\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"function_call\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"id\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"fc_021a54d0dc6d53340068ddc4a4413881a3a6b57998c7e7a360\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"status\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"completed\"\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m}\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \n" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [10/02/2025 00:17:55] ─────────────────────────╮\n",
+       "│ ✅ Tool Response: openai_web_search                                          │\n",
+       "│                                                                              │\n",
+       "│ Response:                                                                    │\n",
+       "│                                                                              │\n",
+       "│                                                                              │\n",
+       "│  {                                                                           │\n",
+       "│    \"type\": \"text\",                                                           │\n",
+       "│    \"text\": \"Here are major AI-related headlines for October 2, 2025 (Oct 2,  │\n",
+       "│  2025), with one-line summaries and sources:\\n\\n1) Meta to use AI-chatbot    │\n",
+       "│  conversations to target ads and content (policy announced; rollout details  │\n",
+       "│  and exclusions described).                                                  │\n",
+       "│  ([wsj.com](https://www.wsj.com/tech/ai/meta-will-begin-using-ai-chatbot-co  │\n",
+       "│  ersations-to-target-ads-291093d3?utm_source=openai))\\n\\n2) OpenAI launches  │\n",
+       "│  Sora (generative-AI short-video app) and debuts an upgraded generative vid  │\n",
+       "│  model; also rolls out a ChatGPT shopping feature (starts with Etsy sellers  │\n",
+       "│  ([sfgate.com](https://www.sfgate.com/tech/article/openai-takes-on-google-m  │\n",
+       "│  a-21076572.php?utm_source=openai))\\n\\n3) New AI-directed feature film \\\"Th  │\n",
+       "│  Sweet Idleness\\\" (claimed as first feature-length AI-generated film) tease  │\n",
+       "│  trailer release.                                                            │\n",
+       "│  ([en.wikipedia.org](https://en.wikipedia.org/wiki/The_Sweet_Idleness?utm_s  │\n",
+       "│  rce=openai))\\n\\n4) Reports of Elon Musk / xAI creating an AI-only software  │\n",
+       "│  company (projected to simulate traditional software firms) \\u2014 coverage  │\n",
+       "│  and industry reaction.                                                      │\n",
+       "│  ([fladgate.com](https://www.fladgate.com/insights/ai-round-up-october-2025  │\n",
+       "│  tm_source=openai))\\n\\n5) Ongoing international/regulatory moves: continued  │\n",
+       "│  reporting on AI governance (Framework Convention on AI, national executive  │\n",
+       "│  actions and proposed U.S. AI bills discussed in recent coverage).           │\n",
+       "│  ([en.wikipedia.org](https://en.wikipedia.org/wiki/Framework_Convention_on_  │\n",
+       "│  tificial_Intelligence?utm_source=openai))\\n\\nIf you\\u2019d like, I can:\\n-  │\n",
+       "│  Expand any headline into a short summary (2\\u20134 paragraphs) with         │\n",
+       "│  additional sources.\\n- Provide links to the full articles or a timeline of  │\n",
+       "│  Oct 2 coverage.\\n- Filter headlines by topic (policy, industry product      │\n",
+       "│  launches, legal, entertainment).\",                                          │\n",
+       "│    \"annotations\": null,                                                      │\n",
+       "│    \"meta\": null                                                              │\n",
+       "│  }                                                                           │\n",
+       "│                                                                              │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[92m╭─\u001b[0m\u001b[92m───────────────────────\u001b[0m\u001b[92m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[92m [10/02/2025 00:17:55] \u001b[0m\u001b[92m────────────────────────\u001b[0m\u001b[92m─╮\u001b[0m\n", + "\u001b[92m│\u001b[0m ✅ \u001b[1mTool Response: openai_web_search\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[1mResponse:\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m{\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"type\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"text\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"text\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"Here are major AI-related headlines for October 2, 2025 (Oct 2,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m2025), with one-line summaries and sources:\\n\\n1) Meta to use AI-chatbot \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mconversations to target ads and content (policy announced; rollout details\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mand exclusions described). \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([wsj.com](https://www.wsj.com/tech/ai/meta-will-begin-using-ai-chatbot-co\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mersations-to-target-ads-291093d3?utm_source=openai))\\n\\n2) OpenAI launches\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mSora (generative-AI short-video app) and debuts an upgraded generative vid\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mmodel; also rolls out a ChatGPT shopping feature (starts with Etsy sellers\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([sfgate.com](https://www.sfgate.com/tech/article/openai-takes-on-google-m\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34ma-21076572.php?utm_source=openai))\\n\\n3) New AI-directed feature film \\\"Th\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mSweet Idleness\\\" (claimed as first feature-length AI-generated film) tease\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mtrailer release. \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([en.wikipedia.org](https://en.wikipedia.org/wiki/The_Sweet_Idleness?utm_s\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mrce=openai))\\n\\n4) Reports of Elon Musk / xAI creating an AI-only software\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mcompany (projected to simulate traditional software firms) \\u2014 coverage\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mand industry reaction. \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([fladgate.com](https://www.fladgate.com/insights/ai-round-up-october-2025\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mtm_source=openai))\\n\\n5) Ongoing international/regulatory moves: continued\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mreporting on AI governance (Framework Convention on AI, national executive\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mactions and proposed U.S. AI bills discussed in recent coverage). \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([en.wikipedia.org](https://en.wikipedia.org/wiki/Framework_Convention_on_\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mtificial_Intelligence?utm_source=openai))\\n\\nIf you\\u2019d like, I can:\\n-\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mExpand any headline into a short summary (2\\u20134 paragraphs) with \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34madditional sources.\\n- Provide links to the full articles or a timeline of\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mOct 2 coverage.\\n- Filter headlines by topic (policy, industry product \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mlaunches, legal, entertainment).\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"annotations\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;102;217;239;48;2;39;40;34mnull\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"meta\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;102;217;239;48;2;39;40;34mnull\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m}\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \n", + " \n", + " \n", + " \n" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [10/02/2025 00:18:10] ─────────────────────────╮\n",
+       "│ 🧠 Reasoning                                                                 │\n",
+       "│                                                                              │\n",
+       "│ Confirming AI news dates                                                     │\n",
+       "│                                                                              │\n",
+       "│ I’m considering the upcoming events like the Anthropic London Builder Summit │\n",
+       "│ and OpenAI DevDay, but since they’re not specifically Oct 2 news, I think    │\n",
+       "│ it’s best to skip them. There’s also a report about an AI-generated film     │\n",
+       "│ teaser that seems weaker, so I’ll avoid including that. I want to focus on   │\n",
+       "│ providing 3-4 solid items from reliable sources like AP, WSJ, and SFGate.    │\n",
+       "│ I’ll run searches to verify each article's date by using web search to check │\n",
+       "│ the headlines specifically for Oct 2!                                        │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[95m╭─\u001b[0m\u001b[95m───────────────────────\u001b[0m\u001b[95m \u001b[0m\u001b[1;95mAGENT\u001b[0m\u001b[95m [10/02/2025 00:18:10] \u001b[0m\u001b[95m────────────────────────\u001b[0m\u001b[95m─╮\u001b[0m\n", + "\u001b[95m│\u001b[0m 🧠 \u001b[1mReasoning\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[1mConfirming AI news dates\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m I’m considering the upcoming events like the Anthropic London Builder Summit \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m and OpenAI DevDay, but since they’re not specifically Oct 2 news, I think \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m it’s best to skip them. There’s also a report about an AI-generated film \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m teaser that seems weaker, so I’ll avoid including that. I want to focus on \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m providing 3-4 solid items from reliable sources like AP, WSJ, and SFGate. \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m I’ll run searches to verify each article's date by using web search to check \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m the headlines specifically for Oct 2! \u001b[95m│\u001b[0m\n", + "\u001b[95m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \n" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [10/02/2025 00:18:13] ─────────────────────────╮\n",
+       "│ 🔧 Tool Request: openai_web_search                                           │\n",
+       "│                                                                              │\n",
+       "│ Arguments:                                                                   │\n",
+       "│                                                                              │\n",
+       "│                                                                              │\n",
+       "│  {                                                                           │\n",
+       "│    \"arguments\": \"{\\\"input\\\":\\\"Meta will begin using AI chatbot conversation  │\n",
+       "│  to target ads WSJ October 2                                                 │\n",
+       "│  2025\\\",\\\"model\\\":\\\"gpt-5-mini\\\",\\\"reasoning_effort\\\":\\\"low\\\",\\\"type\\\":\\\"we  │\n",
+       "│  search_preview\\\",\\\"search_context_size\\\":\\\"high\\\"}\",                        │\n",
+       "│    \"call_id\": \"call_Gf1YdPUxylTVI6pvznxqX0Si\",                               │\n",
+       "│    \"name\": \"openai_web_search\",                                              │\n",
+       "│    \"type\": \"function_call\",                                                  │\n",
+       "│    \"id\": \"fc_021a54d0dc6d53340068ddc4c5d2e481a3a52d44867b4e838b\",            │\n",
+       "│    \"status\": \"completed\"                                                     │\n",
+       "│  }                                                                           │\n",
+       "│                                                                              │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[33m╭─\u001b[0m\u001b[33m───────────────────────\u001b[0m\u001b[33m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[33m [10/02/2025 00:18:13] \u001b[0m\u001b[33m────────────────────────\u001b[0m\u001b[33m─╮\u001b[0m\n", + "\u001b[33m│\u001b[0m 🔧 \u001b[1mTool Request: openai_web_search\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[1mArguments:\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m{\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"arguments\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"{\\\"input\\\":\\\"Meta will begin using AI chatbot conversation\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mto target ads WSJ October 2 \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m2025\\\",\\\"model\\\":\\\"gpt-5-mini\\\",\\\"reasoning_effort\\\":\\\"low\\\",\\\"type\\\":\\\"we\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34msearch_preview\\\",\\\"search_context_size\\\":\\\"high\\\"}\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"call_id\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"call_Gf1YdPUxylTVI6pvznxqX0Si\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"name\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"openai_web_search\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"type\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"function_call\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"id\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"fc_021a54d0dc6d53340068ddc4c5d2e481a3a52d44867b4e838b\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"status\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"completed\"\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m}\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \n" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [10/02/2025 00:18:27] ─────────────────────────╮\n",
+       "│ ✅ Tool Response: openai_web_search                                          │\n",
+       "│                                                                              │\n",
+       "│ Response:                                                                    │\n",
+       "│                                                                              │\n",
+       "│                                                                              │\n",
+       "│  {                                                                           │\n",
+       "│    \"type\": \"text\",                                                           │\n",
+       "│    \"text\": \"Yes \\u2014 several outlets (including The Wall Street Journal)   │\n",
+       "│  report that Meta announced it will begin using users\\u2019 conversations    │\n",
+       "│  with its AI assistant to personalize ads and content. Key points:\\n\\n- Wha  │\n",
+       "│  Meta announced: interactions with Meta AI (text and voice) will be added t  │\n",
+       "│  the signals Meta uses to personalize feeds and ads across Facebook,         │\n",
+       "│  Instagram and other Meta apps.                                              │\n",
+       "│  ([wsj.com](https://www.wsj.com/tech/ai/meta-will-begin-using-ai-chatbot-co  │\n",
+       "│  ersations-to-target-ads-291093d3?utm_source=openai))  \\n- When it starts:   │\n",
+       "│  Meta will notify users beginning October 7, 2025, and the change takes      │\n",
+       "│  effect on December 16, 2025. Conversations before December 16 won\\u2019t b  │\n",
+       "│  used.                                                                       │\n",
+       "│  ([wsj.com](https://www.wsj.com/tech/ai/meta-will-begin-using-ai-chatbot-co  │\n",
+       "│  ersations-to-target-ads-291093d3?utm_source=openai))  \\n- Opt-out and scop  │\n",
+       "│  Users reportedly will not be able to opt out of using AI-chat data for      │\n",
+       "│  personalization; the change applies only to people who use Meta AI. Meta    │\n",
+       "│  says it will exclude certain \\u201csensitive\\u201d topics (examples listed  │\n",
+       "│  include politics, religion, sexual orientation, health, race) from being    │\n",
+       "│  used for ad targeting. The rollout initially excludes the U.K., the EU and  │\n",
+       "│  South Korea.                                                                │\n",
+       "│  ([wsj.com](https://www.wsj.com/tech/ai/meta-will-begin-using-ai-chatbot-co  │\n",
+       "│  ersations-to-target-ads-291093d3?utm_source=openai))  \\n- Why: Meta frames  │\n",
+       "│  this as part of funding and improving its AI/assistant strategy and making  │\n",
+       "│  recommendations more personalized; the company has large ad revenues and    │\n",
+       "│  many monthly Meta AI users.                                                 │\n",
+       "│  ([reuters.com](https://www.reuters.com/business/media-telecom/meta-use-ai-  │\n",
+       "│  ats-personalize-content-ads-december-2025-10-01/?utm_source=openai))\\n\\nIf  │\n",
+       "│  you want, I can:\\n- Pull the full WSJ article and quote the most relevant   │\n",
+       "│  passages (with source citation), or  \\n- Summarize differences in how majo  │\n",
+       "│  outlets are reporting this (WSJ vs. Reuters vs. Bloomberg), or  \\n- Explai  │\n",
+       "│  privacy implications and practical steps you can take (e.g., stop using Me  │\n",
+       "│  AI, adjust ad preferences, limit cross-account linking). Which would you    │\n",
+       "│  prefer?\",                                                                   │\n",
+       "│    \"annotations\": null,                                                      │\n",
+       "│    \"meta\": null                                                              │\n",
+       "│  }                                                                           │\n",
+       "│                                                                              │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[92m╭─\u001b[0m\u001b[92m───────────────────────\u001b[0m\u001b[92m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[92m [10/02/2025 00:18:27] \u001b[0m\u001b[92m────────────────────────\u001b[0m\u001b[92m─╮\u001b[0m\n", + "\u001b[92m│\u001b[0m ✅ \u001b[1mTool Response: openai_web_search\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[1mResponse:\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m{\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"type\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"text\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"text\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"Yes \\u2014 several outlets (including The Wall Street Journal) \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mreport that Meta announced it will begin using users\\u2019 conversations \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mwith its AI assistant to personalize ads and content. Key points:\\n\\n- Wha\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mMeta announced: interactions with Meta AI (text and voice) will be added t\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mthe signals Meta uses to personalize feeds and ads across Facebook, \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mInstagram and other Meta apps. \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([wsj.com](https://www.wsj.com/tech/ai/meta-will-begin-using-ai-chatbot-co\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mersations-to-target-ads-291093d3?utm_source=openai)) \\n- When it starts: \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mMeta will notify users beginning October 7, 2025, and the change takes \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34meffect on December 16, 2025. Conversations before December 16 won\\u2019t b\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mused. \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([wsj.com](https://www.wsj.com/tech/ai/meta-will-begin-using-ai-chatbot-co\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mersations-to-target-ads-291093d3?utm_source=openai)) \\n- Opt-out and scop\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mUsers reportedly will not be able to opt out of using AI-chat data for \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mpersonalization; the change applies only to people who use Meta AI. Meta \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34msays it will exclude certain \\u201csensitive\\u201d topics (examples listed\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34minclude politics, religion, sexual orientation, health, race) from being \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mused for ad targeting. The rollout initially excludes the U.K., the EU and\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mSouth Korea. \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([wsj.com](https://www.wsj.com/tech/ai/meta-will-begin-using-ai-chatbot-co\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mersations-to-target-ads-291093d3?utm_source=openai)) \\n- Why: Meta frames\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mthis as part of funding and improving its AI/assistant strategy and making\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mrecommendations more personalized; the company has large ad revenues and \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mmany monthly Meta AI users. \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([reuters.com](https://www.reuters.com/business/media-telecom/meta-use-ai-\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mats-personalize-content-ads-december-2025-10-01/?utm_source=openai))\\n\\nIf\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34myou want, I can:\\n- Pull the full WSJ article and quote the most relevant \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mpassages (with source citation), or \\n- Summarize differences in how majo\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34moutlets are reporting this (WSJ vs. Reuters vs. Bloomberg), or \\n- Explai\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mprivacy implications and practical steps you can take (e.g., stop using Me\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mAI, adjust ad preferences, limit cross-account linking). Which would you \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mprefer?\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"annotations\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;102;217;239;48;2;39;40;34mnull\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"meta\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;102;217;239;48;2;39;40;34mnull\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m}\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \n" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [10/02/2025 00:18:29] ─────────────────────────╮\n",
+       "│ 🔧 Tool Request: openai_web_search                                           │\n",
+       "│                                                                              │\n",
+       "│ Arguments:                                                                   │\n",
+       "│                                                                              │\n",
+       "│                                                                              │\n",
+       "│  {                                                                           │\n",
+       "│    \"arguments\": \"{\\\"input\\\":\\\"OpenAI Sora generative short-video app 'Sora'  │\n",
+       "│  launch October 2 2025 SFGate OpenAI generative video model ChatGPT shoppin  │\n",
+       "│  Etsy Oct 2                                                                  │\n",
+       "│  2025\\\",\\\"model\\\":\\\"gpt-5-mini\\\",\\\"reasoning_effort\\\":\\\"low\\\",\\\"type\\\":\\\"we  │\n",
+       "│  search_preview\\\",\\\"search_context_size\\\":\\\"high\\\"}\",                        │\n",
+       "│    \"call_id\": \"call_uW4nB1OU4OkqRFJXTnjiDIfl\",                               │\n",
+       "│    \"name\": \"openai_web_search\",                                              │\n",
+       "│    \"type\": \"function_call\",                                                  │\n",
+       "│    \"id\": \"fc_021a54d0dc6d53340068ddc4d626d481a3b1995f20b3b6bbb1\",            │\n",
+       "│    \"status\": \"completed\"                                                     │\n",
+       "│  }                                                                           │\n",
+       "│                                                                              │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[33m╭─\u001b[0m\u001b[33m───────────────────────\u001b[0m\u001b[33m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[33m [10/02/2025 00:18:29] \u001b[0m\u001b[33m────────────────────────\u001b[0m\u001b[33m─╮\u001b[0m\n", + "\u001b[33m│\u001b[0m 🔧 \u001b[1mTool Request: openai_web_search\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[1mArguments:\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m{\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"arguments\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"{\\\"input\\\":\\\"OpenAI Sora generative short-video app 'Sora'\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mlaunch October 2 2025 SFGate OpenAI generative video model ChatGPT shoppin\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mEtsy Oct 2 \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m2025\\\",\\\"model\\\":\\\"gpt-5-mini\\\",\\\"reasoning_effort\\\":\\\"low\\\",\\\"type\\\":\\\"we\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34msearch_preview\\\",\\\"search_context_size\\\":\\\"high\\\"}\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"call_id\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"call_uW4nB1OU4OkqRFJXTnjiDIfl\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"name\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"openai_web_search\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"type\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"function_call\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"id\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"fc_021a54d0dc6d53340068ddc4d626d481a3b1995f20b3b6bbb1\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"status\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"completed\"\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m}\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \n" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [10/02/2025 00:18:48] ─────────────────────────╮\n",
+       "│ ✅ Tool Response: openai_web_search                                          │\n",
+       "│                                                                              │\n",
+       "│ Response:                                                                    │\n",
+       "│                                                                              │\n",
+       "│                                                                              │\n",
+       "│  {                                                                           │\n",
+       "│    \"type\": \"text\",                                                           │\n",
+       "│    \"text\": \"Looks like you\\u2019re referencing two related OpenAI            │\n",
+       "│  announcements today (October 2, 2025). Here\\u2019s a short, sourced summar  │\n",
+       "│  and what I could confirm:\\n\\n- OpenAI launched a new short-video / social   │\n",
+       "│  app called Sora (invite-only, iOS) that uses its Sora video model (Sora 2)  │\n",
+       "│  to generate short videos with synchronized audio, \\u201ccameo\\u201d likene  │\n",
+       "│  features, and feed-style sharing. Multiple outlets reported the rollout     │\n",
+       "│  on/around Oct 1\\u20132, 2025.                                               │\n",
+       "│  ([macrumors.com](https://www.macrumors.com/2025/09/30/openai-sora-ai-video  │\n",
+       "│  pp/?utm_source=openai))\\n\\n- Separately (and related to ChatGPT), OpenAI    │\n",
+       "│  announced an Instant Checkout shopping feature in ChatGPT that initially    │\n",
+       "│  lets U.S. users buy single items from Etsy sellers directly inside ChatGPT  │\n",
+       "│  and will expand to many Shopify merchants. Etsy has a partnership page      │\n",
+       "│  explaining purchases through ChatGPT and CNBC/other outlets covered the     │\n",
+       "│  Instant Checkout announcement (late September / rolling into early October  │\n",
+       "│  2025).                                                                      │\n",
+       "│  ([help.etsy.com](https://help.etsy.com/hc/en-us/articles/34208252828695-Pu  │\n",
+       "│  hasing-Etsy-Items-Through-ChatGPT?utm_source=openai))\\n\\nNote about SFGate  │\n",
+       "│  I didn\\u2019t find an SFGate article in the searches I ran; major outlets   │\n",
+       "│  (MacRumors, CNBC, TechCrunch, Wired, Indian Express, Etsy\\u2019s own        │\n",
+       "│  help/news page) are reporting these items. If you want, I can specifically  │\n",
+       "│  search SFGate (or fetch any SFGate link) and pull that article for you.     │\n",
+       "│  Would you like me to do that or summarize anything in more detail (privacy  │\n",
+       "│  controls, \\u201ccameo\\u201d flow, Instant Checkout payment methods,         │\n",
+       "│  availability, etc.)?\",                                                      │\n",
+       "│    \"annotations\": null,                                                      │\n",
+       "│    \"meta\": null                                                              │\n",
+       "│  }                                                                           │\n",
+       "│                                                                              │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[92m╭─\u001b[0m\u001b[92m───────────────────────\u001b[0m\u001b[92m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[92m [10/02/2025 00:18:48] \u001b[0m\u001b[92m────────────────────────\u001b[0m\u001b[92m─╮\u001b[0m\n", + "\u001b[92m│\u001b[0m ✅ \u001b[1mTool Response: openai_web_search\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[1mResponse:\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m{\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"type\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"text\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"text\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"Looks like you\\u2019re referencing two related OpenAI \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mannouncements today (October 2, 2025). Here\\u2019s a short, sourced summar\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mand what I could confirm:\\n\\n- OpenAI launched a new short-video / social \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mapp called Sora (invite-only, iOS) that uses its Sora video model (Sora 2)\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mto generate short videos with synchronized audio, \\u201ccameo\\u201d likene\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mfeatures, and feed-style sharing. Multiple outlets reported the rollout \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mon/around Oct 1\\u20132, 2025. \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([macrumors.com](https://www.macrumors.com/2025/09/30/openai-sora-ai-video\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mpp/?utm_source=openai))\\n\\n- Separately (and related to ChatGPT), OpenAI \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mannounced an Instant Checkout shopping feature in ChatGPT that initially \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mlets U.S. users buy single items from Etsy sellers directly inside ChatGPT\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mand will expand to many Shopify merchants. Etsy has a partnership page \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mexplaining purchases through ChatGPT and CNBC/other outlets covered the \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mInstant Checkout announcement (late September / rolling into early October\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m2025). \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([help.etsy.com](https://help.etsy.com/hc/en-us/articles/34208252828695-Pu\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mhasing-Etsy-Items-Through-ChatGPT?utm_source=openai))\\n\\nNote about SFGate\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mI didn\\u2019t find an SFGate article in the searches I ran; major outlets \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m(MacRumors, CNBC, TechCrunch, Wired, Indian Express, Etsy\\u2019s own \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mhelp/news page) are reporting these items. If you want, I can specifically\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34msearch SFGate (or fetch any SFGate link) and pull that article for you. \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mWould you like me to do that or summarize anything in more detail (privacy\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mcontrols, \\u201ccameo\\u201d flow, Instant Checkout payment methods, \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mavailability, etc.)?\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"annotations\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;102;217;239;48;2;39;40;34mnull\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"meta\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;102;217;239;48;2;39;40;34mnull\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m}\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \n" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [10/02/2025 00:18:50] ─────────────────────────╮\n",
+       "│ 🔧 Tool Request: openai_web_search                                           │\n",
+       "│                                                                              │\n",
+       "│ Arguments:                                                                   │\n",
+       "│                                                                              │\n",
+       "│                                                                              │\n",
+       "│  {                                                                           │\n",
+       "│    \"arguments\": \"{\\\"input\\\":\\\"OpenAI Stargate partnerships South Korea       │\n",
+       "│  Samsung SK Oct 2 2025 AP News 'Stargate' Oct 2 2025 OpenAI South Korea      │\n",
+       "│  partnership\\\",\\\"model\\\":\\\"gpt-5-mini\\\",\\\"reasoning_effort\\\":\\\"low\\\",\\\"type  │\n",
+       "│  :\\\"web_search_preview\\\",\\\"search_context_size\\\":\\\"high\\\"}\",                 │\n",
+       "│    \"call_id\": \"call_VW52QZ6thugsAMWsvZe3SwfN\",                               │\n",
+       "│    \"name\": \"openai_web_search\",                                              │\n",
+       "│    \"type\": \"function_call\",                                                  │\n",
+       "│    \"id\": \"fc_021a54d0dc6d53340068ddc4eaaf5081a39fac1af70f131d0d\",            │\n",
+       "│    \"status\": \"completed\"                                                     │\n",
+       "│  }                                                                           │\n",
+       "│                                                                              │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[33m╭─\u001b[0m\u001b[33m───────────────────────\u001b[0m\u001b[33m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[33m [10/02/2025 00:18:50] \u001b[0m\u001b[33m────────────────────────\u001b[0m\u001b[33m─╮\u001b[0m\n", + "\u001b[33m│\u001b[0m 🔧 \u001b[1mTool Request: openai_web_search\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[1mArguments:\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m{\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"arguments\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"{\\\"input\\\":\\\"OpenAI Stargate partnerships South Korea \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mSamsung SK Oct 2 2025 AP News 'Stargate' Oct 2 2025 OpenAI South Korea \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mpartnership\\\",\\\"model\\\":\\\"gpt-5-mini\\\",\\\"reasoning_effort\\\":\\\"low\\\",\\\"type\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m:\\\"web_search_preview\\\",\\\"search_context_size\\\":\\\"high\\\"}\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"call_id\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"call_VW52QZ6thugsAMWsvZe3SwfN\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"name\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"openai_web_search\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"type\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"function_call\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"id\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"fc_021a54d0dc6d53340068ddc4eaaf5081a39fac1af70f131d0d\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"status\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"completed\"\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m}\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \n" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [10/02/2025 00:19:07] ─────────────────────────╮\n",
+       "│ ✅ Tool Response: openai_web_search                                          │\n",
+       "│                                                                              │\n",
+       "│ Response:                                                                    │\n",
+       "│                                                                              │\n",
+       "│                                                                              │\n",
+       "│  {                                                                           │\n",
+       "│    \"type\": \"text\",                                                           │\n",
+       "│    \"text\": \"Yes \\u2014 OpenAI announced this partnership with major South    │\n",
+       "│  Korean firms at the start of October 2025. Short summary of the key points  │\n",
+       "│  (with sources):\\n\\n- What was announced: OpenAI signed letters of intent /  │\n",
+       "│  memoranda of understanding with Samsung Electronics and SK Group (includin  │\n",
+       "│  SK hynix and SK Telecom) to supply memory chips and explore data\\u2011cent  │\n",
+       "│  collaboration as part of OpenAI\\u2019s large \\u201cStargate\\u201d           │\n",
+       "│  infrastructure initiative.                                                  │\n",
+       "│  ([apnews.com](https://apnews.com/article/a65fd1a21a8587c991cc30b94b1dfe89?  │\n",
+       "│  m_source=openai))\\n\\n- Supply plans: Samsung and SK hynix are expected to   │\n",
+       "│  scale production of advanced memory (HBM/DRAM) to support Stargate, with    │\n",
+       "│  reports quoting demand estimates up to about 900,000 DRAM wafers per month  │\n",
+       "│  Specific delivery schedules and final contract volumes were not finalized   │\n",
+       "│  the announcements.                                                          │\n",
+       "│  ([reuters.com](https://www.reuters.com/business/media-telecom/samsung-sk-h  │\n",
+       "│  ix-supply-memory-chips-openais-stargate-project-2025-10-01/?utm_source=ope  │\n",
+       "│  i))\\n\\n- Data\\u2011center cooperation: OpenAI and SK Telecom signed an MOU  │\n",
+       "│  to explore building an AI data center in South Korea (referred to in        │\n",
+       "│  coverage as \\u201cStargate Korea\\u201d), and Samsung affiliates will explo  │\n",
+       "│  data\\u2011center technologies (including discussions reported about floati  │\n",
+       "│  data\\u2011center concepts).                                                 │\n",
+       "│  ([apnews.com](https://apnews.com/article/a65fd1a21a8587c991cc30b94b1dfe89?  │\n",
+       "│  m_source=openai))\\n\\n- Context / meetings: The public announcements follow  │\n",
+       "│  meetings in Seoul between OpenAI CEO Sam Altman, South Korean President Le  │\n",
+       "│  Jae\\u2011myung, and senior leaders of Samsung and SK. The coverage ties th  │\n",
+       "│  deals to OpenAI\\u2019s broader Stargate expansion and efforts to secure     │\n",
+       "│  large-scale compute and memory supply.                                      │\n",
+       "│  ([apnews.com](https://apnews.com/article/a65fd1a21a8587c991cc30b94b1dfe89?  │\n",
+       "│  m_source=openai))\\n\\nIf you want, I can:\\n- Pull up the full AP article     │\n",
+       "│  (published Oct 2, 2025) or Reuters/Samsung/SK press releases for exact      │\n",
+       "│  wording and timestamps.\\n- Extract and compare direct quotes from Sam       │\n",
+       "│  Altman, Samsung and SK executives.\\n- Track whether these LOIs/MOUs later   │\n",
+       "│  convert into firm purchase agreements and delivery timelines (would requir  │\n",
+       "│  follow-up searches). Which would you like?\",                                │\n",
+       "│    \"annotations\": null,                                                      │\n",
+       "│    \"meta\": null                                                              │\n",
+       "│  }                                                                           │\n",
+       "│                                                                              │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[92m╭─\u001b[0m\u001b[92m───────────────────────\u001b[0m\u001b[92m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[92m [10/02/2025 00:19:07] \u001b[0m\u001b[92m────────────────────────\u001b[0m\u001b[92m─╮\u001b[0m\n", + "\u001b[92m│\u001b[0m ✅ \u001b[1mTool Response: openai_web_search\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[1mResponse:\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m{\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"type\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"text\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"text\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"Yes \\u2014 OpenAI announced this partnership with major South \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mKorean firms at the start of October 2025. Short summary of the key points\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m(with sources):\\n\\n- What was announced: OpenAI signed letters of intent /\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mmemoranda of understanding with Samsung Electronics and SK Group (includin\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mSK hynix and SK Telecom) to supply memory chips and explore data\\u2011cent\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mcollaboration as part of OpenAI\\u2019s large \\u201cStargate\\u201d \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34minfrastructure initiative. \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([apnews.com](https://apnews.com/article/a65fd1a21a8587c991cc30b94b1dfe89?\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mm_source=openai))\\n\\n- Supply plans: Samsung and SK hynix are expected to \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mscale production of advanced memory (HBM/DRAM) to support Stargate, with \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mreports quoting demand estimates up to about 900,000 DRAM wafers per month\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mSpecific delivery schedules and final contract volumes were not finalized \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mthe announcements. \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([reuters.com](https://www.reuters.com/business/media-telecom/samsung-sk-h\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mix-supply-memory-chips-openais-stargate-project-2025-10-01/?utm_source=ope\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mi))\\n\\n- Data\\u2011center cooperation: OpenAI and SK Telecom signed an MOU\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mto explore building an AI data center in South Korea (referred to in \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mcoverage as \\u201cStargate Korea\\u201d), and Samsung affiliates will explo\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mdata\\u2011center technologies (including discussions reported about floati\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mdata\\u2011center concepts). \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([apnews.com](https://apnews.com/article/a65fd1a21a8587c991cc30b94b1dfe89?\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mm_source=openai))\\n\\n- Context / meetings: The public announcements follow\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mmeetings in Seoul between OpenAI CEO Sam Altman, South Korean President Le\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mJae\\u2011myung, and senior leaders of Samsung and SK. The coverage ties th\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mdeals to OpenAI\\u2019s broader Stargate expansion and efforts to secure \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mlarge-scale compute and memory supply. \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([apnews.com](https://apnews.com/article/a65fd1a21a8587c991cc30b94b1dfe89?\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mm_source=openai))\\n\\nIf you want, I can:\\n- Pull up the full AP article \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m(published Oct 2, 2025) or Reuters/Samsung/SK press releases for exact \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mwording and timestamps.\\n- Extract and compare direct quotes from Sam \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mAltman, Samsung and SK executives.\\n- Track whether these LOIs/MOUs later \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mconvert into firm purchase agreements and delivery timelines (would requir\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mfollow-up searches). Which would you like?\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"annotations\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;102;217;239;48;2;39;40;34mnull\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"meta\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;102;217;239;48;2;39;40;34mnull\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m}\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \n" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [10/02/2025 00:19:09] ─────────────────────────╮\n",
+       "│ 🧠 Reasoning                                                                 │\n",
+       "│                                                                              │\n",
+       "│ Looking for relevant news                                                    │\n",
+       "│                                                                              │\n",
+       "│ I’m considering if there are any major items from October 2. The second      │\n",
+       "│ search generated some reports about film and Elon Musk, which seem less      │\n",
+       "│ central. I might add a brief note about ongoing regulatory actions. To be    │\n",
+       "│ thorough, I'll search for “AI regulation news from October 2, 2025,” as that │\n",
+       "│ might provide more focused and relevant updates regarding current            │\n",
+       "│ discussions on regulation in AI. Let’s see what I find!                      │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[95m╭─\u001b[0m\u001b[95m───────────────────────\u001b[0m\u001b[95m \u001b[0m\u001b[1;95mAGENT\u001b[0m\u001b[95m [10/02/2025 00:19:09] \u001b[0m\u001b[95m────────────────────────\u001b[0m\u001b[95m─╮\u001b[0m\n", + "\u001b[95m│\u001b[0m 🧠 \u001b[1mReasoning\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[1mLooking for relevant news\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m I’m considering if there are any major items from October 2. The second \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m search generated some reports about film and Elon Musk, which seem less \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m central. I might add a brief note about ongoing regulatory actions. To be \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m thorough, I'll search for “AI regulation news from October 2, 2025,” as that \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m might provide more focused and relevant updates regarding current \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m discussions on regulation in AI. Let’s see what I find! \u001b[95m│\u001b[0m\n", + "\u001b[95m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \n" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [10/02/2025 00:19:11] ─────────────────────────╮\n",
+       "│ 🔧 Tool Request: openai_web_search                                           │\n",
+       "│                                                                              │\n",
+       "│ Arguments:                                                                   │\n",
+       "│                                                                              │\n",
+       "│                                                                              │\n",
+       "│  {                                                                           │\n",
+       "│    \"arguments\": \"{\\\"input\\\":\\\"AI regulation news October 2 2025 'Oct 2' AI   │\n",
+       "│  governance today Oct 2 2025                                                 │\n",
+       "│  headlines\\\",\\\"model\\\":\\\"gpt-5-mini\\\",\\\"reasoning_effort\\\":\\\"low\\\",\\\"type\\\"  │\n",
+       "│  \"web_search_preview\\\",\\\"search_context_size\\\":\\\"high\\\"}\",                   │\n",
+       "│    \"call_id\": \"call_C5Uie1v4Y0iShinSml9ekyUr\",                               │\n",
+       "│    \"name\": \"openai_web_search\",                                              │\n",
+       "│    \"type\": \"function_call\",                                                  │\n",
+       "│    \"id\": \"fc_021a54d0dc6d53340068ddc500466481a3901b511309490a71\",            │\n",
+       "│    \"status\": \"completed\"                                                     │\n",
+       "│  }                                                                           │\n",
+       "│                                                                              │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[33m╭─\u001b[0m\u001b[33m───────────────────────\u001b[0m\u001b[33m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[33m [10/02/2025 00:19:11] \u001b[0m\u001b[33m────────────────────────\u001b[0m\u001b[33m─╮\u001b[0m\n", + "\u001b[33m│\u001b[0m 🔧 \u001b[1mTool Request: openai_web_search\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[1mArguments:\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m{\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"arguments\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"{\\\"input\\\":\\\"AI regulation news October 2 2025 'Oct 2' AI \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mgovernance today Oct 2 2025 \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mheadlines\\\",\\\"model\\\":\\\"gpt-5-mini\\\",\\\"reasoning_effort\\\":\\\"low\\\",\\\"type\\\"\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"web_search_preview\\\",\\\"search_context_size\\\":\\\"high\\\"}\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"call_id\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"call_C5Uie1v4Y0iShinSml9ekyUr\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"name\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"openai_web_search\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"type\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"function_call\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"id\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"fc_021a54d0dc6d53340068ddc500466481a3901b511309490a71\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"status\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"completed\"\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m}\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \n" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [10/02/2025 00:19:20] ─────────────────────────╮\n",
+       "│ ✅ Tool Response: openai_web_search                                          │\n",
+       "│                                                                              │\n",
+       "│ Response:                                                                    │\n",
+       "│                                                                              │\n",
+       "│                                                                              │\n",
+       "│  {                                                                           │\n",
+       "│    \"type\": \"text\",                                                           │\n",
+       "│    \"text\": \"Here are the top AI-regulation/governance headlines for October  │\n",
+       "│  2, 2025 (sources cited):\\n\\n- U.S. administration pushes back on            │\n",
+       "│  industry-led health\\u2011AI oversight (Coalition for Health AI): Trump      │\n",
+       "│  administration officials and some GOP lawmakers criticized the CHAI         │\n",
+       "│  private-sector oversight initiative as potentially monopolistic and are     │\n",
+       "│  moving to distance federal endorsement.                                     │\n",
+       "│  ([politico.com](https://www.politico.com/news/2025/10/01/trump-ai-artifici  │\n",
+       "│  -intelligence-regulation-hhs-00590902?utm_source=openai))\\n\\n- European     │\n",
+       "│  Commission transparency consultation for AI-generated content closes today  │\n",
+       "│  (Oct 2, 2025): the consultation on Article 50 transparency guidelines \\u20  │\n",
+       "│  covering labeling of AI\\u2011generated content, deepfake disclosure and     │\n",
+       "│  related rules \\u2014 runs through Oct 2, 2025 and will feed mandatory       │\n",
+       "│  transparency obligations that take effect next year.                        │\n",
+       "│  ([euairisk.com](https://euairisk.com/news/2025-09-13?utm_source=openai))\\n  │\n",
+       "│  - Google Europe executive urges EU to simplify overlapping AI rules:        │\n",
+       "│  Alphabet/Google\\u2019s Europe president called for streamlining the         │\n",
+       "│  EU\\u2019s growing patchwork of internet- and AI-related laws, warning       │\n",
+       "│  complexity risks harming innovation.                                        │\n",
+       "│  ([timesofindia.indiatimes.com](https://timesofindia.indiatimes.com/technol  │\n",
+       "│  y/tech-news/google-europe-president-debbie-weinstein-on-eus-ai-laws-there-  │\n",
+       "│  -a-real-need-for-/articleshow/124264273.cms?utm_source=openai))\\n\\n-        │\n",
+       "│  Implementation issues and timeline pressure continue around the EU AI Act:  │\n",
+       "│  standards bodies and others have flagged delays and calls from industry fo  │\n",
+       "│  more time or simplification as key AI\\u2011Act technical standards and      │\n",
+       "│  compliance guidance are still being finalized.                              │\n",
+       "│  ([euronews.com](https://www.euronews.com/next/2025/04/16/eu-standards-bodi  │\n",
+       "│  -flag-delays-to-work-on-ai-act?utm_source=openai))\\n\\n- Broader geopolitic  │\n",
+       "│  / market context: policymakers\\u2019 moves and regulatory uncertainty are   │\n",
+       "│  taking place amid continued large capital flows into AI infrastructure and  │\n",
+       "│  products, which is keeping AI governance high on legislative and corporate  │\n",
+       "│  agendas.                                                                    │\n",
+       "│  ([theaustralian.com.au](https://www.theaustralian.com.au/business/markets/  │\n",
+       "│  -shutdown-the-real-story-behind-surging-ai-investment-numbers/news-story/9  │\n",
+       "│  f4249381f557d13c947d037a6e905?utm_source=openai))\\n\\nWould you like a deep  │\n",
+       "│  summary for any of these items (timeline, who\\u2019s involved, likely next  │\n",
+       "│  steps), or links to the full articles?\",                                    │\n",
+       "│    \"annotations\": null,                                                      │\n",
+       "│    \"meta\": null                                                              │\n",
+       "│  }                                                                           │\n",
+       "│                                                                              │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[92m╭─\u001b[0m\u001b[92m───────────────────────\u001b[0m\u001b[92m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[92m [10/02/2025 00:19:20] \u001b[0m\u001b[92m────────────────────────\u001b[0m\u001b[92m─╮\u001b[0m\n", + "\u001b[92m│\u001b[0m ✅ \u001b[1mTool Response: openai_web_search\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[1mResponse:\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m{\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"type\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"text\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"text\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"Here are the top AI-regulation/governance headlines for October\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m2, 2025 (sources cited):\\n\\n- U.S. administration pushes back on \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mindustry-led health\\u2011AI oversight (Coalition for Health AI): Trump \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34madministration officials and some GOP lawmakers criticized the CHAI \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mprivate-sector oversight initiative as potentially monopolistic and are \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mmoving to distance federal endorsement. \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([politico.com](https://www.politico.com/news/2025/10/01/trump-ai-artifici\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m-intelligence-regulation-hhs-00590902?utm_source=openai))\\n\\n- European \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mCommission transparency consultation for AI-generated content closes today\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m(Oct 2, 2025): the consultation on Article 50 transparency guidelines \\u20\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mcovering labeling of AI\\u2011generated content, deepfake disclosure and \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mrelated rules \\u2014 runs through Oct 2, 2025 and will feed mandatory \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mtransparency obligations that take effect next year. \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([euairisk.com](https://euairisk.com/news/2025-09-13?utm_source=openai))\\n\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m- Google Europe executive urges EU to simplify overlapping AI rules: \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mAlphabet/Google\\u2019s Europe president called for streamlining the \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mEU\\u2019s growing patchwork of internet- and AI-related laws, warning \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mcomplexity risks harming innovation. \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([timesofindia.indiatimes.com](https://timesofindia.indiatimes.com/technol\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34my/tech-news/google-europe-president-debbie-weinstein-on-eus-ai-laws-there-\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m-a-real-need-for-/articleshow/124264273.cms?utm_source=openai))\\n\\n- \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mImplementation issues and timeline pressure continue around the EU AI Act:\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mstandards bodies and others have flagged delays and calls from industry fo\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mmore time or simplification as key AI\\u2011Act technical standards and \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mcompliance guidance are still being finalized. \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([euronews.com](https://www.euronews.com/next/2025/04/16/eu-standards-bodi\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m-flag-delays-to-work-on-ai-act?utm_source=openai))\\n\\n- Broader geopolitic\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m/ market context: policymakers\\u2019 moves and regulatory uncertainty are \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mtaking place amid continued large capital flows into AI infrastructure and\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mproducts, which is keeping AI governance high on legislative and corporate\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34magendas. \u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m([theaustralian.com.au](https://www.theaustralian.com.au/business/markets/\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m-shutdown-the-real-story-behind-surging-ai-investment-numbers/news-story/9\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34mf4249381f557d13c947d037a6e905?utm_source=openai))\\n\\nWould you like a deep\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34msummary for any of these items (timeline, who\\u2019s involved, likely next\u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34msteps), or links to the full articles?\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"annotations\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;102;217;239;48;2;39;40;34mnull\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"meta\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;102;217;239;48;2;39;40;34mnull\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m}\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \n" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [10/02/2025 00:19:23] ─────────────────────────╮\n",
+       "│ 🧠 Reasoning                                                                 │\n",
+       "│                                                                              │\n",
+       "│ Gathering AI news summaries                                                  │\n",
+       "│                                                                              │\n",
+       "│ I noticed that some items are from earlier dates but were listed as closing  │\n",
+       "│ on October 2. The user probably wants a brief list of the top AI news items  │\n",
+       "│ reported today. I should create concise bullet points with sources and 1-2   │\n",
+       "│ sentence summaries. It might be a good idea to offer to expand on any item   │\n",
+       "│ or provide full article links. Plus, I’ll mention that coverage evolves and  │\n",
+       "│ offer to keep monitoring for updates.                                        │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[95m╭─\u001b[0m\u001b[95m───────────────────────\u001b[0m\u001b[95m \u001b[0m\u001b[1;95mAGENT\u001b[0m\u001b[95m [10/02/2025 00:19:23] \u001b[0m\u001b[95m────────────────────────\u001b[0m\u001b[95m─╮\u001b[0m\n", + "\u001b[95m│\u001b[0m 🧠 \u001b[1mReasoning\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[1mGathering AI news summaries\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m I noticed that some items are from earlier dates but were listed as closing \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m on October 2. The user probably wants a brief list of the top AI news items \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m reported today. I should create concise bullet points with sources and 1-2 \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m sentence summaries. It might be a good idea to offer to expand on any item \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m or provide full article links. Plus, I’ll mention that coverage evolves and \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m offer to keep monitoring for updates. \u001b[95m│\u001b[0m\n", + "\u001b[95m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \n" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [10/02/2025 00:19:26] ─────────────────────────╮\n",
+       "│ 🧠 Reasoning                                                                 │\n",
+       "│                                                                              │\n",
+       "│ Summarizing AI stories for today                                             │\n",
+       "│                                                                              │\n",
+       "│ I’m crafting the final answer by starting with, \"Here are the main AI        │\n",
+       "│ stories reported today (Oct 2, 2025):\" First, Meta plans to use AI-chatbot   │\n",
+       "│ conversations to target ads, starting December 16, with notifications on     │\n",
+       "│ October 7, although it excludes the UK, EU, and South Korea initially and    │\n",
+       "│ sensitive topics.                                                            │\n",
+       "│                                                                              │\n",
+       "│ Next, there’s news about OpenAI expanding partnerships in South Korea with   │\n",
+       "│ Samsung and SK, and launching the Sora short-video app. Finally, although    │\n",
+       "│ the user wants \"today only,\" I might mention the EU consultation on          │\n",
+       "│ transparency is relevant too.                                                │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[95m╭─\u001b[0m\u001b[95m───────────────────────\u001b[0m\u001b[95m \u001b[0m\u001b[1;95mAGENT\u001b[0m\u001b[95m [10/02/2025 00:19:26] \u001b[0m\u001b[95m────────────────────────\u001b[0m\u001b[95m─╮\u001b[0m\n", + "\u001b[95m│\u001b[0m 🧠 \u001b[1mReasoning\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[1mSummarizing AI stories for today\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m I’m crafting the final answer by starting with, \"Here are the main AI \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m stories reported today (Oct 2, 2025):\" First, Meta plans to use AI-chatbot \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m conversations to target ads, starting December 16, with notifications on \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m October 7, although it excludes the UK, EU, and South Korea initially and \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m sensitive topics. \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m Next, there’s news about OpenAI expanding partnerships in South Korea with \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m Samsung and SK, and launching the Sora short-video app. Finally, although \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m the user wants \"today only,\" I might mention the EU consultation on \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m transparency is relevant too. \u001b[95m│\u001b[0m\n", + "\u001b[95m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \n" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [10/02/2025 00:19:28] ─────────────────────────╮\n",
+       "│ 🧠 Reasoning                                                                 │\n",
+       "│                                                                              │\n",
+       "│ Crafting the final message                                                   │\n",
+       "│                                                                              │\n",
+       "│ I could ask the user if they want the full articles in addition to the       │\n",
+       "│ summaries. I need to keep things concise and make direct offers like, \"Do    │\n",
+       "│ you want full articles, a deeper summary, or tracking updates?\" It’s         │\n",
+       "│ important to mention sources as well, and since the search provided URLs in  │\n",
+       "│ the tool outputs, I can include those in parentheses. Alright, I’ll put all  │\n",
+       "│ this together to create the final message!                                   │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[95m╭─\u001b[0m\u001b[95m───────────────────────\u001b[0m\u001b[95m \u001b[0m\u001b[1;95mAGENT\u001b[0m\u001b[95m [10/02/2025 00:19:28] \u001b[0m\u001b[95m────────────────────────\u001b[0m\u001b[95m─╮\u001b[0m\n", + "\u001b[95m│\u001b[0m 🧠 \u001b[1mReasoning\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[1mCrafting the final message\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m I could ask the user if they want the full articles in addition to the \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m summaries. I need to keep things concise and make direct offers like, \"Do \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m you want full articles, a deeper summary, or tracking updates?\" It’s \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m important to mention sources as well, and since the search provided URLs in \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m the tool outputs, I can include those in parentheses. Alright, I’ll put all \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m this together to create the final message! \u001b[95m│\u001b[0m\n", + "\u001b[95m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \n" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [10/02/2025 00:19:31] ─────────────────────────╮\n",
+       "│ Here are the main AI stories reported today (October 2, 2025):               │\n",
+       "│                                                                              │\n",
+       "│  • Meta will begin using conversations with its Meta AI assistant to         │\n",
+       "│    personalize ads and content (WSJ / Reuters). Key points: Meta says it     │\n",
+       "│    will add signals from AI chats to ad/content personalization (excluding   │\n",
+       "│    certain “sensitive” topics), notify users starting Oct 7, and the change  │\n",
+       "│    takes effect Dec 16; rollout initially excludes the U.K., EU and South    │\n",
+       "│    Korea. Sources: Wall Street Journal (Oct 2) and Reuters coverage.         │\n",
+       "│    Links:                                                                    │\n",
+       "│    https://www.wsj.com/tech/ai/meta-will-begin-using-ai-chatbot-conversation │\n",
+       "│    s-to-target-ads-291093d3 and                                              │\n",
+       "│    https://www.reuters.com/business/media-telecom/meta-use-ai-chats-personal │\n",
+       "│    ize-content-ads-december-2025-10-01/                                      │\n",
+       "│  • OpenAI expands its “Stargate” infrastructure partnerships in South Korea  │\n",
+       "│    (AP / Reuters). OpenAI signed MOUs/LOIs with Samsung and SK Group to      │\n",
+       "│    secure advanced memory supply (HBM/DRAM) and explore data‑center          │\n",
+       "│    collaboration as part of its global Stargate build‑out. Coverage          │\n",
+       "│    highlights meetings in Seoul between OpenAI leadership and South Korean   │\n",
+       "│    officials/companies. Source: AP News (Oct 2) and Reuters.                 │\n",
+       "│    Link: https://apnews.com/article/a65fd1a21a8587c991cc30b94b1dfe89         │\n",
+       "│  • OpenAI launches Sora (short-video / generative-video app) and related     │\n",
+       "│    generative-video features; ChatGPT shopping/Instant Checkout pilots       │\n",
+       "│    continue (tech press reports). Outlets report OpenAI rolling out an       │\n",
+       "│    invite-style short-video app (Sora) built on its video-generation models  │\n",
+       "│    and expanding ChatGPT shopping integrations (initial merchants include    │\n",
+       "│    Etsy sellers). Sources: major tech outlets reporting Oct 1–2 (examples:   │\n",
+       "│    MacRumors, CNBC/tech sites).                                              │\n",
+       "│    Example link:                                                             │\n",
+       "│    https://www.macrumors.com/2025/09/30/openai-sora-ai-video-app/            │\n",
+       "│  • AI governance/regulatory items tied to Oct 2: EU transparency             │\n",
+       "│    consultation on AI‑generated content and labeling closes today (feeds     │\n",
+       "│    ongoing EU transparency/labeling work), and the U.S. press continues      │\n",
+       "│    debate about private-sector AI oversight initiatives. Sources include EU  │\n",
+       "│    filings and Politico / other regulatory coverage.                         │\n",
+       "│                                                                              │\n",
+       "│ Would you like me to:                                                        │\n",
+       "│                                                                              │\n",
+       "│  • Pull the full articles for any of the items above and summarize them in   │\n",
+       "│    more detail?                                                              │\n",
+       "│  • Focus on one topic (privacy implications for Meta’s change,               │\n",
+       "│    technical/market impact of OpenAI’s Stargate deals, how Sora works and    │\n",
+       "│    availability)?                                                            │\n",
+       "│  • Monitor the rest of today for any breaking updates (I can check again and │\n",
+       "│    send any new items)?                                                      │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[32m╭─\u001b[0m\u001b[32m───────────────────────\u001b[0m\u001b[32m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[32m [10/02/2025 00:19:31] \u001b[0m\u001b[32m────────────────────────\u001b[0m\u001b[32m─╮\u001b[0m\n", + "\u001b[32m│\u001b[0m Here are the main AI stories reported today (October 2, 2025): \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mMeta will begin using conversations with its Meta AI assistant to \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mpersonalize ads and content (WSJ / Reuters). Key points: Meta says it \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mwill add signals from AI chats to ad/content personalization (excluding \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mcertain “sensitive” topics), notify users starting Oct 7, and the change \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mtakes effect Dec 16; rollout initially excludes the U.K., EU and South \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mKorea. Sources: Wall Street Journal (Oct 2) and Reuters coverage. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mLinks: \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mhttps://www.wsj.com/tech/ai/meta-will-begin-using-ai-chatbot-conversation \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0ms-to-target-ads-291093d3 and \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mhttps://www.reuters.com/business/media-telecom/meta-use-ai-chats-personal \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mize-content-ads-december-2025-10-01/ \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mOpenAI expands its “Stargate” infrastructure partnerships in South Korea \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0m(AP / Reuters). OpenAI signed MOUs/LOIs with Samsung and SK Group to \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0msecure advanced memory supply (HBM/DRAM) and explore data‑center \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mcollaboration as part of its global Stargate build‑out. Coverage \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mhighlights meetings in Seoul between OpenAI leadership and South Korean \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mofficials/companies. Source: AP News (Oct 2) and Reuters. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mLink: https://apnews.com/article/a65fd1a21a8587c991cc30b94b1dfe89 \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mOpenAI launches Sora (short-video / generative-video app) and related \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mgenerative-video features; ChatGPT shopping/Instant Checkout pilots \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mcontinue (tech press reports). Outlets report OpenAI rolling out an \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0minvite-style short-video app (Sora) built on its video-generation models \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mand expanding ChatGPT shopping integrations (initial merchants include \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mEtsy sellers). Sources: major tech outlets reporting Oct 1–2 (examples: \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mMacRumors, CNBC/tech sites). \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mExample link: \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mhttps://www.macrumors.com/2025/09/30/openai-sora-ai-video-app/ \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mAI governance/regulatory items tied to Oct 2: EU transparency \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mconsultation on AI‑generated content and labeling closes today (feeds \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mongoing EU transparency/labeling work), and the U.S. press continues \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mdebate about private-sector AI oversight initiatives. Sources include EU \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mfilings and Politico / other regulatory coverage. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m Would you like me to: \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mPull the full articles for any of the items above and summarize them in \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mmore detail? \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mFocus on one topic (privacy implications for Meta’s change, \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mtechnical/market impact of OpenAI’s Stargate deals, how Sora works and \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mavailability)? \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mMonitor the rest of today for any breaking updates (I can check again and \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0msend any new items)? \u001b[32m│\u001b[0m\n", + "\u001b[32m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Streaming timed out after 120 seconds - returning collected messages\n" + ] + } + ], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=120,\n", + ")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/tutorials/10_async/10_temporal/010_agent_chat/manifest.yaml b/examples/tutorials/10_async/10_temporal/010_agent_chat/manifest.yaml new file mode 100644 index 000000000..1d53a7c2b --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/010_agent_chat/manifest.yaml @@ -0,0 +1,139 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../../../ # Up to tutorials level to include test_utils + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - 10_async/10_temporal/010_agent_chat + - test_utils + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: 10_async/10_temporal/010_agent_chat/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: 10_async/10_temporal/010_agent_chat/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + # Path to temporal worker file + # Examples: + # project/run_worker.py (standard) + # workers/temporal.py (custom structure) + # ../shared/worker.py (shared across projects) + worker: project/run_worker.py + + +# Agent Configuration +# ----------------- +agent: + # Type of agent - either sync or async + acp_type: async + + # Unique name for your agent + # Used for task routing and monitoring + name: at010-agent-chat + + # Description of what your agent does + # Helps with documentation and discovery + description: An AgentEx agentthat streams multiturn tool-enabled chat with tracing + + # Temporal workflow configuration + # This enables your agent to run as a Temporal workflow for long-running tasks + temporal: + enabled: true + workflows: + # Name of the workflow class + # Must match the @workflow.defn name in your workflow.py + - name: at010-agent-chat + + # Queue name for task distribution + # Used by Temporal to route tasks to your agent + # Convention: _task_queue + queue_name: 010_agent_chat_queue + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + # credentials: + # - env_var_name: OPENAI_API_KEY + # secret_name: openai-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + # env: + # - name: OPENAI_BASE_URL + # value: "https://api.openai.com/v1" + # - name: ACCOUNT_ID + # value: "your_account_id_here" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: + - name: my-registry-secret # Update with your image pull secret name + + # Global deployment settings that apply to all clusters + # These can be overridden using --override-file with custom configuration files + global: + agent: + name: "at010-agent-chat" + description: "An AgentEx agentthat streams multiturn tool-enabled chat with tracing" + + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/010_agent_chat/project/__init__.py b/examples/tutorials/10_async/10_temporal/010_agent_chat/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/10_temporal/010_agent_chat/project/acp.py b/examples/tutorials/10_async/10_temporal/010_agent_chat/project/acp.py new file mode 100644 index 000000000..744068d77 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/010_agent_chat/project/acp.py @@ -0,0 +1,30 @@ +import os + +from agentex.lib.types.fastacp import TemporalACPConfig +from agentex.lib.sdk.fastacp.fastacp import FastACP + +# Create the ACP server +acp = FastACP.create( + acp_type="async", + config=TemporalACPConfig( + # When deployed to the cluster, the Temporal address will automatically be set to the cluster address + # For local development, we set the address manually to talk to the local Temporal service set up via docker compose + type="temporal", + temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233") + ) +) + + +# Notice that we don't need to register any handlers when we use type="temporal" +# If you look at the code in agentex.sdk.fastacp.impl.temporal_acp +# You can see that these handlers are automatically registered when the ACP is created + +# @acp.on_task_create +# This will be handled by the method in your workflow that is decorated with @workflow.run + +# @acp.on_task_event_send +# This will be handled by the method in your workflow that is decorated with @workflow.signal(name=SignalName.RECEIVE_MESSAGE) + +# @acp.on_task_cancel +# This does not need to be handled by your workflow. +# It is automatically handled by the temporal client which cancels the workflow directly \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/010_agent_chat/project/run_worker.py b/examples/tutorials/10_async/10_temporal/010_agent_chat/project/run_worker.py new file mode 100644 index 000000000..31a3c98c2 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/010_agent_chat/project/run_worker.py @@ -0,0 +1,34 @@ +import asyncio + +from project.workflow import At010AgentChatWorkflow +from agentex.lib.utils.debug import setup_debug_if_enabled +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.activities import get_all_activities +from agentex.lib.core.temporal.workers.worker import AgentexWorker + +environment_variables = EnvironmentVariables.refresh() + +logger = make_logger(__name__) + + +async def main(): + # Setup debug mode if enabled + setup_debug_if_enabled() + + task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE + if task_queue_name is None: + raise ValueError("WORKFLOW_TASK_QUEUE is not set") + + # Create a worker with automatic tracing + worker = AgentexWorker( + task_queue=task_queue_name, + ) + + await worker.run( + activities=get_all_activities(), + workflow=At010AgentChatWorkflow, + ) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/010_agent_chat/project/workflow.py b/examples/tutorials/10_async/10_temporal/010_agent_chat/project/workflow.py new file mode 100644 index 000000000..3e3ac5b27 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/010_agent_chat/project/workflow.py @@ -0,0 +1,276 @@ +import os +import json +from typing import Any, Dict, List, override + +from mcp import StdioServerParameters +from agents import ModelSettings, RunContextWrapper +from dotenv import load_dotenv +from temporalio import workflow +from openai.types.shared import Reasoning + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CreateTaskParams +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.types.workflow import SignalName +from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow +from agentex.lib.core.tracing.tracing_processor_manager import ( + add_tracing_processor_config, +) +from agentex.lib.core.temporal.activities.adk.providers.openai_activities import ( + FunctionTool, +) + +environment_variables = EnvironmentVariables.refresh() +load_dotenv(dotenv_path=".env") + +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SCALE_GP_API_KEY", ""), + sgp_account_id=os.environ.get("SCALE_GP_ACCOUNT_ID", ""), + ) +) + +if not environment_variables.WORKFLOW_NAME: + raise ValueError("Environment variable WORKFLOW_NAME is not set") + +if not environment_variables.AGENT_NAME: + raise ValueError("Environment variable AGENT_NAME is not set") + +logger = make_logger(__name__) + + +class StateModel(BaseModel): + input_list: List[Dict[str, Any]] + turn_number: int + + +MCP_SERVERS = [ # No longer needed due to reasoning + # StdioServerParameters( + # command="npx", + # args=["-y", "@modelcontextprotocol/server-sequential-thinking"], + # ), + StdioServerParameters( + command="uvx", + args=["openai-websearch-mcp"], + env={"OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY", "")}, + ), +] + + +async def calculator(context: RunContextWrapper, args: str) -> str: # noqa: ARG001 + """ + Simple calculator that can perform basic arithmetic operations. + + Args: + context: The run context wrapper + args: JSON string containing the operation and operands + + Returns: + String representation of the calculation result + """ + try: + # Parse the JSON arguments + parsed_args = json.loads(args) + operation = parsed_args.get("operation") + a = parsed_args.get("a") + b = parsed_args.get("b") + + if operation is None or a is None or b is None: + return ( + "Error: Missing required parameters. " + "Please provide 'operation', 'a', and 'b'." + ) + + # Convert to numbers + try: + a = float(a) + b = float(b) + except (ValueError, TypeError): + return "Error: 'a' and 'b' must be valid numbers." + + # Perform the calculation + if operation == "add": + result = a + b + elif operation == "subtract": + result = a - b + elif operation == "multiply": + result = a * b + elif operation == "divide": + if b == 0: + return "Error: Division by zero is not allowed." + result = a / b + else: + supported_ops = "add, subtract, multiply, divide" + return ( + f"Error: Unknown operation '{operation}'. " + f"Supported operations: {supported_ops}." + ) + + # Format the result nicely + if result == int(result): + return f"The result of {a} {operation} {b} is {int(result)}" + else: + formatted = f"{result:.6f}".rstrip("0").rstrip(".") + return f"The result of {a} {operation} {b} is {formatted}" + + except json.JSONDecodeError: + return "Error: Invalid JSON format in arguments." + except Exception as e: + return f"Error: An unexpected error occurred: {str(e)}" + + +# Create the calculator tool +CALCULATOR_TOOL = FunctionTool( + name="calculator", + description=( + "Performs basic arithmetic operations (add, subtract, multiply, divide) " + "on two numbers." + ), + params_json_schema={ + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["add", "subtract", "multiply", "divide"], + "description": "The arithmetic operation to perform", + }, + "a": {"type": "number", "description": "The first number"}, + "b": {"type": "number", "description": "The second number"}, + }, + "required": ["operation", "a", "b"], + "additionalProperties": False, + }, + strict_json_schema=True, + on_invoke_tool=calculator, +) + + +@workflow.defn(name=environment_variables.WORKFLOW_NAME) +class At010AgentChatWorkflow(BaseWorkflow): + """ + Minimal async workflow template for AgentEx Temporal agents. + """ + + def __init__(self): + super().__init__(display_name=environment_variables.AGENT_NAME) + self._complete_task = False + self._state: StateModel | None = None + + @workflow.signal(name=SignalName.RECEIVE_EVENT) + @override + async def on_task_event_send(self, params: SendEventParams) -> None: + logger.info(f"Received task message instruction: {params}") + + if not params.event.content: + return + if params.event.content.type != "text": + raise ValueError(f"Expected text message, got {params.event.content.type}") + + if params.event.content.author != "user": + raise ValueError( + f"Expected user message, got {params.event.content.author}" + ) + + if self._state is None: + raise ValueError("State is not initialized") + + # Increment the turn number + self._state.turn_number += 1 + # Add the new user message to the message history + self._state.input_list.append( + {"role": "user", "content": params.event.content.content} + ) + + async with adk.tracing.span( + trace_id=params.task.id, + name=f"Turn {self._state.turn_number}", + input=self._state, + ) as span: + # Echo back the user's message so it shows up in the UI. This is not done by default so the agent developer has full control over what is shown to the user. + await adk.messages.create( + task_id=params.task.id, + trace_id=params.task.id, + content=params.event.content, + parent_span_id=span.id if span else None, + ) + + if not os.environ.get("OPENAI_API_KEY"): + await adk.messages.create( + task_id=params.task.id, + trace_id=params.task.id, + content=TextContent( + author="agent", + content=( + "Hey, sorry I'm unable to respond to your message " + "because you're running this example without an " + "OpenAI API key. Please set the OPENAI_API_KEY " + "environment variable to run this example. Do this " + "by either by adding a .env file to the project/ " + "directory or by setting the environment variable " + "in your terminal." + ), + ), + parent_span_id=span.id if span else None, + ) + + # Call an LLM to respond to the user's message + # When send_as_agent_task_message=True, returns a TaskMessage + run_result = await adk.providers.openai.run_agent_streamed_auto_send( + task_id=params.task.id, + trace_id=params.task.id, + input_list=self._state.input_list, + mcp_server_params=MCP_SERVERS, + agent_name="Tool-Enabled Assistant", + agent_instructions=( + "You are a helpful assistant that can answer questions " + "using various tools. You have access to sequential " + "thinking and web search capabilities through MCP servers, " + "as well as a calculator tool for performing basic " + "arithmetic operations. Use these tools when appropriate " + "to provide accurate and well-reasoned responses." + ), + parent_span_id=span.id if span else None, + model="gpt-5", + model_settings=ModelSettings( + # Include reasoning items in the response (IDs, summaries) + # response_include=["reasoning.encrypted_content"], + # Ask the model to include a short reasoning summary + reasoning=Reasoning(effort="medium", summary="detailed"), + ), + # tools=[CALCULATOR_TOOL], + ) + if self._state: + # Update the state with the final input list if available + final_list = getattr(run_result, "final_input_list", None) + if final_list is not None: + self._state.input_list = final_list + + # Set the span output to the state for the next turn + if span and self._state: + span.output = self._state.model_dump() + + @workflow.run + @override + async def on_task_create(self, params: CreateTaskParams) -> None: + logger.info(f"Received task create params: {params}") + + # 1. Initialize the state. You can either do this here or in the __init__ method. + # This function is triggered whenever a client creates a task for this agent. + # It is not re-triggered when a new event is sent to the task. + self._state = StateModel( + input_list=[], + turn_number=0, + ) + + # 2. Wait for the task to be completed indefinitely. If we don't do this the workflow will close as soon as this function returns. Temporal can run hundreds of millions of workflows in parallel, so you don't need to worry about too many workflows running at once. + + # Thus, if you want this agent to field events indefinitely (or for a long time) you need to wait for a condition to be met. + + await workflow.wait_condition( + lambda: self._complete_task, + timeout=None, # Set a timeout if you want to prevent the task from running indefinitely. Generally this is not needed. Temporal can run hundreds of millions of workflows in parallel and more. Only do this if you have a specific reason to do so. + ) diff --git a/examples/tutorials/10_async/10_temporal/010_agent_chat/pyproject.toml b/examples/tutorials/10_async/10_temporal/010_agent_chat/pyproject.toml new file mode 100644 index 000000000..799fa5fe1 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/010_agent_chat/pyproject.toml @@ -0,0 +1,35 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "at010-agent-chat" +version = "0.1.0" +description = "An AgentEx agentthat streams multiturn tool-enabled chat with tracing" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "debugpy>=1.8.15", + "scale-gp", + "yaspin>=3.1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/examples/tutorials/10_async/10_temporal/010_agent_chat/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/010_agent_chat/tests/test_agent.py new file mode 100644 index 000000000..f8c3e1aa8 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/010_agent_chat/tests/test_agent.py @@ -0,0 +1,277 @@ +""" +Sample tests for AgentEx Temporal agent with OpenAI Agents SDK integration. + +This test suite demonstrates how to test agents that integrate: +- OpenAI Agents SDK with streaming (via Temporal workflows) +- MCP (Model Context Protocol) servers for tool access +- Multi-turn conversations with state management +- Tool usage (calculator and web search via MCP) + +Key differences from base async (040_other_sdks): +1. Temporal Integration: Uses Temporal workflows for durable execution +2. State Management: State is managed within the workflow instance +3. No Race Conditions: Temporal ensures sequential event processing +4. Durable Execution: Workflow state survives restarts + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Ensure OPENAI_API_KEY is set in the environment +4. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: at010-agent-chat) +""" + +import os +import uuid +import asyncio + +import pytest +import pytest_asyncio +from test_utils.async_utils import ( + stream_agent_response, + send_event_and_poll_yielding, +) + +from agentex import AsyncAgentex +from agentex.types import TaskMessage, TextContent +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.agent_rpc_result import StreamTaskMessageDone, StreamTaskMessageFull +from agentex.types.text_content_param import TextContentParam + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "at010-agent-chat") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling with OpenAI Agents SDK.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll_simple_query(self, client: AsyncAgentex, agent_id: str): + """Test sending a simple event and polling for the response (no tool use).""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Wait for workflow to initialize + await asyncio.sleep(1) + + # Send a simple message that shouldn't require tool use + user_message = "Hello! Please introduce yourself briefly." + messages = [] + user_message_found = False + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message, + timeout=30, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + messages.append(message) + + if message.content and message.content.author == "user": + assert message.content == TextContent( + author="user", + content=user_message, + type="text", + ) + user_message_found = True + break + + assert user_message_found, "User message not found" + + @pytest.mark.asyncio + async def test_send_event_and_poll_with_calculator(self, client: AsyncAgentex, agent_id: str): + """Test sending an event that triggers calculator tool usage and polling for the response.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Wait for workflow to initialize + await asyncio.sleep(1) + + # Send a message that could trigger the calculator tool (though with reasoning, it may not need it) + user_message = "What is 15 multiplied by 37?" + has_final_agent_response = False + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message, + timeout=60, # Longer timeout for tool use + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + if message.content and message.content.type == "text" and message.content.author == "agent": + # Check that the answer contains 555 (15 * 37) + if "555" in message.content.content: + has_final_agent_response = True + break + + assert has_final_agent_response, "Did not receive final agent text response with correct answer" + + @pytest.mark.asyncio + async def test_multi_turn_conversation(self, client: AsyncAgentex, agent_id: str): + """ + Test message ordering by sending messages about distinct topics. + + This validates that the agent receives messages in chronological order. + If messages are reversed (newest first), the agent would respond about + the wrong topic. + """ + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Wait for workflow to initialize + await asyncio.sleep(1) + + # First turn - ask about tennis + user_message_1 = "Tell me about tennis. You must include the word 'tennis' in your response." + first_turn_found = False + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message_1, + timeout=30, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + if ( + message.content + and message.content.type == "text" + and message.content.author == "agent" + and message.content.content + ): + # Validate response is about tennis + assert "tennis" in message.content.content.lower(), "First response should be about tennis" + first_turn_found = True + break + + assert first_turn_found, "First turn response not found" + + # Wait a bit for state to update + await asyncio.sleep(2) + + # Second turn - ask about basketball (different topic) + # If message ordering is wrong, agent might respond about tennis instead + found_response = False + user_message_2 = "Now tell me about basketball. You must include the word 'basketball' in your response. Do not mention tennis." + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message_2, + timeout=30, + sleep_interval=1.0, + ): + if ( + message.content + and message.content.type == "text" + and message.content.author == "agent" + and message.content.content + ): + response_text = message.content.content.lower() + # Validate response is about basketball, not tennis + assert "basketball" in response_text, f"Second response should be about basketball, got: {response_text}" + found_response = True + break + + assert found_response, "Did not receive final agent text response with correct topic" + + +class TestStreamingEvents: + """Test streaming event sending with OpenAI Agents SDK and tool usage.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream_with_reasoning(self, client: AsyncAgentex, agent_id: str): + """Test streaming a simple response without tool usage.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Wait for workflow to initialize + await asyncio.sleep(1) + + user_message = "Tell me a very short joke about programming." + + # Check for user message and agent response + user_message_found = False + agent_response_found = False + reasoning_found = False + async def stream_messages() -> None: + nonlocal user_message_found, agent_response_found, reasoning_found + async for event in stream_agent_response( + client=client, + task_id=task.id, + timeout=90, # Increased timeout for CI environments + ): + # A turn emits several messages (user echo, reasoning, agent text), + # each ending in "full" or "done"; consume until the text reply lands. + msg_type = event.get("type") + if msg_type == "full": + parent_task_message = StreamTaskMessageFull.model_validate(event).parent_task_message + elif msg_type == "done": + parent_task_message = StreamTaskMessageDone.model_validate(event).parent_task_message + else: + continue + + if parent_task_message and parent_task_message.id: + finished_message = await client.messages.retrieve(parent_task_message.id) + content = finished_message.content + if content and content.type == "text" and content.author == "user": + user_message_found = True + elif content and content.type == "text" and content.author == "agent": + agent_response_found = True + elif content and content.type == "reasoning": + reasoning_found = True + + # Stop once both the user echo and the agent's text reply are seen. + if user_message_found and agent_response_found: + break + + stream_task = asyncio.create_task(stream_messages()) + event_content = TextContentParam(type="text", author="user", content=user_message) + await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + await stream_task + + assert user_message_found, "User message not found in stream" + assert agent_response_found, "Agent response not found in stream" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/10_temporal/020_state_machine/.dockerignore b/examples/tutorials/10_async/10_temporal/020_state_machine/.dockerignore new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/020_state_machine/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/10_temporal/020_state_machine/Dockerfile b/examples/tutorials/10_async/10_temporal/020_state_machine/Dockerfile new file mode 100644 index 000000000..59051b4b8 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/020_state_machine/Dockerfile @@ -0,0 +1,60 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install tctl (Temporal CLI) +RUN curl -L https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_arm64.tar.gz -o /tmp/tctl.tar.gz && \ + tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ + chmod +x /usr/local/bin/tctl && \ + rm /tmp/tctl.tar.gz + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 10_async/10_temporal/020_state_machine/pyproject.toml /app/020_state_machine/pyproject.toml +COPY 10_async/10_temporal/020_state_machine/README.md /app/020_state_machine/README.md + +WORKDIR /app/020_state_machine + +# Copy the project code +COPY 10_async/10_temporal/020_state_machine/project /app/020_state_machine/project + +# Copy the test files +COPY 10_async/10_temporal/020_state_machine/tests /app/020_state_machine/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies (includes pytest) +RUN uv pip install --system .[dev] pytest-asyncio httpx + +WORKDIR /app/020_state_machine + +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=at020-state-machine + +# Run the ACP server using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] diff --git a/examples/tutorials/10_async/10_temporal/020_state_machine/README.md b/examples/tutorials/10_async/10_temporal/020_state_machine/README.md new file mode 100644 index 000000000..498140006 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/020_state_machine/README.md @@ -0,0 +1,70 @@ +# [Temporal] State Machine + +Build complex multi-state workflows using state machines with Temporal. This tutorial shows a "deep research" agent that transitions through states: clarify query → wait for input → perform research → wait for follow-ups. + +## What You'll Learn +- Building state machines with Temporal sub-workflows +- Explicit state transitions and phase management +- When to use state machines vs simple workflows +- Handling complex multi-phase agent behaviors + +## Prerequisites +- Development environment set up (see [main repo README](https://github.com/scaleapi/scale-agentex)) +- Backend services running: `make dev` from repository root +- Temporal UI available at http://localhost:8233 +- Understanding of Temporal workflows (see [010_agent_chat](../010_agent_chat/)) + +## Quick Start + +```bash +cd examples/tutorials/10_async/10_temporal/020_state_machine +uv run agentex agents run --manifest manifest.yaml +``` + +**Monitor:** Open Temporal UI at http://localhost:8233 to see state transitions and sub-workflows. + +## Architecture + +The workflow uses three sub-workflows, each handling a specific state: +- `ClarifyUserQueryWorkflow` - Asks follow-up questions to understand user intent +- `WaitingForUserInputWorkflow` - Waits for user responses +- `PerformingDeepResearchWorkflow` - Executes the research with full context + +State transitions are explicit and tracked, with each sub-workflow handling its own logic. + +## Why State Machines Matter + +Complex agents often need to: +- Wait for user input at specific points +- Branch behavior based on conditions +- Orchestrate multiple steps with clear transitions +- Resume at the exact state after failures + +State machines provide this structure. Each state is a sub-workflow, and Temporal ensures transitions are durable and resumable. + +## Key Pattern + +```python +self.state_machine = DeepResearchStateMachine( + initial_state=DeepResearchState.WAITING_FOR_USER_INPUT, + states=[ + State(name=DeepResearchState.CLARIFYING, workflow=ClarifyWorkflow()), + State(name=DeepResearchState.RESEARCHING, workflow=ResearchWorkflow()), + ] +) + +await self.state_machine.transition(DeepResearchState.RESEARCHING) +``` + +This is an advanced pattern - only needed when your agent has complex, multi-phase behavior. + +## When to Use +- Multi-step processes with clear phases +- Workflows that wait for user input at specific points +- Operations with branching logic based on state +- Complex coordination patterns requiring explicit transitions + +## Why This Matters +State machines provide structure for complex agent behaviors. While simple agents can use basic workflows, complex agents benefit from explicit state management. Temporal ensures state transitions are durable and resumable, even after failures. + +**Next:** [030_custom_activities](../030_custom_activities/) - Extend workflows with custom activities diff --git a/examples/tutorials/10_async/10_temporal/020_state_machine/dev.ipynb b/examples/tutorials/10_async/10_temporal/020_state_machine/dev.ipynb new file mode 100644 index 000000000..8f9f4dff1 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/020_state_machine/dev.ipynb @@ -0,0 +1,167 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "0", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"at020-state-machine\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "# (REQUIRED) Create a new task. For Agentic agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello tell me the latest news about AI and AI startups\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "# Send a follow up event to the agent in response to the agent's follow up question\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"I want to know what viral news came up and which startups failed, got acquired, or became very successful or popular in the last 3 months\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=30, # Notice the longer timeout to give time for the agent to respond\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/tutorials/10_async/10_temporal/020_state_machine/manifest.yaml b/examples/tutorials/10_async/10_temporal/020_state_machine/manifest.yaml new file mode 100644 index 000000000..8b2bca147 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/020_state_machine/manifest.yaml @@ -0,0 +1,138 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../../../ # Up to tutorials level to include test_utils + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - 10_async/10_temporal/020_state_machine + - test_utils + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: 10_async/10_temporal/020_state_machine/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: 10_async/10_temporal/020_state_machine/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + # Path to temporal worker file + # Examples: + # project/run_worker.py (standard) + # workers/temporal.py (custom structure) + # ../shared/worker.py (shared across projects) + worker: project/run_worker.py + + +# Agent Configuration +# ----------------- +agent: + # Type of agent - either sync or async + acp_type: async + + # Unique name for your agent + # Used for task routing and monitoring + name: at020-state-machine + + # Description of what your agent does + # Helps with documentation and discovery + description: An AgentEx agentthat demonstrates how to uose state machines to manage complex async workflows + + # Temporal workflow configuration + # This enables your agent to run as a Temporal workflow for long-running tasks + temporal: + enabled: true + workflows: + # Name of the workflow class + # Must match the @workflow.defn name in your workflow.py + - name: at020-state-machine + + # Queue name for task distribution + # Used by Temporal to route tasks to your agent + # Convention: _task_queue + queue_name: 020_state_machine_queue + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + # credentials: + # - env_var_name: OPENAI_API_KEY + # secret_name: openai-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + # env: + # OPENAI_API_KEY: "" + # OPENAI_BASE_URL: "" + # OPENAI_ORG_ID: "" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: + - name: my-registry-secret # Update with your image pull secret name + + # Global deployment settings that apply to all clusters + # These can be overridden using --override-file with custom configuration files + global: + agent: + name: "at020-state-machine" + description: "An AgentEx agentthat demonstrates how to uose state machines to manage complex async workflows" + + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/020_state_machine/project/__init__.py b/examples/tutorials/10_async/10_temporal/020_state_machine/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/10_temporal/020_state_machine/project/acp.py b/examples/tutorials/10_async/10_temporal/020_state_machine/project/acp.py new file mode 100644 index 000000000..744068d77 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/020_state_machine/project/acp.py @@ -0,0 +1,30 @@ +import os + +from agentex.lib.types.fastacp import TemporalACPConfig +from agentex.lib.sdk.fastacp.fastacp import FastACP + +# Create the ACP server +acp = FastACP.create( + acp_type="async", + config=TemporalACPConfig( + # When deployed to the cluster, the Temporal address will automatically be set to the cluster address + # For local development, we set the address manually to talk to the local Temporal service set up via docker compose + type="temporal", + temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233") + ) +) + + +# Notice that we don't need to register any handlers when we use type="temporal" +# If you look at the code in agentex.sdk.fastacp.impl.temporal_acp +# You can see that these handlers are automatically registered when the ACP is created + +# @acp.on_task_create +# This will be handled by the method in your workflow that is decorated with @workflow.run + +# @acp.on_task_event_send +# This will be handled by the method in your workflow that is decorated with @workflow.signal(name=SignalName.RECEIVE_MESSAGE) + +# @acp.on_task_cancel +# This does not need to be handled by your workflow. +# It is automatically handled by the temporal client which cancels the workflow directly \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/020_state_machine/project/run_worker.py b/examples/tutorials/10_async/10_temporal/020_state_machine/project/run_worker.py new file mode 100644 index 000000000..2f0059d51 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/020_state_machine/project/run_worker.py @@ -0,0 +1,34 @@ +import asyncio + +from project.workflow import At020StateMachineWorkflow +from agentex.lib.utils.debug import setup_debug_if_enabled +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.activities import get_all_activities +from agentex.lib.core.temporal.workers.worker import AgentexWorker + +environment_variables = EnvironmentVariables.refresh() + +logger = make_logger(__name__) + + +async def main(): + # Setup debug mode if enabled + setup_debug_if_enabled() + + task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE + if task_queue_name is None: + raise ValueError("WORKFLOW_TASK_QUEUE is not set") + + # Create a worker with automatic tracing + worker = AgentexWorker( + task_queue=task_queue_name, + ) + + await worker.run( + activities=get_all_activities(), + workflow=At020StateMachineWorkflow, + ) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/020_state_machine/project/state_machines/deep_research.py b/examples/tutorials/10_async/10_temporal/020_state_machine/project/state_machines/deep_research.py new file mode 100644 index 000000000..d1c4df00a --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/020_state_machine/project/state_machines/deep_research.py @@ -0,0 +1,41 @@ +from enum import Enum +from typing import Dict, List, Optional, override + +from pydantic import BaseModel + +from agentex.types.span import Span +from agentex.lib.sdk.state_machine import StateMachine + + +class DeepResearchState(str, Enum): + """States for the deep research workflow.""" + CLARIFYING_USER_QUERY = "clarifying_user_query" + PERFORMING_DEEP_RESEARCH = "performing_deep_research" + WAITING_FOR_USER_INPUT = "waiting_for_user_input" + COMPLETED = "completed" + FAILED = "failed" + + +class DeepResearchData(BaseModel): + """Data model for the deep research state machine - everything is one continuous research report.""" + task_id: Optional[str] = None + current_span: Optional[Span] = None + current_turn: int = 1 + + # Research report data + user_query: str = "" + follow_up_questions: List[str] = [] + follow_up_responses: List[str] = [] + n_follow_up_questions_to_ask: int = 1 + agent_input_list: List[Dict[str, str]] = [] + research_report: str = "" + research_iteration: int = 0 + + +class DeepResearchStateMachine(StateMachine[DeepResearchData]): + """State machine for the deep research workflow.""" + + @override + async def terminal_condition(self) -> bool: + """Check if the state machine has reached a terminal state.""" + return self.get_current_state() == DeepResearchState.COMPLETED diff --git a/examples/tutorials/10_async/10_temporal/020_state_machine/project/workflow.py b/examples/tutorials/10_async/10_temporal/020_state_machine/project/workflow.py new file mode 100644 index 000000000..aa88de687 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/020_state_machine/project/workflow.py @@ -0,0 +1,154 @@ +import asyncio +from typing import override + +from temporalio import workflow + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CreateTaskParams +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.sdk.state_machine.state import State +from project.state_machines.deep_research import DeepResearchData, DeepResearchState, DeepResearchStateMachine +from agentex.lib.core.temporal.types.workflow import SignalName +from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow +from project.workflows.deep_research.clarify_user_query import ClarifyUserQueryWorkflow +from project.workflows.deep_research.waiting_for_user_input import WaitingForUserInputWorkflow +from project.workflows.deep_research.performing_deep_research import PerformingDeepResearchWorkflow + +environment_variables = EnvironmentVariables.refresh() + +if environment_variables.WORKFLOW_NAME is None: + raise ValueError("Environment variable WORKFLOW_NAME is not set") + +if environment_variables.AGENT_NAME is None: + raise ValueError("Environment variable AGENT_NAME is not set") + + +logger = make_logger(__name__) + +@workflow.defn(name=environment_variables.WORKFLOW_NAME) +class At020StateMachineWorkflow(BaseWorkflow): + """ + Minimal async workflow template for AgentEx Temporal agents. + """ + def __init__(self): + super().__init__(display_name=environment_variables.AGENT_NAME) + self.state_machine = DeepResearchStateMachine( + initial_state=DeepResearchState.WAITING_FOR_USER_INPUT, + states=[ + State(name=DeepResearchState.CLARIFYING_USER_QUERY, workflow=ClarifyUserQueryWorkflow()), + State(name=DeepResearchState.WAITING_FOR_USER_INPUT, workflow=WaitingForUserInputWorkflow()), + State(name=DeepResearchState.PERFORMING_DEEP_RESEARCH, workflow=PerformingDeepResearchWorkflow()), + ], + state_machine_data=DeepResearchData(), + trace_transitions=True + ) + + @override + @workflow.signal(name=SignalName.RECEIVE_EVENT) + async def on_task_event_send(self, params: SendEventParams) -> None: + deep_research_data = self.state_machine.get_state_machine_data() + task = params.task + message = params.event.content + + # If waiting for user input, handle the message + if self.state_machine.get_current_state() == DeepResearchState.WAITING_FOR_USER_INPUT: + if not deep_research_data.user_query: + # First time - initialize research data + deep_research_data.user_query = message.content + deep_research_data.current_turn += 1 + + if not deep_research_data.current_span: + deep_research_data.current_span = await adk.tracing.start_span( + trace_id=task.id, + name=f"Turn {deep_research_data.current_turn}", + input={ + "task_id": task.id, + "message": message.content, + } + ) + else: + # Check if we're in the middle of follow-up questions + if deep_research_data.n_follow_up_questions_to_ask > 0: + # User is responding to a follow-up question + # Safely extract content from message + content_text = "" + if hasattr(message, 'content'): + content_val = getattr(message, 'content', '') + if isinstance(content_val, str): + content_text = content_val + deep_research_data.follow_up_responses.append(content_text) + + # Add the Q&A to the agent input list as context + if deep_research_data.follow_up_questions: + last_question = deep_research_data.follow_up_questions[-1] + qa_context = f"Q: {last_question}\nA: {message.content}" + deep_research_data.agent_input_list.append({ + "role": "user", + "content": qa_context + }) + else: + # User is asking a new follow-up question about the same research topic + # Add the user's follow-up question to the agent input list as context + if deep_research_data.agent_input_list: + # Add user's follow-up question to the conversation + deep_research_data.agent_input_list.append({ + "role": "user", + "content": f"Additional question: {message.content}" + }) + else: + # Initialize agent input list with the follow-up question + deep_research_data.agent_input_list = [{ + "role": "user", + "content": f"Original query: {deep_research_data.user_query}\nAdditional question: {message.content}" + }] + + deep_research_data.current_turn += 1 + + if not deep_research_data.current_span: + deep_research_data.current_span = await adk.tracing.start_span( + trace_id=task.id, + name=f"Turn {deep_research_data.current_turn}", + input={ + "task_id": task.id, + "message": message.content, + } + ) + + # Always go to clarifying user query to ask follow-up questions + # This ensures we gather more context before doing deep research + await self.state_machine.transition(DeepResearchState.CLARIFYING_USER_QUERY) + + # Echo back the user's message + # Safely extract content from message for display + message_content = "" + if hasattr(message, 'content'): + content_val = getattr(message, 'content', '') + if isinstance(content_val, str): + message_content = content_val + + await adk.messages.create( + task_id=task.id, + content=TextContent( + author="user", + content=message_content, + ), + trace_id=task.id, + parent_span_id=deep_research_data.current_span.id if deep_research_data.current_span else None, + ) + + @override + @workflow.run + async def on_task_create(self, params: CreateTaskParams) -> None: + task = params.task + + self.state_machine.set_task_id(task.id) + deep_research_data = self.state_machine.get_state_machine_data() + deep_research_data.task_id = task.id + + try: + await self.state_machine.run() + except asyncio.CancelledError as error: + logger.warning(f"Task canceled by user: {task.id}") + raise error \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/020_state_machine/project/workflows/deep_research/clarify_user_query.py b/examples/tutorials/10_async/10_temporal/020_state_machine/project/workflows/deep_research/clarify_user_query.py new file mode 100644 index 000000000..c8e756b20 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/020_state_machine/project/workflows/deep_research/clarify_user_query.py @@ -0,0 +1,89 @@ +from typing import Optional, override + +from project.state_machines.deep_research import DeepResearchData, DeepResearchState + +from agentex.lib import adk +from agentex.lib.utils.logging import make_logger +from agentex.lib.types.llm_messages import LLMConfig, UserMessage, SystemMessage +from agentex.lib.sdk.state_machine.state_machine import StateMachine +from agentex.lib.sdk.state_machine.state_workflow import StateWorkflow + +logger = make_logger(__name__) + + +FOLLOW_UP_QUESTION_TEMPLATE = """ +Given the following research query from the user, ask a follow up question to clarify the research direction. + +{{ user_query }} + + +{% if follow_up_questions|length > 0 %} +The following are follow up questions and answers that have been asked/given so far: +{% for q in follow_up_questions %} +Q: {{ follow_up_questions[loop.index0] }} +A: {{ follow_up_responses[loop.index0] }} +{% endfor %} +{% endif %} + +Return the follow up question and nothing else. +Follow up question: +""" + +class ClarifyUserQueryWorkflow(StateWorkflow): + """Workflow for engaging in follow-up questions.""" + + @override + async def execute(self, state_machine: StateMachine, state_machine_data: Optional[DeepResearchData] = None) -> str: + """Execute the workflow.""" + if state_machine_data is None: + return DeepResearchState.PERFORMING_DEEP_RESEARCH + + if state_machine_data.n_follow_up_questions_to_ask == 0: + # No more follow-up questions to ask, proceed to deep research + return DeepResearchState.PERFORMING_DEEP_RESEARCH + + # Generate follow-up question prompt + if state_machine_data.task_id and state_machine_data.current_span: + follow_up_question_generation_prompt = await adk.utils.templating.render_jinja( + trace_id=state_machine_data.task_id, + template=FOLLOW_UP_QUESTION_TEMPLATE, + variables={ + "user_query": state_machine_data.user_query, + "follow_up_questions": state_machine_data.follow_up_questions, + "follow_up_responses": state_machine_data.follow_up_responses + }, + parent_span_id=state_machine_data.current_span.id, + ) + + task_message = await adk.providers.litellm.chat_completion_stream_auto_send( + task_id=state_machine_data.task_id, + llm_config=LLMConfig( + model="gpt-4o-mini", + messages=[ + SystemMessage(content="You are assistant that follows exact instructions without outputting any other text except your response to the user's exact request."), + UserMessage(content=follow_up_question_generation_prompt), + ], + stream=True, + ), + trace_id=state_machine_data.task_id, + parent_span_id=state_machine_data.current_span.id, + ) + # Safely extract content from task message + follow_up_question = "" + if task_message.content and hasattr(task_message.content, 'content'): + content_val = getattr(task_message.content, 'content', '') + if isinstance(content_val, str): + follow_up_question = content_val + + # Update with follow-up question + state_machine_data.follow_up_questions.append(follow_up_question) + + # Decrement the number of follow-up questions to ask + state_machine_data.n_follow_up_questions_to_ask -= 1 + + logger.info(f"Current research data: {state_machine_data}") + + # Always go back to waiting for user input to get their response + return DeepResearchState.WAITING_FOR_USER_INPUT + else: + return DeepResearchState.PERFORMING_DEEP_RESEARCH \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/020_state_machine/project/workflows/deep_research/performing_deep_research.py b/examples/tutorials/10_async/10_temporal/020_state_machine/project/workflows/deep_research/performing_deep_research.py new file mode 100644 index 000000000..7bcb5a6d5 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/020_state_machine/project/workflows/deep_research/performing_deep_research.py @@ -0,0 +1,162 @@ +import os +from typing import Optional, override +from datetime import datetime + +from mcp import StdioServerParameters +from project.state_machines.deep_research import DeepResearchData, DeepResearchState + +from agentex.lib import adk +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.sdk.state_machine.state_machine import StateMachine +from agentex.lib.sdk.state_machine.state_workflow import StateWorkflow + +logger = make_logger(__name__) + +# These reference MCP servers still import the mcp 1.x API (``McpError``), which +# mcp 2.0.0 renamed to ``MCPError``. uvx gives each server its own isolated env and +# resolves ``mcp`` unpinned there, ignoring the version this project pins, so without +# this constraint every server dies at import and the agent silently makes zero tool +# calls. Drop the pin once the servers support mcp 2.x. +_MCP_PIN = ["--with", "mcp<2"] + +MCP_SERVERS = [ + StdioServerParameters( + command="uvx", + args=[*_MCP_PIN, "mcp-server-time", "--local-timezone", "America/Los_Angeles"], + ), + StdioServerParameters( + command="uvx", + args=[*_MCP_PIN, "openai-websearch-mcp"], + env={ + "OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY", "") + } + ), + StdioServerParameters( + command="uvx", + args=[*_MCP_PIN, "mcp-server-fetch"], + ), +] + +class PerformingDeepResearchWorkflow(StateWorkflow): + """Workflow for performing deep research.""" + + @override + async def execute(self, state_machine: StateMachine, state_machine_data: Optional[DeepResearchData] = None) -> str: + """Execute the workflow.""" + if state_machine_data is None: + return DeepResearchState.CLARIFYING_USER_QUERY + + if not state_machine_data.user_query: + return DeepResearchState.CLARIFYING_USER_QUERY + + # Construct initial research instruction + follow_up_qa_str = "" + for q, r in zip(state_machine_data.follow_up_questions, state_machine_data.follow_up_responses): + follow_up_qa_str += f"Q: {q}\nA: {r}\n" + + # Increment research iteration + state_machine_data.research_iteration += 1 + + # Create research instruction based on whether this is the first iteration or a continuation + if state_machine_data.research_iteration == 1: + initial_instruction = ( + f"Initial Query: {state_machine_data.user_query}\n" + f"Follow-up Q&A:\n{follow_up_qa_str}" + ) + + # Notify user that deep research is starting + if state_machine_data.task_id and state_machine_data.current_span: + await adk.messages.create( + task_id=state_machine_data.task_id, + content=TextContent( + author="agent", + content="Starting deep research process based on your query and follow-up responses...", + ), + trace_id=state_machine_data.task_id, + parent_span_id=state_machine_data.current_span.id, + ) + else: + initial_instruction = ( + f"Initial Query: {state_machine_data.user_query}\n" + f"Follow-up Q&A:\n{follow_up_qa_str}\n" + f"Current Research Report (Iteration {state_machine_data.research_iteration - 1}):\n{state_machine_data.research_report}" + ) + + # Notify user that research is continuing + if state_machine_data.task_id and state_machine_data.current_span: + await adk.messages.create( + task_id=state_machine_data.task_id, + content=TextContent( + author="agent", + content=f"Continuing deep research (iteration {state_machine_data.research_iteration}) to expand and refine the research report...", + ), + trace_id=state_machine_data.task_id, + parent_span_id=state_machine_data.current_span.id, + ) + + # Fetch the current time in human readable format + current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S %Z") + + # Deep Research Loop + if not state_machine_data.agent_input_list: + state_machine_data.agent_input_list = [ + {"role": "user", "content": f""" +Here is my initial query, clarified with the following follow-up questions and answers: +{initial_instruction} + +You should now perform a depth search to get a more detailed understanding of the most promising areas. + +The current time is {current_time}. +"""} + ] + + if state_machine_data.task_id and state_machine_data.current_span: + result = await adk.providers.openai.run_agent_streamed_auto_send( + task_id=state_machine_data.task_id, + trace_id=state_machine_data.task_id, + input_list=state_machine_data.agent_input_list, + mcp_server_params=MCP_SERVERS, + agent_name="Deep Research Agent", + agent_instructions=f"""You are a deep research expert that can search the web for information. +You should use the tools you have access to to write an extensive report on the users query. + +You must use the web search tool at least 10 times before writing your report. +Use the fetch tool to open links you want to read. +Then use web search again repeatedly to dig deeper into the most promising areas of search results. + +Be very targeted with your searches, make sure all search queries are relevant to either the initial user query or dig deeper into the most promising areas of search results. All searches should tie back to the original query though. Remember your searches are stateless, so there is no context shared between search queries. + +Always cite your sources in the format [source](link). Do not hallucinate. Your latent information is not likely to be up to date. + +If this is a continuation of previous research (iteration {state_machine_data.research_iteration}), focus on: +1. Expanding areas that need more detail +2. Adding new relevant information discovered +3. Removing outdated or incorrect information +4. Improving the overall structure and clarity of the report +""", + parent_span_id=state_machine_data.current_span.id, + mcp_timeout_seconds=180, + ) + + # Update state with conversation history + state_machine_data.agent_input_list = result.final_input_list + + # Extract the research report from the last assistant message + if result.final_input_list: + for message in reversed(result.final_input_list): + if message.get("role") == "assistant": + state_machine_data.research_report = message.get("content", "") + break + + # Keep the research data active for future iterations + + if state_machine_data.task_id and state_machine_data.current_span: + await adk.tracing.end_span( + trace_id=state_machine_data.task_id, + span=state_machine_data.current_span, + ) + state_machine_data.current_span = None + + # Transition to waiting for user input state + return DeepResearchState.WAITING_FOR_USER_INPUT \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/020_state_machine/project/workflows/deep_research/waiting_for_user_input.py b/examples/tutorials/10_async/10_temporal/020_state_machine/project/workflows/deep_research/waiting_for_user_input.py new file mode 100644 index 000000000..842c5c423 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/020_state_machine/project/workflows/deep_research/waiting_for_user_input.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from typing import override + +from temporalio import workflow +from project.state_machines.deep_research import DeepResearchData, DeepResearchState + +from agentex.lib.utils.logging import make_logger +from agentex.lib.sdk.state_machine import StateMachine, StateWorkflow + +logger = make_logger(__name__) + +class WaitingForUserInputWorkflow(StateWorkflow): + @override + async def execute(self, state_machine: StateMachine, state_machine_data: DeepResearchData | None = None) -> str: + logger.info("ActorWaitingForUserInputWorkflow: waiting for user input...") + def condition(): + current_state = state_machine.get_current_state() + return current_state != DeepResearchState.WAITING_FOR_USER_INPUT + await workflow.wait_condition(condition) + return state_machine.get_current_state() \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/020_state_machine/pyproject.toml b/examples/tutorials/10_async/10_temporal/020_state_machine/pyproject.toml new file mode 100644 index 000000000..e018b3229 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/020_state_machine/pyproject.toml @@ -0,0 +1,34 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "at020-state-machine" +version = "0.1.0" +description = "An AgentEx agentthat demonstrates how to uose state machines to manage complex async workflows" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", + "debugpy>=1.8.15", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/020_state_machine/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/020_state_machine/tests/test_agent.py new file mode 100644 index 000000000..fac8605aa --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/020_state_machine/tests/test_agent.py @@ -0,0 +1,193 @@ +""" +Sample tests for AgentEx Temporal State Machine agent. + +This test suite demonstrates how to test a state machine-based agent that: +- Uses state transitions (WAITING → CLARIFYING → PERFORMING_DEEP_RESEARCH) +- Asks follow-up questions before performing research +- Performs deep web research using MCP servers +- Handles multi-turn conversations with context preservation + +Key features tested: +1. State Machine Flow: Agent transitions through multiple states +2. Follow-up Questions: Agent clarifies queries before research +3. Deep Research: Agent performs extensive web research +4. Multi-turn Support: User can ask follow-ups about research + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Ensure OPENAI_API_KEY is set in the environment +4. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: at020-state-machine) +""" + +import os +import uuid +import asyncio + +import pytest +import pytest_asyncio +from test_utils.async_utils import ( + stream_task_messages, + send_event_and_poll_yielding, +) + +from agentex import AsyncAgentex +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam +from agentex.types.tool_request_content import ToolRequestContent + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "at020-state-machine") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling with state machine workflow.""" + @pytest.mark.asyncio + async def test_send_event_and_poll_simple_query(self, client: AsyncAgentex, agent_id: str): + """Test sending a simple event and polling for the response (no tool use).""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Wait for workflow to initialize + await asyncio.sleep(1) + + # Send a simple message that shouldn't require tool use + user_message = "Hello! Please tell me the latest news about AI and AI startups." + messages = [] + found_agent_message = False + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message, + timeout=30, + sleep_interval=1.0, + ): + messages.append(message) + ## we should expect to get a question from the agent + if message.content.type == "text" and message.content.author == "agent": + found_agent_message = True + break + + assert found_agent_message, "Did not find an agent message" + + # now we want to clarity that message + await asyncio.sleep(2) + next_user_message = "I want to know what viral news came up and which startups failed, got acquired, or became very successful or popular in the last 3 months" + starting_deep_research_message = False + uses_tool_requests = False + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=next_user_message, + timeout=30, + sleep_interval=1.0, + ): + if message.content.type == "text" and message.content.author == "agent": + if "starting deep research" in message.content.content.lower(): + starting_deep_research_message = True + if isinstance(message.content, ToolRequestContent): + uses_tool_requests = True + break + + assert starting_deep_research_message, "Did not start deep research" + assert uses_tool_requests, "Did not use tool requests" + +class TestStreamingEvents: + """Test streaming event sending with state machine workflow.""" + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_id: str): + """Test sending an event and streaming the response.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + found_agent_message = False + user_message = "Hello! Please tell me the latest news about AI and AI startups." + async def stream_first_turn() -> None: + nonlocal found_agent_message + async for message in stream_task_messages( + client=client, + task_id=task.id, + timeout=30, + ): + if message.content.type == "text" and message.content.author == "agent": + found_agent_message = True + break + + stream_task = asyncio.create_task(stream_first_turn()) + await client.agents.send_event( + agent_id=agent_id, + params={"task_id": task.id, "content": TextContentParam(type="text", author="user", content=user_message)}, + ) + await stream_task + assert found_agent_message, "Did not find an agent message" + + await asyncio.sleep(2) + starting_deep_research_message = False + uses_tool_requests = False + next_user_message = "I want to know what viral news came up and which startups failed, got acquired, or became very successful or popular in the last 3 months" + async def stream_second_turn() -> None: + nonlocal starting_deep_research_message, uses_tool_requests + async for message in stream_task_messages( + client=client, + task_id=task.id, + timeout=30, + ): + # can you add the same checks as we did in the non-streaming events test? + if message.content.type == "text" and message.content.author == "agent": + if "starting deep research" in message.content.content.lower(): + starting_deep_research_message = True + if isinstance(message.content, ToolRequestContent): + uses_tool_requests = True + break + + stream_task = asyncio.create_task(stream_second_turn()) + await client.agents.send_event( + agent_id=agent_id, + params={ + "task_id": task.id, + "content": TextContentParam(type="text", author="user", content=next_user_message), + }, + ) + await stream_task + + assert starting_deep_research_message, "Did not start deep research" + assert uses_tool_requests, "Did not use tool requests" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/10_temporal/030_custom_activities/.dockerignore b/examples/tutorials/10_async/10_temporal/030_custom_activities/.dockerignore new file mode 100644 index 000000000..c4f7a8b4b --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/030_custom_activities/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/030_custom_activities/Dockerfile b/examples/tutorials/10_async/10_temporal/030_custom_activities/Dockerfile new file mode 100644 index 000000000..752ad8e93 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/030_custom_activities/Dockerfile @@ -0,0 +1,59 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install tctl (Temporal CLI) +RUN curl -L https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_arm64.tar.gz -o /tmp/tctl.tar.gz && \ + tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ + chmod +x /usr/local/bin/tctl && \ + rm /tmp/tctl.tar.gz + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 10_async/10_temporal/030_custom_activities/pyproject.toml /app/030_custom_activities/pyproject.toml +COPY 10_async/10_temporal/030_custom_activities/README.md /app/030_custom_activities/README.md + +WORKDIR /app/030_custom_activities + +# Copy the project code +COPY 10_async/10_temporal/030_custom_activities/project /app/030_custom_activities/project + +# Copy the test files +COPY 10_async/10_temporal/030_custom_activities/tests /app/030_custom_activities/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies (includes pytest) +RUN uv pip install --system .[dev] pytest-asyncio httpx + +# Set environment variables +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=at030-custom-activities + +# Run the ACP server using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/030_custom_activities/README.md b/examples/tutorials/10_async/10_temporal/030_custom_activities/README.md new file mode 100644 index 000000000..28a08c217 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/030_custom_activities/README.md @@ -0,0 +1,106 @@ +# [Temporal] Custom Activities + +Learn how to extend Temporal workflows with custom activities for external operations like API calls, database queries, or complex computations. + +## What You'll Learn +- How to define custom Temporal activities +- When to use activities vs inline workflow code +- Activity retry and timeout configuration +- Integrating external services into workflows + +## Prerequisites +- Development environment set up (see [main repo README](https://github.com/scaleapi/scale-agentex)) +- Backend services running: `make dev` from repository root +- Temporal UI available at http://localhost:8233 +- Understanding of basic Temporal workflows (see [000_hello_acp](../000_hello_acp/)) + +## Quick Start + +**Terminal 1 - Start Worker:** +```bash +cd examples/tutorials/10_async/10_temporal/030_custom_activities +uv run python project/run_worker.py +``` + +**Terminal 2 - Run Agent:** +```bash +uv run agentex agents run --manifest manifest.yaml +``` + +**Terminal 3 - Test via Notebook:** +```bash +jupyter notebook dev.ipynb +``` + +## Key Concepts + +### Activities vs Workflow Code + +**Use activities for:** +- External API calls +- Database operations +- File I/O or network operations +- Non-deterministic operations (random, time, external state) + +**Use workflow code for:** +- Orchestration logic +- State management +- Decision making based on activity results + +### Defining a Custom Activity + +```python +# In project/activities.py +from temporalio import activity + +@activity.defn +async def call_external_api(endpoint: str, data: dict) -> dict: + """Activities can perform non-deterministic operations.""" + import httpx + async with httpx.AsyncClient() as client: + response = await client.post(endpoint, json=data) + return response.json() +``` + +### Using Activities in Workflows + +```python +# In project/workflow.py +from temporalio import workflow + +@workflow.defn +class MyWorkflow(BaseWorkflow): + @workflow.run + async def run(self, input: dict): + # Activities are executed with retry and timeout policies + result = await workflow.execute_activity( + call_external_api, + args=["https://api.example.com", input], + start_to_close_timeout=timedelta(seconds=30), + retry_policy=RetryPolicy(maximum_attempts=3) + ) + return result +``` + +## Try It + +1. Modify `project/activities.py` to add a new activity +2. Update `project/workflow.py` to call your activity +3. Register the activity in `project/run_worker.py` +4. Restart the worker and test via the notebook +5. Check Temporal UI at http://localhost:8233 to see activity execution and retries + +## When to Use +- Integrating external services (OpenAI, databases, APIs) +- Operations that may fail and need automatic retries +- Long-running computations that should be checkpointed +- Separating business logic from orchestration + +## Why This Matters +Activities are Temporal's way of handling the real world's messiness: network failures, API rate limits, and transient errors. They provide automatic retries, timeouts, and observability for operations that would otherwise require extensive error handling code. + +--- + +**For detailed setup instructions, see [TEMPLATE_GUIDE.md](./TEMPLATE_GUIDE.md)** + +**Next:** [050_agent_chat_guardrails](../050_agent_chat_guardrails/) - Add safety and validation to your workflows diff --git a/examples/tutorials/10_async/10_temporal/030_custom_activities/dev.ipynb b/examples/tutorials/10_async/10_temporal/030_custom_activities/dev.ipynb new file mode 100644 index 000000000..b08063696 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/030_custom_activities/dev.ipynb @@ -0,0 +1,228 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 38, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": 39, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"at030-custom-activities\"" + ] + }, + { + "cell_type": "code", + "execution_count": 40, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Task(id='0927b469-5aed-4804-aa53-79a6af70f76f', created_at=datetime.datetime(2025, 8, 14, 5, 54, 44, 734709, tzinfo=TzInfo(UTC)), name='26d1fa25-task', status='RUNNING', status_reason='Task created, forwarding to ACP server', updated_at=datetime.datetime(2025, 8, 14, 5, 54, 44, 734709, tzinfo=TzInfo(UTC)))\n" + ] + } + ], + "source": [ + "# (REQUIRED) Create a new task. For Agentic agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": 41, + "id": "b03b0d37", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Event(id='5f402c77-ed37-4f56-b161-50f3ceb87685', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=247, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 0', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 54, 52, 106969, tzinfo=TzInfo(UTC)))\n", + "Event(id='f71c4b80-6d93-4167-bdf9-d2f407bde759', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=248, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 1', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 54, 53, 141757, tzinfo=TzInfo(UTC)))\n", + "Event(id='797aca62-6260-4c4d-a89b-43e33ecfdc30', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=249, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 2', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 54, 54, 200724, tzinfo=TzInfo(UTC)))\n", + "Event(id='f207d685-789c-4538-b1c2-03c5a93028d8', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=250, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 3', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 54, 55, 264489, tzinfo=TzInfo(UTC)))\n", + "Event(id='9685fc87-d38b-4d4a-94e2-ac148b3b060f', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=251, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 4', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 54, 56, 352169, tzinfo=TzInfo(UTC)))\n", + "Event(id='f2a134c7-5dac-4643-acee-259dc076c953', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=252, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 5', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 54, 57, 419635, tzinfo=TzInfo(UTC)))\n", + "Event(id='68dcfc1f-71b1-4876-907a-4282d12b1c54', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=253, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 6', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 54, 58, 476724, tzinfo=TzInfo(UTC)))\n", + "Event(id='f8644089-1b20-49ca-b8bd-706feeeed096', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=254, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 7', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 54, 59, 527430, tzinfo=TzInfo(UTC)))\n", + "Event(id='11300500-4843-4c15-bfa9-7de6e1f29017', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=255, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 8', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 55, 0, 584924, tzinfo=TzInfo(UTC)))\n", + "Event(id='27d8f4f3-7e25-4d17-aaa8-a4c9d39bfcda', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=256, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 9', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 55, 1, 637711, tzinfo=TzInfo(UTC)))\n", + "Event(id='7b964f5c-504c-43c5-a1ea-84f96ce1a696', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=257, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 10', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 55, 2, 693531, tzinfo=TzInfo(UTC)))\n", + "Event(id='dc70e5c3-75d7-4b76-9a1b-171f755440c4', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=258, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 11', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 55, 3, 724789, tzinfo=TzInfo(UTC)))\n", + "Event(id='adb547d2-5ac8-4c45-86f0-85703434568a', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=259, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 12', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 55, 4, 773604, tzinfo=TzInfo(UTC)))\n", + "Event(id='575b7dbc-d884-42cf-b67b-47f752862b43', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=260, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 13', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 55, 5, 825423, tzinfo=TzInfo(UTC)))\n", + "Event(id='de7f328a-03f2-44a3-9ee0-111ba41b48c4', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=261, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 14', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 55, 6, 873700, tzinfo=TzInfo(UTC)))\n", + "Event(id='639fc12d-867a-4739-a5d6-71e3db5cb3db', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=262, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 15', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 55, 7, 920757, tzinfo=TzInfo(UTC)))\n", + "Event(id='d7c93e13-8d49-4ba9-88c9-642176bea019', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=263, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 16', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 55, 8, 952535, tzinfo=TzInfo(UTC)))\n", + "Event(id='177047ce-bd57-47e4-b1ab-ca6a2c8333fe', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=264, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 17', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 55, 9, 986904, tzinfo=TzInfo(UTC)))\n", + "Event(id='f081c0c8-f9c4-4cb8-90d3-aa1fb662e065', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=265, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 18', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 55, 11, 34936, tzinfo=TzInfo(UTC)))\n", + "Event(id='45c49d20-de58-4f29-be75-b4169f30ff5c', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=266, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 19', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 55, 12, 64206, tzinfo=TzInfo(UTC)))\n", + "Event(id='23ef7bb3-f1a7-41cd-ba33-3513724ecb9a', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=267, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 20', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 55, 13, 117837, tzinfo=TzInfo(UTC)))\n", + "Event(id='f6d5dbda-ca45-42a9-a15e-3726f15a42ce', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=268, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 21', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 55, 14, 172609, tzinfo=TzInfo(UTC)))\n", + "Event(id='f8c0e226-c950-4173-9b63-00530d5a5a62', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=269, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 22', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 55, 15, 242257, tzinfo=TzInfo(UTC)))\n", + "Event(id='0cc1591b-0e5f-4044-9b08-8a7f8977f35b', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=270, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 23', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 55, 16, 287209, tzinfo=TzInfo(UTC)))\n", + "Event(id='562ad0d5-695e-46ca-a3f1-c918f44d8dce', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=271, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 24', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 55, 17, 330180, tzinfo=TzInfo(UTC)))\n", + "Event(id='c35b5515-a51c-486b-a46a-47cbd31b7b98', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=272, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 25', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 55, 18, 373297, tzinfo=TzInfo(UTC)))\n", + "Event(id='83b99f74-27d0-443f-8929-ce6f3fea1868', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=273, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 26', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 55, 19, 446678, tzinfo=TzInfo(UTC)))\n", + "Event(id='291784dd-0722-47d2-913e-27d2c328d972', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=274, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 27', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 55, 20, 508485, tzinfo=TzInfo(UTC)))\n", + "Event(id='36fff710-3617-4a58-827f-baee95ef0d6d', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=275, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 28', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 55, 21, 603524, tzinfo=TzInfo(UTC)))\n", + "Event(id='40058df6-7226-47c3-9e11-eb6aeb304ff2', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=276, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=TextContent(author='user', content='Hello what can you do? EVENT NUM: 29', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 14, 5, 55, 22, 651817, tzinfo=TzInfo(UTC)))\n" + ] + } + ], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "num_events = 30\n", + "for i in range(num_events):\n", + " rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": f\"Hello what can you do? EVENT NUM: {i}\"},\n", + " \"task_id\": task.id,\n", + " }\n", + " )\n", + " \n", + " event = rpc_response.result\n", + " print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": 42, + "id": "a2c269df-a33a-422e-a2bf-1cab514080e8", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Event(id='e4204e25-dba1-428a-a278-e5bf16464cbc', agent_id='c9fc2e91-df7a-42b4-bc79-fe154bd4db5a', sequence_id=277, task_id='0927b469-5aed-4804-aa53-79a6af70f76f', content=DataContent(author='user', data={'clear_queue': True, 'cancel_running_tasks': True}, style='static', type='data'), created_at=datetime.datetime(2025, 8, 14, 5, 55, 23, 716517, tzinfo=TzInfo(UTC)))\n" + ] + } + ], + "source": [ + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"data\", \"author\": \"user\", \"data\": {\"clear_queue\": True, \"cancel_running_tasks\": True}},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "id": "a6927cc0", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [08/14/2025 05:49:07] ─────────────────────────╮\n",
+       "│ I just cleared the queue of events that were received. Total cleared events: │\n",
+       "│ 1                                                                            │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[32m╭─\u001b[0m\u001b[32m───────────────────────\u001b[0m\u001b[32m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[32m [08/14/2025 05:49:07] \u001b[0m\u001b[32m────────────────────────\u001b[0m\u001b[32m─╮\u001b[0m\n", + "\u001b[32m│\u001b[0m I just cleared the queue of events that were received. Total cleared events: \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m 1 \u001b[32m│\u001b[0m\n", + "\u001b[32m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Streaming timed out after 5 seconds - returning collected messages\n" + ] + } + ], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4864e354", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.8" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/tutorials/10_async/10_temporal/030_custom_activities/manifest.yaml b/examples/tutorials/10_async/10_temporal/030_custom_activities/manifest.yaml new file mode 100644 index 000000000..40af196f1 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/030_custom_activities/manifest.yaml @@ -0,0 +1,138 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../../../ # Up to tutorials level to include test_utils + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - 10_async/10_temporal/030_custom_activities + - test_utils + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: 10_async/10_temporal/030_custom_activities/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: 10_async/10_temporal/030_custom_activities/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + # Path to temporal worker file + # Examples: + # project/run_worker.py (standard) + # workers/temporal.py (custom structure) + # ../shared/worker.py (shared across projects) + worker: project/run_worker.py + + +# Agent Configuration +# ----------------- +agent: + # Type of agent - either sync or async + acp_type: async + + # Unique name for your agent + # Used for task routing and monitoring + name: at030-custom-activities + + # Description of what your agent does + # Helps with documentation and discovery + description: An AgentEx agent with custom activities + + # Temporal workflow configuration + # This enables your agent to run as a Temporal workflow for long-running tasks + temporal: + enabled: true + workflows: + # Name of the workflow class + # Must match the @workflow.defn name in your workflow.py + - name: at030-custom-activities + + # Queue name for task distribution + # Used by Temporal to route tasks to your agent + # Convention: _task_queue + queue_name: 030_custom_activities_queue + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + # credentials: + # - env_var_name: OPENAI_API_KEY + # secret_name: openai-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + # env: + # OPENAI_API_KEY: "" + # OPENAI_BASE_URL: "" + # OPENAI_ORG_ID: "" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: + - name: my-registry-secret # Update with your image pull secret name + + # Global deployment settings that apply to all clusters + # These can be overridden using --override-file with custom configuration files + global: + agent: + name: "at030-custom-activities" + description: "An AgentEx agent with custom activities" + + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/030_custom_activities/project/__init__.py b/examples/tutorials/10_async/10_temporal/030_custom_activities/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/10_temporal/030_custom_activities/project/acp.py b/examples/tutorials/10_async/10_temporal/030_custom_activities/project/acp.py new file mode 100644 index 000000000..819b119ce --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/030_custom_activities/project/acp.py @@ -0,0 +1,60 @@ +import os +import sys + +# === DEBUG SETUP (AgentEx CLI Debug Support) === +if os.getenv("AGENTEX_DEBUG_ENABLED") == "true": + try: + import debugpy + debug_port = int(os.getenv("AGENTEX_DEBUG_PORT", "5679")) + debug_type = os.getenv("AGENTEX_DEBUG_TYPE", "acp") + wait_for_attach = os.getenv("AGENTEX_DEBUG_WAIT_FOR_ATTACH", "false").lower() == "true" + + # Configure debugpy + debugpy.configure(subProcess=False) + debugpy.listen(debug_port) + + print(f"🐛 [{debug_type.upper()}] Debug server listening on port {debug_port}") + + if wait_for_attach: + print(f"⏳ [{debug_type.upper()}] Waiting for debugger to attach...") + debugpy.wait_for_client() + print(f"✅ [{debug_type.upper()}] Debugger attached!") + else: + print(f"📡 [{debug_type.upper()}] Ready for debugger attachment") + + except ImportError: + print("❌ debugpy not available. Install with: pip install debugpy") + sys.exit(1) + except Exception as e: + print(f"❌ Debug setup failed: {e}") + sys.exit(1) +# === END DEBUG SETUP === + +from agentex.lib.types.fastacp import TemporalACPConfig +from agentex.lib.sdk.fastacp.fastacp import FastACP + +# Create the ACP server +acp = FastACP.create( + acp_type="async", + config=TemporalACPConfig( + # When deployed to the cluster, the Temporal address will automatically be set to the cluster address + # For local development, we set the address manually to talk to the local Temporal service set up via docker compose + type="temporal", + temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233") + ) +) + + +# Notice that we don't need to register any handlers when we use type="temporal" +# If you look at the code in agentex.sdk.fastacp.impl.temporal_acp +# You can see that these handlers are automatically registered when the ACP is created + +# @acp.on_task_create +# This will be handled by the method in your workflow that is decorated with @workflow.run + +# @acp.on_task_event_send +# This will be handled by the method in your workflow that is decorated with @workflow.signal(name=SignalName.RECEIVE_MESSAGE) + +# @acp.on_task_cancel +# This does not need to be handled by your workflow. +# It is automatically handled by the temporal client which cancels the workflow directly \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/030_custom_activities/project/custom_activites.py b/examples/tutorials/10_async/10_temporal/030_custom_activities/project/custom_activites.py new file mode 100644 index 000000000..36b5c9d2b --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/030_custom_activities/project/custom_activites.py @@ -0,0 +1,111 @@ +import asyncio +from typing import Any, List + +from pydantic import BaseModel +from temporalio import activity + +from agentex.lib import adk +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent + +logger = make_logger(__name__) + + +PROCESS_BATCH_EVENTS_ACTIVITY = "process_batch_events" +class ProcessBatchEventsActivityParams(BaseModel): + events: List[Any] + batch_number: int + + +REPORT_PROGRESS_ACTIVITY = "report_progress" +class ReportProgressActivityParams(BaseModel): + num_batches_processed: int + num_batches_failed: int + num_batches_running: int + task_id: str + + +COMPLETE_WORKFLOW_ACTIVITY = "complete_workflow" +class CompleteWorkflowActivityParams(BaseModel): + task_id: str + + +class CustomActivities: + def __init__(self): + self._batch_size = 5 + + + @activity.defn(name=PROCESS_BATCH_EVENTS_ACTIVITY) + async def process_batch_events(self, params: ProcessBatchEventsActivityParams) -> bool: + """ + This activity will take a list of events and process them. + + This is a simple example that demonstrates how to: + 1. Create a custom Temporal activity + 2. Accept structured parameters via Pydantic models + 3. Process batched data + 4. Simulate work with async sleep + 5. Return results back to the workflow + + In a real-world scenario, you could: + - Make database calls (batch inserts, updates) + - Call external APIs (payment processing, email sending) + - Perform heavy computations (ML model inference, data analysis) + - Generate reports or files + - Any other business logic that benefits from Temporal's reliability + + The key benefit is that this activity will automatically: + - Retry on failures (with configurable retry policies) + - Be durable across worker restarts + - Provide observability and metrics + - Handle timeouts and cancellations gracefully + """ + logger.info(f"[Batch {params.batch_number}] 🚀 Starting to process batch of {len(params.events)} events") + + # Process each event with some simulated work + for i, event in enumerate(params.events): + logger.info(f"[Batch {params.batch_number}] 📄 Processing event {i+1}/{len(params.events)}: {event}") + + # Simulate processing time - in reality this could be: + # - Database operations, API calls, file processing, ML inference, etc. + await asyncio.sleep(2) + + logger.info(f"[Batch {params.batch_number}] ✅ Event {i+1} processed successfully") + + logger.info(f"[Batch {params.batch_number}] 🎉 Batch processing complete! Processed {len(params.events)} events") + + # Return success - in reality you might return processing results, IDs, stats, etc. + return True + + @activity.defn(name=REPORT_PROGRESS_ACTIVITY) + async def report_progress(self, params: ReportProgressActivityParams) -> None: + """ + This activity will report progress to an external system. + + NORMALLY, this would be a call to an external system to report progress. For example, this could + be a call to an email service to send an update email to the user. + + In this example, we'll just log the progress to the console. + """ + logger.info(f"📊 Progress Update - num_batches_processed: {params.num_batches_processed}, num_batches_failed: {params.num_batches_failed}, num_batches_running: {params.num_batches_running}") + + await adk.messages.create( + task_id=params.task_id, + content=TextContent( + author="agent", + content=f"📊 Progress Update - num_batches_processed: {params.num_batches_processed}, num_batches_failed: {params.num_batches_failed}, num_batches_running: {params.num_batches_running}", + ), + ) + + @activity.defn(name=COMPLETE_WORKFLOW_ACTIVITY) + async def complete_workflow(self, params: CompleteWorkflowActivityParams) -> None: + """ + This activity will complete the workflow. + + Typically here you may do anything like: + - Send a final email to the user + - Send a final message to the user + - Update a job status in a database to completed + """ + logger.info(f"🎉 Workflow Complete! Task ID: {params.task_id}") + diff --git a/examples/tutorials/10_async/10_temporal/030_custom_activities/project/run_worker.py b/examples/tutorials/10_async/10_temporal/030_custom_activities/project/run_worker.py new file mode 100644 index 000000000..44ff5530a --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/030_custom_activities/project/run_worker.py @@ -0,0 +1,44 @@ +import asyncio + +from project.workflow import At030CustomActivitiesWorkflow +from agentex.lib.utils.debug import setup_debug_if_enabled +from project.custom_activites import CustomActivities +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.activities import get_all_activities +from agentex.lib.core.temporal.workers.worker import AgentexWorker + +environment_variables = EnvironmentVariables.refresh() + +logger = make_logger(__name__) + + +async def main(): + # Setup debug mode if enabled + setup_debug_if_enabled() + + task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE + if task_queue_name is None: + raise ValueError("WORKFLOW_TASK_QUEUE is not set") + + # Create a worker with automatic tracing + worker = AgentexWorker( + task_queue=task_queue_name, + ) + + agentex_activities = get_all_activities() + + custom_activities_use_case = CustomActivities() + all_activites = [ + custom_activities_use_case.report_progress, + custom_activities_use_case.process_batch_events, + *agentex_activities, + ] + + await worker.run( + activities=all_activites, + workflow=At030CustomActivitiesWorkflow, + ) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/030_custom_activities/project/shared_models.py b/examples/tutorials/10_async/10_temporal/030_custom_activities/project/shared_models.py new file mode 100644 index 000000000..2d894a9f4 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/030_custom_activities/project/shared_models.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel + + +class StateModel(BaseModel): + num_batches_processed: int = 0 + num_batches_failed: int = 0 + total_events_processed: int = 0 + total_events_dropped: int = 0 + total_events_enqueued: int = 0 + + +class IncomingEventData(BaseModel): + clear_queue: bool = False + cancel_running_tasks: bool = False \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/030_custom_activities/project/workflow.py b/examples/tutorials/10_async/10_temporal/030_custom_activities/project/workflow.py new file mode 100644 index 000000000..0fa85bbb9 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/030_custom_activities/project/workflow.py @@ -0,0 +1,216 @@ +import asyncio +from typing import Any, List, override +from datetime import timedelta + +from temporalio import workflow +from temporalio.common import RetryPolicy + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CreateTaskParams +from project.shared_models import StateModel, IncomingEventData +from project.workflow_utils import BatchProcessingUtils +from project.custom_activites import ( + REPORT_PROGRESS_ACTIVITY, + COMPLETE_WORKFLOW_ACTIVITY, + ReportProgressActivityParams, + CompleteWorkflowActivityParams, +) +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 +from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow + +environment_variables = EnvironmentVariables.refresh() + +if environment_variables.WORKFLOW_NAME is None: + raise ValueError("Environment variable WORKFLOW_NAME is not set") + +if not environment_variables.AGENT_NAME: + raise ValueError("Environment variable AGENT_NAME is not set") + +logger = make_logger(__name__) + + +WAIT_TIMEOUT = 300 +BATCH_SIZE = 5 +MAX_QUEUE_DEPTH = 50 + + +@workflow.defn(name=environment_variables.WORKFLOW_NAME) +class At030CustomActivitiesWorkflow(BaseWorkflow): + """ + Simple tutorial workflow demonstrating custom activities with concurrent processing. + + Key Learning Points: + 1. Queue incoming events using Temporal signals + 2. Process events in batches when enough arrive + 3. Use asyncio.create_task() for concurrent processing + 4. Execute custom activities from within workflows + 5. Handle workflow completion cleanly + """ + def __init__(self): + super().__init__(display_name=environment_variables.AGENT_NAME) + self._incoming_queue: asyncio.Queue[Any] = asyncio.Queue() + self._processing_tasks: List[asyncio.Task[Any]] = [] + self._batch_size = BATCH_SIZE + self._state: StateModel + + + @workflow.signal(name=SignalName.RECEIVE_EVENT) + @override + async def on_task_event_send(self, params: SendEventParams) -> None: + if params.event.content is None: + return + + if params.event.content.type == "text": + if self._incoming_queue.qsize() >= MAX_QUEUE_DEPTH: + logger.warning(f"Queue is at max depth of {MAX_QUEUE_DEPTH}. Dropping event.") + if self._state: + self._state.total_events_dropped += 1 + else: + await self._incoming_queue.put(params.event.content) + return + + elif params.event.content.type == "data": + received_data = params.event.content.data + try: + received_data = IncomingEventData.model_validate(received_data) + except Exception as e: + logger.error(f"Error parsing received data: {e}. Dropping event.") + return + + if received_data.clear_queue: + await BatchProcessingUtils.handle_queue_clear(self._incoming_queue, params.task.id) + + if received_data.cancel_running_tasks: + await BatchProcessingUtils.handle_task_cancellation(self._processing_tasks, params.task.id) + else: + logger.info(f"Received IncomingEventData: {received_data} with no known action.") + else: + logger.info(f"Received event: {params.event.content} with no action.") + + + @workflow.run + @override + async def on_task_create(self, params: CreateTaskParams) -> None: + logger.info(f"Received task create params: {params}") + + self._state = StateModel() + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"🚀 Starting batch processing! I'll collect events into batches of {self._batch_size} and process them using custom activities. I'll also report progress you as I go..", + ), + ) + + batch_number = 0 + + # Simple event processing loop with progress tracking + while True: + # Check for completed tasks and update progress + self._processing_tasks = await BatchProcessingUtils.update_progress(self._processing_tasks, self._state, params.task.id) + + # Wait for enough events to form a batch, or timeout + try: + await workflow.wait_condition( + lambda: self._incoming_queue.qsize() >= self._batch_size, + timeout=WAIT_TIMEOUT + ) + except asyncio.TimeoutError: + logger.info(f"⏰ Timeout after {WAIT_TIMEOUT} seconds - ending workflow") + break + + # We have enough events - start processing them as a batch + data_to_process: List[Any] = [] + await BatchProcessingUtils.dequeue_pending_data(self._incoming_queue, data_to_process, self._batch_size) + + if data_to_process: + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"📦 Starting batch #{batch_number} with {len(data_to_process)} events using asyncio.create_task()", + ), + ) + + # Create concurrent task for this batch - this is the key learning point! + task = asyncio.create_task( + BatchProcessingUtils.process_batch_concurrent( + events=data_to_process, + batch_number=batch_number, + task_id=params.task.id + ) + ) + batch_number += 1 + self._processing_tasks.append(task) + + logger.info(f"📝 Tutorial Note: Created asyncio.create_task() for batch #{batch_number} to run asynchronously") + + # Check progress again immediately to show real-time updates + self._processing_tasks = await BatchProcessingUtils.update_progress(self._processing_tasks, self._state, params.task.id) + + # Process any remaining events that didn't form a complete batch + if self._incoming_queue.qsize() > 0: + data_to_process: List[Any] = [] + await BatchProcessingUtils.dequeue_pending_data(self._incoming_queue, data_to_process, self._incoming_queue.qsize()) + + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"🔄 Processing final {len(data_to_process)} events that didn't form a complete batch.", + ), + ) + + # Now, add another batch to process the remaining events + task = asyncio.create_task( + BatchProcessingUtils.process_batch_concurrent( + events=data_to_process, + batch_number=batch_number, + task_id=params.task.id + ) + ) + self._processing_tasks.append(task) + batch_number += 1 + + # Wait for all remaining tasks to complete, with real-time progress updates + await BatchProcessingUtils.wait_for_remaining_tasks(self._processing_tasks, self._state, params.task.id) + await workflow.execute_activity( + REPORT_PROGRESS_ACTIVITY, + ReportProgressActivityParams( + num_batches_processed=self._state.num_batches_processed, + num_batches_failed=self._state.num_batches_failed, + num_batches_running=0, + task_id=params.task.id + ), + start_to_close_timeout=timedelta(minutes=1), + retry_policy=RetryPolicy(maximum_attempts=3) + ) + + final_summary = ( + f"✅ Workflow Complete! Final Summary:\n" + f"• Batches completed successfully: {self._state.num_batches_processed} ✅\n" + f"• Batches failed: {self._state.num_batches_failed} ❌\n" + f"• Total events processed: {self._state.total_events_processed}\n" + f"• Events dropped (queue full): {self._state.total_events_dropped}\n" + f"📝 Tutorial completed - you learned how to use asyncio.create_task() with Temporal custom activities!" + ) + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=final_summary + ), + ) + + await workflow.execute_activity( + COMPLETE_WORKFLOW_ACTIVITY, + CompleteWorkflowActivityParams( + task_id=params.task.id + ), + start_to_close_timeout=timedelta(minutes=1), + retry_policy=RetryPolicy(maximum_attempts=3) + ) + diff --git a/examples/tutorials/10_async/10_temporal/030_custom_activities/project/workflow_utils.py b/examples/tutorials/10_async/10_temporal/030_custom_activities/project/workflow_utils.py new file mode 100644 index 000000000..da04a8dab --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/030_custom_activities/project/workflow_utils.py @@ -0,0 +1,204 @@ +import asyncio +from typing import Any, Dict, List +from datetime import timedelta + +from temporalio import workflow +from temporalio.common import RetryPolicy + +from agentex.lib import adk +from project.shared_models import StateModel +from project.custom_activites import ( + REPORT_PROGRESS_ACTIVITY, + PROCESS_BATCH_EVENTS_ACTIVITY, + ReportProgressActivityParams, + ProcessBatchEventsActivityParams, +) +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent + +logger = make_logger(__name__) + + +class BatchProcessingUtils: + """ + Utility class containing batch processing logic extracted from the main workflow. + This keeps the workflow clean while maintaining all the same functionality. + """ + + @staticmethod + async def dequeue_pending_data(queue: asyncio.Queue[Any], data_to_process: List[Any], max_items: int) -> None: + """ + Dequeue exactly the number of items requested, maintaining FIFO order. + This is much cleaner than dequeuing everything and putting items back. + """ + items_dequeued = 0 + while items_dequeued < max_items and not queue.empty(): + try: + item = queue.get_nowait() + data_to_process.append(item) + items_dequeued += 1 + except Exception: + # Queue became empty while we were dequeuing + break + + @staticmethod + async def process_batch_concurrent(events: List[Any], batch_number: int, task_id: str) -> Dict[str, Any]: + """ + Process a single batch using a custom activity. + This demonstrates how asyncio.create_task() allows multiple batches to run concurrently. + Returns batch info for state tracking by the main workflow thread. + """ + try: + logger.info(f"🚀 Batch #{batch_number}: Starting concurrent processing of {len(events)} events") + + # This is the key: calling a custom activity from within the workflow + await workflow.execute_activity( + PROCESS_BATCH_EVENTS_ACTIVITY, + ProcessBatchEventsActivityParams( + events=events, + batch_number=batch_number + ), + start_to_close_timeout=timedelta(minutes=5), + retry_policy=RetryPolicy(maximum_attempts=3) + ) + + await adk.messages.create( + task_id=task_id, + content=TextContent( + author="agent", + content=f"✅ Batch #{batch_number} completed! Processed {len(events)} events using custom activity.", + ), + ) + + logger.info(f"✅ Batch #{batch_number}: Processing completed successfully") + return {"success": True, "events_processed": len(events), "batch_number": batch_number} + + except Exception as e: + await adk.messages.create( + task_id=task_id, + content=TextContent( + author="agent", + content=f"❌ Batch #{batch_number} failed: {str(e)}", + ), + ) + logger.error(f"❌ Batch #{batch_number} failed: {str(e)}") + return {"success": False, "events_processed": 0, "batch_number": batch_number, "error": str(e)} + + @staticmethod + async def update_progress(processing_tasks: List[asyncio.Task[Any]], state: StateModel, task_id: str) -> List[asyncio.Task[Any]]: + """ + Check for completed tasks and update progress in real-time. + This is key for tutorials - showing progress as things happen! + + Returns the updated list of still-running tasks. + """ + if not processing_tasks: + return processing_tasks + + # Check which tasks have completed + completed_tasks: List[asyncio.Task[Any]] = [] + still_running: List[asyncio.Task[Any]] = [] + + for task in processing_tasks: + if task.done(): + completed_tasks.append(task) + else: + still_running.append(task) + + # Update state based on completed tasks + if completed_tasks: + for task in completed_tasks: + try: + result = await task # Get the result + if isinstance(result, dict) and result.get("success"): + # Successful processing - update state + state.num_batches_processed += 1 + state.total_events_processed += result.get("events_processed", 0) + else: + # Failed processing + state.num_batches_failed += 1 + except Exception: + # Task failed with exception + state.num_batches_failed += 1 + + await workflow.execute_activity( + REPORT_PROGRESS_ACTIVITY, + ReportProgressActivityParams( + num_batches_processed=state.num_batches_processed, + num_batches_failed=state.num_batches_failed, + num_batches_running=len(still_running), + task_id=task_id, + ), + start_to_close_timeout=timedelta(minutes=1), + retry_policy=RetryPolicy(maximum_attempts=3) + ) + return still_running + + @staticmethod + async def handle_queue_clear(queue: asyncio.Queue[Any], task_id: str) -> int: + """ + Handle clearing the event queue and return the number of events cleared. + """ + num_events = queue.qsize() + logger.info(f"Clearing queue of size: {num_events}") + while not queue.empty(): + queue.get_nowait() + + await adk.messages.create( + task_id=task_id, + content=TextContent( + author="agent", + content=f"I just cleared the queue of events that were received. Total cleared events: {num_events}", + ), + ) + return num_events + + @staticmethod + async def handle_task_cancellation(processing_tasks: List[asyncio.Task[Any]], task_id: str) -> int: + """ + Handle cancelling all running batch processing tasks. + Returns the number of tasks cancelled. + """ + # Simple cancellation for tutorial purposes + cancelled_count = len([task for task in processing_tasks if not task.done()]) + for task in processing_tasks: + if not task.done(): + task.cancel() + + processing_tasks.clear() + await adk.messages.create( + task_id=task_id, + content=TextContent( + author="agent", + content=f"⛔ Cancelled {cancelled_count} running tasks. This shows how asyncio.create_task() tasks can be cancelled!", + ), + ) + return cancelled_count + + @staticmethod + async def wait_for_remaining_tasks(processing_tasks: List[asyncio.Task[Any]], state: Any, task_id: str) -> None: + """ + Wait for all remaining tasks to complete, with real-time progress updates. + """ + while processing_tasks: + await adk.messages.create( + task_id=task_id, + content=TextContent( + author="agent", + content=f"⏳ Waiting for {len(processing_tasks)} remaining batches to complete...", + ), + ) + + # Wait a bit, then update progress + try: + await workflow.wait_condition( + lambda: not any(task for task in processing_tasks if not task.done()), + timeout=10 # Check progress every 10 seconds + ) + # All tasks are done! + processing_tasks[:] = await BatchProcessingUtils.update_progress(processing_tasks, state, task_id) + break + except asyncio.TimeoutError: + # Some tasks still running, update progress and continue waiting + processing_tasks[:] = await BatchProcessingUtils.update_progress(processing_tasks, state, task_id) + continue \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/030_custom_activities/pyproject.toml b/examples/tutorials/10_async/10_temporal/030_custom_activities/pyproject.toml new file mode 100644 index 000000000..cc53d065a --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/030_custom_activities/pyproject.toml @@ -0,0 +1,42 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "030_custom_activities" +version = "0.1.0" +description = "An AgentEx agent with custom activities" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "ipykernel>=6.30.1", + "jupyter-server>=2.16.0", + "jupyterlab>=4.4.5", + "nbconvert>=7.16.6", + "nbformat>=5.10.4", + "notebook>=7.4.5", + "scale-gp", + "temporalio", + "yaspin>=3.1.0", +] + +[project.optional-dependencies] +dev = [ + "jupyter", + "pytest", + "black", + "isort", + "flake8", + "debugpy>=1.8.15", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/examples/tutorials/10_async/10_temporal/030_custom_activities/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/030_custom_activities/tests/test_agent.py new file mode 100644 index 000000000..b839332c7 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/030_custom_activities/tests/test_agent.py @@ -0,0 +1,136 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: at030-custom-activities) +""" + +import os + +import pytest +import pytest_asyncio + +from agentex import AsyncAgentex + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "at030-custom-activities") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_id: str): + """Test sending an event and polling for the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # TODO: Poll for the initial task creation message (if your agent sends one) + # async for message in poll_messages( + # client=client, + # task_id=task.id, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected initial message + # assert "expected initial text" in message.content.content + # break + + # TODO: Send an event and poll for response using the yielding helper function + # user_message = "Your test message here" + # async for message in send_event_and_poll_yielding( + # client=client, + # agent_id=agent_id, + # task_id=task.id, + # user_message=user_message, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected response + # assert "expected response text" in message.content.content + # break + pass + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_id: str): + """Test sending an event and streaming the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # user_message = "Your test message here" + + # # Collect events from stream + # all_events = [] + + # async def collect_stream_events(): + # async for event in stream_agent_response( + # client=client, + # task_id=task.id, + # timeout=30, + # ): + # all_events.append(event) + + # # Start streaming task + # stream_task = asyncio.create_task(collect_stream_events()) + + # # Send the event + # event_content = TextContentParam(type="text", author="user", content=user_message) + # await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + + # # Wait for streaming to complete + # await stream_task + + # # TODO: Add your validation here + # assert len(all_events) > 0, "No events received in streaming response" + pass + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/.dockerignore b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/.dockerignore new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/Dockerfile b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/Dockerfile new file mode 100644 index 000000000..ef1ea0bf6 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/Dockerfile @@ -0,0 +1,59 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install tctl (Temporal CLI) +RUN curl -L https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_arm64.tar.gz -o /tmp/tctl.tar.gz && \ + tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ + chmod +x /usr/local/bin/tctl && \ + rm /tmp/tctl.tar.gz + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 10_async/10_temporal/050_agent_chat_guardrails/pyproject.toml /app/050_agent_chat_guardrails/pyproject.toml +COPY 10_async/10_temporal/050_agent_chat_guardrails/README.md /app/050_agent_chat_guardrails/README.md + +WORKDIR /app/050_agent_chat_guardrails + +# Copy the project code +COPY 10_async/10_temporal/050_agent_chat_guardrails/project /app/050_agent_chat_guardrails/project + +# Copy the test files +COPY 10_async/10_temporal/050_agent_chat_guardrails/tests /app/050_agent_chat_guardrails/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies (includes pytest) +RUN uv pip install --system .[dev] pytest-asyncio httpx + +# Set environment variables +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=at050-agent-chat-guardrails + +# Run the ACP server using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/README.md b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/README.md new file mode 100644 index 000000000..b6e192b58 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/README.md @@ -0,0 +1,70 @@ +# [Temporal] Agent Chat with Guardrails + +This tutorial demonstrates how to implement streaming multiturn tool-enabled chat with input and output guardrails using Temporal workflows in AgentEx agents. + +## What You'll Learn +- Adding safety guardrails to conversational agents +- Input validation and output filtering +- Implementing content moderation with Temporal +- When to block vs warn vs allow content + +## Prerequisites +- Development environment set up (see [main repo README](https://github.com/scaleapi/scale-agentex)) +- Backend services running: `make dev` from repository root +- Temporal UI available at http://localhost:8233 +- Understanding of agent chat patterns (see [010_agent_chat](../010_agent_chat/)) + +## Quick Start + +```bash +cd examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails +uv run agentex agents run --manifest manifest.yaml +``` + +**Monitor:** Open Temporal UI at http://localhost:8233 to see guardrail checks as workflow activities. + +## Guardrails + +### Input Guardrails +- **Spaghetti Guardrail**: Blocks any mention of "spaghetti" in user messages +- **Soup Guardrail**: Blocks any mention of "soup" in user messages + +### Output Guardrails +- **Pizza Guardrail**: Prevents the AI from mentioning "pizza" in responses +- **Sushi Guardrail**: Prevents the AI from mentioning "sushi" in responses + +## Testing the Guardrails + +To see the guardrails in action: + +1. **Test Input Guardrails:** + - Try: "Tell me about spaghetti" + - Try: "What's your favorite soup?" + - The guardrails will block these messages before they reach the AI + +2. **Test Output Guardrails:** + - Ask: "What are popular Italian foods?" (may trigger pizza guardrail) + - Ask: "What are popular Japanese foods?" (may trigger sushi guardrail) + - The AI may generate responses containing these words, but the guardrails will block them + +## Implementation Details + +The guardrails are implemented as functions that: +- Check the input/output for specific content +- Return a `GuardrailFunctionOutput` with: + - `tripwire_triggered`: Whether to block the content + - `output_info`: Metadata about the check + - `rejection_message`: Custom message shown when content is blocked + +See `workflow.py` for the complete implementation. + +## When to Use +- Content moderation and safety requirements +- Compliance with regulatory restrictions +- Brand safety and reputation protection +- Preventing agents from discussing sensitive topics + +## Why This Matters +Production agents need safety rails. This pattern shows how to implement content filtering without sacrificing the benefits of Temporal workflows. Guardrail checks become durable activities, visible in Temporal UI for audit and debugging. + +**Next:** [060_open_ai_agents_sdk_hello_world](../060_open_ai_agents_sdk_hello_world/) - Integrate OpenAI Agents SDK with Temporal \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/dev.ipynb b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/dev.ipynb new file mode 100644 index 000000000..ab87b676d --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/dev.ipynb @@ -0,0 +1,1196 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "0", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"at010-agent-chat\"" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "2", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Task(id='0577cdc8-6c6a-4ef7-bc5c-85d27b9327e7', created_at=datetime.datetime(2025, 8, 27, 21, 33, 21, 976210, tzinfo=TzInfo(UTC)), name='7ff11264-task', params={}, status='RUNNING', status_reason='Task created, forwarding to ACP server', updated_at=datetime.datetime(2025, 8, 27, 21, 33, 21, 976210, tzinfo=TzInfo(UTC)))\n" + ] + } + ], + "source": [ + "# (REQUIRED) Create a new task. For Agentic agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "markdown", + "id": "645fb612", + "metadata": {}, + "source": [ + "## Testing Guardrails\n", + "\n", + "We have configured 4 guardrails:\n", + "- **Input Guardrails**: Spaghetti (tested above), Soup\n", + "- **Output Guardrails**: Pizza, Sushi\n" + ] + }, + { + "cell_type": "markdown", + "id": "11d260f4", + "metadata": {}, + "source": [ + "### Test 2: Soup Input Guardrail\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "3", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Event(id='b243f073-a7cb-4420-b513-305c2b6aae5d', agent_id='a1abb90e-c673-4448-a4e2-841170568840', sequence_id=1844, task_id='0577cdc8-6c6a-4ef7-bc5c-85d27b9327e7', content=TextContent(author='user', content='Find me a recipe on spaghetti', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 27, 21, 33, 22, 16063, tzinfo=TzInfo(UTC)))\n" + ] + } + ], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "# - ReasoningContent: A message with a reasoning content, which contains a reasoning object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Find me a recipe on spaghetti\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "4", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
╭───────────────────────── USER [08/27/2025 21:33:22] ─────────────────────────╮\n",
+       "│ Find me a recipe on spaghetti                                                │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[96m╭─\u001b[0m\u001b[96m────────────────────────\u001b[0m\u001b[96m \u001b[0m\u001b[1;96mUSER\u001b[0m\u001b[96m [08/27/2025 21:33:22] \u001b[0m\u001b[96m────────────────────────\u001b[0m\u001b[96m─╮\u001b[0m\n", + "\u001b[96m│\u001b[0m Find me a recipe on spaghetti \u001b[96m│\u001b[0m\n", + "\u001b[96m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \r" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [08/27/2025 21:33:25] ─────────────────────────╮\n",
+       "│ I'm sorry, but I cannot process messages about spaghetti. This guardrail was │\n",
+       "│ put in place for demonstration purposes. Please ask me about something else! │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[32m╭─\u001b[0m\u001b[32m───────────────────────\u001b[0m\u001b[32m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[32m [08/27/2025 21:33:25] \u001b[0m\u001b[32m────────────────────────\u001b[0m\u001b[32m─╮\u001b[0m\n", + "\u001b[32m│\u001b[0m I'm sorry, but I cannot process messages about spaghetti. This guardrail was \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m put in place for demonstration purposes. Please ask me about something else! \u001b[32m│\u001b[0m\n", + "\u001b[32m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Streaming timed out after 60 seconds - returning collected messages\n" + ] + } + ], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=60,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "ff7cf427", + "metadata": {}, + "source": [ + "### Test 3: Soup Input Guardrail\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "ea464eea", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Task(id='b34a414a-5753-4c6c-a6f5-aa8eabb6a731', created_at=datetime.datetime(2025, 8, 27, 21, 34, 25, 397654, tzinfo=TzInfo(UTC)), name='66fd90bb-soup-test', params={}, status='RUNNING', status_reason='Task created, forwarding to ACP server', updated_at=datetime.datetime(2025, 8, 27, 21, 34, 25, 397654, tzinfo=TzInfo(UTC)))\n" + ] + } + ], + "source": [ + "# Create a new task for soup guardrail test\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-soup-test\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task_soup = rpc_response.result\n", + "print(task_soup)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "48d40391", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Event(id='90d002ac-ff06-4d36-8af7-b764420ae2ff', agent_id='a1abb90e-c673-4448-a4e2-841170568840', sequence_id=1845, task_id='b34a414a-5753-4c6c-a6f5-aa8eabb6a731', content=TextContent(author='user', content=\"What's your favorite soup recipe?\", attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 27, 21, 34, 25, 427792, tzinfo=TzInfo(UTC)))\n" + ] + } + ], + "source": [ + "# Send event that triggers soup guardrail\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"What's your favorite soup recipe?\"},\n", + " \"task_id\": task_soup.id,\n", + " }\n", + ")\n", + "\n", + "event_soup = rpc_response.result\n", + "print(event_soup)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "154c6498", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
╭───────────────────────── USER [08/27/2025 21:34:25] ─────────────────────────╮\n",
+       "│ What's your favorite soup recipe?                                            │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[96m╭─\u001b[0m\u001b[96m────────────────────────\u001b[0m\u001b[96m \u001b[0m\u001b[1;96mUSER\u001b[0m\u001b[96m [08/27/2025 21:34:25] \u001b[0m\u001b[96m────────────────────────\u001b[0m\u001b[96m─╮\u001b[0m\n", + "\u001b[96m│\u001b[0m What's your favorite soup recipe? \u001b[96m│\u001b[0m\n", + "\u001b[96m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \r" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [08/27/2025 21:34:26] ─────────────────────────╮\n",
+       "│ I'm sorry, but I cannot process messages about soup. This is a demonstration │\n",
+       "│ guardrail for testing purposes. Please ask about something other than soup!  │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[32m╭─\u001b[0m\u001b[32m───────────────────────\u001b[0m\u001b[32m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[32m [08/27/2025 21:34:26] \u001b[0m\u001b[32m────────────────────────\u001b[0m\u001b[32m─╮\u001b[0m\n", + "\u001b[32m│\u001b[0m I'm sorry, but I cannot process messages about soup. This is a demonstration \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m guardrail for testing purposes. Please ask about something other than soup! \u001b[32m│\u001b[0m\n", + "\u001b[32m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Streaming timed out after 30 seconds - returning collected messages\n" + ] + } + ], + "source": [ + "# Subscribe to see the soup guardrail response\n", + "task_messages_soup = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task_soup, \n", + " only_after_timestamp=event_soup.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=30,\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "dae8d0be", + "metadata": {}, + "source": [ + "### Test 4: Pizza Output Guardrail\n" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "1abbe06b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Task(id='ca2d107e-4f21-48f6-830a-61a03779895f', created_at=datetime.datetime(2025, 8, 27, 21, 34, 56, 922244, tzinfo=TzInfo(UTC)), name='fbd68764-pizza-test', params={}, status='RUNNING', status_reason='Task created, forwarding to ACP server', updated_at=datetime.datetime(2025, 8, 27, 21, 34, 56, 922244, tzinfo=TzInfo(UTC)))\n" + ] + } + ], + "source": [ + "# Create a new task for pizza guardrail test\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-pizza-test\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task_pizza = rpc_response.result\n", + "print(task_pizza)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "ea6b58b5", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Event(id='2b39425a-f3c0-409b-b725-2ee88e6ae178', agent_id='a1abb90e-c673-4448-a4e2-841170568840', sequence_id=1846, task_id='ca2d107e-4f21-48f6-830a-61a03779895f', content=TextContent(author='user', content='What are some popular Italian dishes?', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 27, 21, 34, 56, 969021, tzinfo=TzInfo(UTC)))\n" + ] + } + ], + "source": [ + "# Send event that might trigger pizza output guardrail\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"What are some popular Italian dishes?\"},\n", + " \"task_id\": task_pizza.id,\n", + " }\n", + ")\n", + "\n", + "event_pizza = rpc_response.result\n", + "print(event_pizza)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "899be668", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
╭───────────────────────── USER [08/27/2025 21:34:57] ─────────────────────────╮\n",
+       "│ What are some popular Italian dishes?                                        │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[96m╭─\u001b[0m\u001b[96m────────────────────────\u001b[0m\u001b[96m \u001b[0m\u001b[1;96mUSER\u001b[0m\u001b[96m [08/27/2025 21:34:57] \u001b[0m\u001b[96m────────────────────────\u001b[0m\u001b[96m─╮\u001b[0m\n", + "\u001b[96m│\u001b[0m What are some popular Italian dishes? \u001b[96m│\u001b[0m\n", + "\u001b[96m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \r" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [08/27/2025 21:35:01] ─────────────────────────╮\n",
+       "│ 🧠 Reasoning                                                                 │\n",
+       "│                                                                              │\n",
+       "│ Listing popular Italian dishes                                               │\n",
+       "│                                                                              │\n",
+       "│ The user is asking about popular Italian dishes, which is simple enough!     │\n",
+       "│ I’ll create a list that spans across various courses: antipasti, primi (like │\n",
+       "│ pasta and risotto), secondi (meat and fish), contorni, and dolci. I think I  │\n",
+       "│ should mention regional specialties, aiming for 15-20 items. Key dishes will │\n",
+       "│ include pizza, several pasta types like spaghetti alla carbonara and         │\n",
+       "│ bolognese, risotto alla milanese, and more. I can also offer recipes or      │\n",
+       "│ recommendations if they’d like.                                              │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[95m╭─\u001b[0m\u001b[95m───────────────────────\u001b[0m\u001b[95m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[95m [08/27/2025 21:35:01] \u001b[0m\u001b[95m────────────────────────\u001b[0m\u001b[95m─╮\u001b[0m\n", + "\u001b[95m│\u001b[0m 🧠 \u001b[1mReasoning\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[1mListing popular Italian dishes\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m The user is asking about popular Italian dishes, which is simple enough! \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m I’ll create a list that spans across various courses: antipasti, primi (like \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m pasta and risotto), secondi (meat and fish), contorni, and dolci. I think I \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m should mention regional specialties, aiming for 15-20 items. Key dishes will \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m include pizza, several pasta types like spaghetti alla carbonara and \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m bolognese, risotto alla milanese, and more. I can also offer recipes or \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m recommendations if they’d like. \u001b[95m│\u001b[0m\n", + "\u001b[95m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \r" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [08/27/2025 21:35:03] ─────────────────────────╮\n",
+       "│ Here are some popular Italian dishes, grouped by course with a short         │\n",
+       "│ description for each:                                                        │\n",
+       "│                                                                              │\n",
+       "│ Antipasti (starters)                                                         │\n",
+       "│                                                                              │\n",
+       "│  • Bruschetta: grilled bread rubbed with garlic and topped (commonly) with   │\n",
+       "│    tomatoes, basil, olive oil.                                               │\n",
+       "│  • Caprese: fresh tomatoes, mozzarella, basil and olive oil (from Campania). │\n",
+       "│  • Carpaccio: thinly sliced raw beef or fish, dressed with lemon/olive oil   │\n",
+       "│    and parmesan.                                                             │\n",
+       "│  • Prosciutto e melone: cured ham served with cantaloupe.                    │\n",
+       "│                                                                              │\n",
+       "│ Primi (first courses — usually pasta, rice or soup)                          │\n",
+       "│                                                                              │\n",
+       "│  • Spaghetti alla Carbonara: eggs, Pecorino/Romano cheese, guanciale (cured  │\n",
+       "│    pork) and black pepper (Roman classic).                                   │\n",
+       "│  • Spaghetti alla Bolognese / Ragù: meat-based sauce (Emilia-Romagna).       │\n",
+       "│  • Pasta all’Amatriciana: tomato, guanciale and pecorino (from Amatrice).    │\n",
+       "│  • Cacio e Pepe: very simple pasta with Pecorino cheese and black pepper     │\n",
+       "│    (Roman).                                                                  │\n",
+       "│  • Lasagna alla Bolognese: layered pasta with ragù, béchamel and cheese.     │\n",
+       "│  • Risotto alla Milanese: creamy saffron risotto (Milan).                    │\n",
+       "│  • Gnocchi: potato dumplings served with various sauces.                     │\n",
+       "│  • Minestrone: hearty vegetable soup.                                        │\n",
+       "│                                                                              │\n",
+       "│ Secondi (main courses)                                                       │\n",
+       "│                                                                              │\n",
+       "│  • Pollo alla Cacciatora (chicken cacciatore): chicken stewed with tomatoes, │\n",
+       "│    herbs, wine.                                                              │\n",
+       "│  • Saltimbocca alla Romana: veal topped with prosciutto and sage, cooked in  │\n",
+       "│    wine/butter (Rome).                                                       │\n",
+       "│  • Osso Buco: braised veal shanks, often served with risotto alla Milanese.  │\n",
+       "│  • Branzino al forno: roast sea bass (common coastal dish).                  │\n",
+       "│  • Parmigiana di Melanzane (Eggplant Parmesan): fried eggplant layered with  │\n",
+       "│    tomato sauce and cheese (Southern Italy).                                 │\n",
+       "│                                                                              │\n",
+       "│ Contorni (sides)                                                             │\n",
+       "│                                                                              │\n",
+       "│  • Focaccia: flat oven-baked bread from Liguria (often seasoned with olive   │\n",
+       "│    oil, rosemary).                                                           │\n",
+       "│  • Polenta: cornmeal porridge, served soft or grilled (Northern Italy).      │\n",
+       "│                                                                              │\n",
+       "│ Dolci (desserts)                                                             │\n",
+       "│                                                                              │\n",
+       "│  • Tiramisu: coffee-soaked ladyfingers layered with mascarpone cream.        │\n",
+       "│  • Gelato: Italian-style ice cream, denser and more intense than many ice    │\n",
+       "│    creams.                                                                   │\n",
+       "│  • Panna Cotta: creamy set dessert, often served with fruit coulis.          │\n",
+       "│  • Cannoli: Sicilian fried pastry tubes filled with sweet ricotta.           │\n",
+       "│                                                                              │\n",
+       "│ Regional specialties worth noting                                            │\n",
+       "│                                                                              │\n",
+       "│  • Pizza Margherita (Naples): tomato, mozzarella, basil — the classic        │\n",
+       "│    Neapolitan pizza.                                                         │\n",
+       "│  • Arancini (Sicily): fried rice balls usually filled with ragù, peas and    │\n",
+       "│    cheese.                                                                   │\n",
+       "│                                                                              │\n",
+       "│ If you’d like, I can:                                                        │\n",
+       "│                                                                              │\n",
+       "│  • Give recipes for any of these dishes,                                     │\n",
+       "│  • Suggest restaurants or regional variations, or                            │\n",
+       "│  • Provide wine-pairing ideas. Which would you prefer?                       │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[32m╭─\u001b[0m\u001b[32m───────────────────────\u001b[0m\u001b[32m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[32m [08/27/2025 21:35:03] \u001b[0m\u001b[32m────────────────────────\u001b[0m\u001b[32m─╮\u001b[0m\n", + "\u001b[32m│\u001b[0m Here are some popular Italian dishes, grouped by course with a short \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m description for each: \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m Antipasti (starters) \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mBruschetta: grilled bread rubbed with garlic and topped (commonly) with \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mtomatoes, basil, olive oil. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mCaprese: fresh tomatoes, mozzarella, basil and olive oil (from Campania). \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mCarpaccio: thinly sliced raw beef or fish, dressed with lemon/olive oil \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mand parmesan. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mProsciutto e melone: cured ham served with cantaloupe. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m Primi (first courses — usually pasta, rice or soup) \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mSpaghetti alla Carbonara: eggs, Pecorino/Romano cheese, guanciale (cured \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mpork) and black pepper (Roman classic). \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mSpaghetti alla Bolognese / Ragù: meat-based sauce (Emilia-Romagna). \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mPasta all’Amatriciana: tomato, guanciale and pecorino (from Amatrice). \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mCacio e Pepe: very simple pasta with Pecorino cheese and black pepper \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0m(Roman). \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mLasagna alla Bolognese: layered pasta with ragù, béchamel and cheese. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mRisotto alla Milanese: creamy saffron risotto (Milan). \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mGnocchi: potato dumplings served with various sauces. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mMinestrone: hearty vegetable soup. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m Secondi (main courses) \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mPollo alla Cacciatora (chicken cacciatore): chicken stewed with tomatoes, \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mherbs, wine. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mSaltimbocca alla Romana: veal topped with prosciutto and sage, cooked in \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mwine/butter (Rome). \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mOsso Buco: braised veal shanks, often served with risotto alla Milanese. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mBranzino al forno: roast sea bass (common coastal dish). \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mParmigiana di Melanzane (Eggplant Parmesan): fried eggplant layered with \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mtomato sauce and cheese (Southern Italy). \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m Contorni (sides) \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mFocaccia: flat oven-baked bread from Liguria (often seasoned with olive \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0moil, rosemary). \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mPolenta: cornmeal porridge, served soft or grilled (Northern Italy). \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m Dolci (desserts) \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mTiramisu: coffee-soaked ladyfingers layered with mascarpone cream. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mGelato: Italian-style ice cream, denser and more intense than many ice \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mcreams. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mPanna Cotta: creamy set dessert, often served with fruit coulis. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mCannoli: Sicilian fried pastry tubes filled with sweet ricotta. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m Regional specialties worth noting \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mPizza Margherita (Naples): tomato, mozzarella, basil — the classic \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mNeapolitan pizza. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mArancini (Sicily): fried rice balls usually filled with ragù, peas and \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mcheese. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m If you’d like, I can: \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mGive recipes for any of these dishes, \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mSuggest restaurants or regional variations, or \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mProvide wine-pairing ideas. Which would you prefer? \u001b[32m│\u001b[0m\n", + "\u001b[32m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \r" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [08/27/2025 21:35:10] ─────────────────────────╮\n",
+       "│ I cannot provide this response as it mentions pizza. Due to content          │\n",
+       "│ policies, I need to avoid discussing pizza. Let me provide a different       │\n",
+       "│ response.                                                                    │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[32m╭─\u001b[0m\u001b[32m───────────────────────\u001b[0m\u001b[32m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[32m [08/27/2025 21:35:10] \u001b[0m\u001b[32m────────────────────────\u001b[0m\u001b[32m─╮\u001b[0m\n", + "\u001b[32m│\u001b[0m I cannot provide this response as it mentions pizza. Due to content \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m policies, I need to avoid discussing pizza. Let me provide a different \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m response. \u001b[32m│\u001b[0m\n", + "\u001b[32m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Streaming timed out after 30 seconds - returning collected messages\n" + ] + } + ], + "source": [ + "# Subscribe to see if pizza output guardrail triggers\n", + "task_messages_pizza = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task_pizza, \n", + " only_after_timestamp=event_pizza.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=30,\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "d59c0cfc", + "metadata": {}, + "source": [ + "### Test 5: Sushi Output Guardrail\n" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "0443e640", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Task(id='1b3e7c18-b2a7-4980-be10-c8e50aac8643', created_at=datetime.datetime(2025, 8, 27, 21, 35, 48, 956144, tzinfo=TzInfo(UTC)), name='3bd766f1-sushi-test', params={}, status='RUNNING', status_reason='Task created, forwarding to ACP server', updated_at=datetime.datetime(2025, 8, 27, 21, 35, 48, 956144, tzinfo=TzInfo(UTC)))\n" + ] + } + ], + "source": [ + "# Create a new task for sushi guardrail test\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-sushi-test\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task_sushi = rpc_response.result\n", + "print(task_sushi)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "7e7feb64", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Event(id='ab1f8ec6-5bdb-4b75-9999-f6b193de3772', agent_id='a1abb90e-c673-4448-a4e2-841170568840', sequence_id=1847, task_id='1b3e7c18-b2a7-4980-be10-c8e50aac8643', content=TextContent(author='user', content='What are some popular Japanese foods?', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 27, 21, 35, 48, 983826, tzinfo=TzInfo(UTC)))\n" + ] + } + ], + "source": [ + "# Send event that might trigger sushi output guardrail\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"What are some popular Japanese foods?\"},\n", + " \"task_id\": task_sushi.id,\n", + " }\n", + ")\n", + "\n", + "event_sushi = rpc_response.result\n", + "print(event_sushi)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "33d8b0f6", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
╭───────────────────────── USER [08/27/2025 21:35:49] ─────────────────────────╮\n",
+       "│ What are some popular Japanese foods?                                        │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[96m╭─\u001b[0m\u001b[96m────────────────────────\u001b[0m\u001b[96m \u001b[0m\u001b[1;96mUSER\u001b[0m\u001b[96m [08/27/2025 21:35:49] \u001b[0m\u001b[96m────────────────────────\u001b[0m\u001b[96m─╮\u001b[0m\n", + "\u001b[96m│\u001b[0m What are some popular Japanese foods? \u001b[96m│\u001b[0m\n", + "\u001b[96m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \r" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [08/27/2025 21:35:59] ─────────────────────────╮\n",
+       "│ 🧠 Reasoning                                                                 │\n",
+       "│                                                                              │\n",
+       "│ Compiling popular Japanese foods                                             │\n",
+       "│                                                                              │\n",
+       "│ The user is asking for a list of popular Japanese foods, likely with brief   │\n",
+       "│ descriptions. I don’t need any tools for this, so I’ll compile a             │\n",
+       "│ well-rounded list that covers items like sushi, sashimi, ramen, udon,        │\n",
+       "│ tempura, and more, along with regional specialties and brief notes on        │\n",
+       "│ etiquette like using chopsticks. I’ll keep it concise for a casual reader    │\n",
+       "│ while including around 20 items with short descriptions and suggestions for  │\n",
+       "│ where to try them. This will help create a great summary!                    │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[95m╭─\u001b[0m\u001b[95m───────────────────────\u001b[0m\u001b[95m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[95m [08/27/2025 21:35:59] \u001b[0m\u001b[95m────────────────────────\u001b[0m\u001b[95m─╮\u001b[0m\n", + "\u001b[95m│\u001b[0m 🧠 \u001b[1mReasoning\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[1mCompiling popular Japanese foods\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m The user is asking for a list of popular Japanese foods, likely with brief \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m descriptions. I don’t need any tools for this, so I’ll compile a \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m well-rounded list that covers items like sushi, sashimi, ramen, udon, \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m tempura, and more, along with regional specialties and brief notes on \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m etiquette like using chopsticks. I’ll keep it concise for a casual reader \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m while including around 20 items with short descriptions and suggestions for \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m where to try them. This will help create a great summary! \u001b[95m│\u001b[0m\n", + "\u001b[95m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \r" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [08/27/2025 21:36:00] ─────────────────────────╮\n",
+       "│ Here are many popular Japanese foods, with a short description of each so    │\n",
+       "│ you know what to look for:                                                   │\n",
+       "│                                                                              │\n",
+       "│  • Sushi — Vinegared rice with raw fish or other toppings (nigiri, maki      │\n",
+       "│    rolls, chirashi).                                                         │\n",
+       "│  • Sashimi — Thinly sliced raw fish served with soy sauce and wasabi.        │\n",
+       "│  • Ramen — Wheat noodles in flavorful broth (shoyu, miso, shio, tonkotsu)    │\n",
+       "│    with toppings like chashu pork and egg.                                   │\n",
+       "│  • Tempura — Lightly battered and deep-fried seafood or vegetables.          │\n",
+       "│  • Udon — Thick wheat noodles served hot in broth or chilled with a dipping  │\n",
+       "│    sauce.                                                                    │\n",
+       "│  • Soba — Buckwheat noodles, served hot or cold (zaru soba is a cold,        │\n",
+       "│    dipping style).                                                           │\n",
+       "│  • Yakitori — Skewered grilled chicken (various parts) usually seasoned with │\n",
+       "│    tare or salt.                                                             │\n",
+       "│  • Okonomiyaki — Savory pancake with cabbage and choice of fillings (Osaka   │\n",
+       "│    and Hiroshima styles).                                                    │\n",
+       "│  • Takoyaki — Octopus-filled batter balls, topped with sauce, mayo and       │\n",
+       "│    bonito flakes—common street food.                                         │\n",
+       "│  • Tonkatsu — Breaded, deep-fried pork cutlet served with shredded cabbage   │\n",
+       "│    and tonkatsu sauce.                                                       │\n",
+       "│  • Gyoza — Pan-fried dumplings filled with pork and vegetables (also boiled  │\n",
+       "│    or steamed).                                                              │\n",
+       "│  • Karaage — Japanese-style fried chicken, marinated then deep-fried—crispy  │\n",
+       "│    and juicy.                                                                │\n",
+       "│  • Onigiri — Rice balls often wrapped in nori and filled with pickled plum,  │\n",
+       "│    salmon, or tuna mayo.                                                     │\n",
+       "│  • Miso soup — Soup made from miso paste with tofu, wakame seaweed and       │\n",
+       "│    scallions.                                                                │\n",
+       "│  • Bento — Packed meal box with rice, protein and side dishes—convenient and │\n",
+       "│    varied.                                                                   │\n",
+       "│  • Shabu-shabu — Hot-pot where thin meat and veggies are briefly cooked in   │\n",
+       "│    boiling broth and dipped in sauces.                                       │\n",
+       "│  • Sukiyaki — Hot-pot cooked with soy-sugar broth, sliced beef and           │\n",
+       "│    vegetables, often dipped in raw egg.                                      │\n",
+       "│  • Yakiniku — Japanese-style barbecue where you grill slices of meat at the  │\n",
+       "│    table.                                                                    │\n",
+       "│  • Kaiseki — Multi-course traditional meal emphasizing seasonal ingredients  │\n",
+       "│    and presentation (formal dining).                                         │\n",
+       "│  • Natto — Fermented soybeans with a sticky texture and strong flavor (often │\n",
+       "│    eaten with rice).                                                         │\n",
+       "│                                                                              │\n",
+       "│ Regional specialties to try:                                                 │\n",
+       "│                                                                              │\n",
+       "│  • Hakata (Fukuoka) tonkotsu ramen, Osaka takoyaki/okonomiyaki, Hokkaido     │\n",
+       "│    seafood and miso ramen, Kyoto kaiseki and yudofu (tofu hot dish).         │\n",
+       "│                                                                              │\n",
+       "│ Tips:                                                                        │\n",
+       "│                                                                              │\n",
+       "│  • Many dishes have vegetarian/vegan variations (ask about dashi, which      │\n",
+       "│    often contains fish).                                                     │\n",
+       "│  • Try street-food stalls, izakayas (pubs), ramen shops, and traditional     │\n",
+       "│    ryokan or kaiseki restaurants for authentic experiences.                  │\n",
+       "│                                                                              │\n",
+       "│ If you want, I can suggest: typical places to try any of these, simple       │\n",
+       "│ recipes, or a short list of must-tries for a first-time visitor. Which would │\n",
+       "│ you prefer?                                                                  │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[32m╭─\u001b[0m\u001b[32m───────────────────────\u001b[0m\u001b[32m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[32m [08/27/2025 21:36:00] \u001b[0m\u001b[32m────────────────────────\u001b[0m\u001b[32m─╮\u001b[0m\n", + "\u001b[32m│\u001b[0m Here are many popular Japanese foods, with a short description of each so \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m you know what to look for: \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mSushi — Vinegared rice with raw fish or other toppings (nigiri, maki \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mrolls, chirashi). \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mSashimi — Thinly sliced raw fish served with soy sauce and wasabi. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mRamen — Wheat noodles in flavorful broth (shoyu, miso, shio, tonkotsu) \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mwith toppings like chashu pork and egg. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mTempura — Lightly battered and deep-fried seafood or vegetables. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mUdon — Thick wheat noodles served hot in broth or chilled with a dipping \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0msauce. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mSoba — Buckwheat noodles, served hot or cold (zaru soba is a cold, \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mdipping style). \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mYakitori — Skewered grilled chicken (various parts) usually seasoned with \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mtare or salt. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mOkonomiyaki — Savory pancake with cabbage and choice of fillings (Osaka \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mand Hiroshima styles). \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mTakoyaki — Octopus-filled batter balls, topped with sauce, mayo and \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mbonito flakes—common street food. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mTonkatsu — Breaded, deep-fried pork cutlet served with shredded cabbage \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mand tonkatsu sauce. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mGyoza — Pan-fried dumplings filled with pork and vegetables (also boiled \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mor steamed). \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mKaraage — Japanese-style fried chicken, marinated then deep-fried—crispy \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mand juicy. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mOnigiri — Rice balls often wrapped in nori and filled with pickled plum, \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0msalmon, or tuna mayo. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mMiso soup — Soup made from miso paste with tofu, wakame seaweed and \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mscallions. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mBento — Packed meal box with rice, protein and side dishes—convenient and \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mvaried. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mShabu-shabu — Hot-pot where thin meat and veggies are briefly cooked in \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mboiling broth and dipped in sauces. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mSukiyaki — Hot-pot cooked with soy-sugar broth, sliced beef and \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mvegetables, often dipped in raw egg. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mYakiniku — Japanese-style barbecue where you grill slices of meat at the \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mtable. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mKaiseki — Multi-course traditional meal emphasizing seasonal ingredients \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mand presentation (formal dining). \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mNatto — Fermented soybeans with a sticky texture and strong flavor (often \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0meaten with rice). \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m Regional specialties to try: \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mHakata (Fukuoka) tonkotsu ramen, Osaka takoyaki/okonomiyaki, Hokkaido \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mseafood and miso ramen, Kyoto kaiseki and yudofu (tofu hot dish). \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m Tips: \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mMany dishes have vegetarian/vegan variations (ask about dashi, which \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0moften contains fish). \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m • \u001b[0mTry street-food stalls, izakayas (pubs), ramen shops, and traditional \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[1;33m \u001b[0mryokan or kaiseki restaurants for authentic experiences. \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m If you want, I can suggest: typical places to try any of these, simple \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m recipes, or a short list of must-tries for a first-time visitor. Which would \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m you prefer? \u001b[32m│\u001b[0m\n", + "\u001b[32m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \r" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [08/27/2025 21:36:07] ─────────────────────────╮\n",
+       "│ I cannot mention sushi in my response. This guardrail prevents discussions   │\n",
+       "│ about sushi for demonstration purposes. Please let me provide information    │\n",
+       "│ about other topics.                                                          │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[32m╭─\u001b[0m\u001b[32m───────────────────────\u001b[0m\u001b[32m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[32m [08/27/2025 21:36:07] \u001b[0m\u001b[32m────────────────────────\u001b[0m\u001b[32m─╮\u001b[0m\n", + "\u001b[32m│\u001b[0m I cannot mention sushi in my response. This guardrail prevents discussions \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m about sushi for demonstration purposes. Please let me provide information \u001b[32m│\u001b[0m\n", + "\u001b[32m│\u001b[0m about other topics. \u001b[32m│\u001b[0m\n", + "\u001b[32m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Streaming timed out after 30 seconds - returning collected messages\n" + ] + } + ], + "source": [ + "# Subscribe to see if sushi output guardrail triggers\n", + "task_messages_sushi = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task_sushi, \n", + " only_after_timestamp=event_sushi.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=30,\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "5ade7d59", + "metadata": {}, + "source": [ + "### Test 6: Normal Conversation (No Guardrails Triggered)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "096a8784", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Task(id='e14d5602-bc80-4023-b523-354af82dcdc2', created_at=datetime.datetime(2025, 8, 27, 21, 36, 46, 563649, tzinfo=TzInfo(UTC)), name='e8618275-normal-test', params={}, status='RUNNING', status_reason='Task created, forwarding to ACP server', updated_at=datetime.datetime(2025, 8, 27, 21, 36, 46, 563649, tzinfo=TzInfo(UTC)))\n" + ] + } + ], + "source": [ + "# Create a new task for normal conversation\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-normal-test\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task_normal = rpc_response.result\n", + "print(task_normal)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "ec04822d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Event(id='406c31f1-5eb4-4a90-bd8d-825ddbddcfcd', agent_id='a1abb90e-c673-4448-a4e2-841170568840', sequence_id=1848, task_id='e14d5602-bc80-4023-b523-354af82dcdc2', content=TextContent(author='user', content='What is 5 + 3? Use the calculator tool.', attachments=None, format='plain', style='static', type='text'), created_at=datetime.datetime(2025, 8, 27, 21, 36, 46, 593485, tzinfo=TzInfo(UTC)))\n" + ] + } + ], + "source": [ + "# Send event that won't trigger any guardrails\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"What is 5 + 3? Use the calculator tool.\"},\n", + " \"task_id\": task_normal.id,\n", + " }\n", + ")\n", + "\n", + "event_normal = rpc_response.result\n", + "print(event_normal)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "3ab67e94", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
╭───────────────────────── USER [08/27/2025 21:36:46] ─────────────────────────╮\n",
+       "│ What is 5 + 3? Use the calculator tool.                                      │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[96m╭─\u001b[0m\u001b[96m────────────────────────\u001b[0m\u001b[96m \u001b[0m\u001b[1;96mUSER\u001b[0m\u001b[96m [08/27/2025 21:36:46] \u001b[0m\u001b[96m────────────────────────\u001b[0m\u001b[96m─╮\u001b[0m\n", + "\u001b[96m│\u001b[0m What is 5 + 3? Use the calculator tool. \u001b[96m│\u001b[0m\n", + "\u001b[96m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \r" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [08/27/2025 21:36:49] ─────────────────────────╮\n",
+       "│ 🧠 Reasoning                                                                 │\n",
+       "│                                                                              │\n",
+       "│ I see the user wants to do a simple addition and prefers using the           │\n",
+       "│ calculator tool. I'll call the functions.calculator with parameters a=5,     │\n",
+       "│ b=3, and the operation set to \"add.\" It's pretty straightforward, and        │\n",
+       "│ there's no need for sequential thinking here. Just a direct call to the tool │\n",
+       "│ will do the job efficiently. So, I'll go ahead and call that function to get │\n",
+       "│ the result for the user!                                                     │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[95m╭─\u001b[0m\u001b[95m───────────────────────\u001b[0m\u001b[95m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[95m [08/27/2025 21:36:49] \u001b[0m\u001b[95m────────────────────────\u001b[0m\u001b[95m─╮\u001b[0m\n", + "\u001b[95m│\u001b[0m 🧠 \u001b[1mReasoning\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m I see the user wants to do a simple addition and prefers using the \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m calculator tool. I'll call the functions.calculator with parameters a=5, \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m b=3, and the operation set to \"add.\" It's pretty straightforward, and \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m there's no need for sequential thinking here. Just a direct call to the tool \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m will do the job efficiently. So, I'll go ahead and call that function to get \u001b[95m│\u001b[0m\n", + "\u001b[95m│\u001b[0m the result for the user! \u001b[95m│\u001b[0m\n", + "\u001b[95m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \r" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [08/27/2025 21:36:51] ─────────────────────────╮\n",
+       "│ 🔧 Tool Request: calculator                                                  │\n",
+       "│                                                                              │\n",
+       "│ Arguments:                                                                   │\n",
+       "│                                                                              │\n",
+       "│                                                                              │\n",
+       "│  {                                                                           │\n",
+       "│    \"a\": 5,                                                                   │\n",
+       "│    \"b\": 3,                                                                   │\n",
+       "│    \"operation\": \"add\"                                                        │\n",
+       "│  }                                                                           │\n",
+       "│                                                                              │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[33m╭─\u001b[0m\u001b[33m───────────────────────\u001b[0m\u001b[33m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[33m [08/27/2025 21:36:51] \u001b[0m\u001b[33m────────────────────────\u001b[0m\u001b[33m─╮\u001b[0m\n", + "\u001b[33m│\u001b[0m 🔧 \u001b[1mTool Request: calculator\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[1mArguments:\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m{\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"a\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;174;129;255;48;2;39;40;34m5\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"b\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;174;129;255;48;2;39;40;34m3\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m,\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;255;70;137;48;2;39;40;34m\"operation\"\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m:\u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m \u001b[0m\u001b[38;2;230;219;116;48;2;39;40;34m\"add\"\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m\u001b[38;2;248;248;242;48;2;39;40;34m}\u001b[0m\u001b[48;2;39;40;34m \u001b[0m\u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m│\u001b[0m \u001b[48;2;39;40;34m \u001b[0m \u001b[33m│\u001b[0m\n", + "\u001b[33m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " \r" + ] + }, + { + "data": { + "text/html": [ + "
╭──────────────────────── AGENT [08/27/2025 21:36:51] ─────────────────────────╮\n",
+       "│ ✅ Tool Response: calculator                                                 │\n",
+       "│                                                                              │\n",
+       "│ The result of 5.0 add 3.0 is 8                                               │\n",
+       "╰──────────────────────────────────────────────────────────────────────────────╯\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[92m╭─\u001b[0m\u001b[92m───────────────────────\u001b[0m\u001b[92m \u001b[0m\u001b[1;32mAGENT\u001b[0m\u001b[92m [08/27/2025 21:36:51] \u001b[0m\u001b[92m────────────────────────\u001b[0m\u001b[92m─╮\u001b[0m\n", + "\u001b[92m│\u001b[0m ✅ \u001b[1mTool Response: calculator\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m \u001b[92m│\u001b[0m\n", + "\u001b[92m│\u001b[0m The result of 5.0 add 3.0 is 8 \u001b[92m│\u001b[0m\n", + "\u001b[92m╰──────────────────────────────────────────────────────────────────────────────╯\u001b[0m\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Streaming timed out after 30 seconds - returning collected messages\n" + ] + } + ], + "source": [ + "# Subscribe to see normal response without guardrails\n", + "task_messages_normal = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task_normal, \n", + " only_after_timestamp=event_normal.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=30,\n", + ")\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/manifest.yaml b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/manifest.yaml new file mode 100644 index 000000000..3fe94a001 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/manifest.yaml @@ -0,0 +1,139 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../../../ # Up to tutorials level to include test_utils + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - 10_async/10_temporal/050_agent_chat_guardrails + - test_utils + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: 10_async/10_temporal/050_agent_chat_guardrails/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: 10_async/10_temporal/050_agent_chat_guardrails/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + # Path to temporal worker file + # Examples: + # project/run_worker.py (standard) + # workers/temporal.py (custom structure) + # ../shared/worker.py (shared across projects) + worker: project/run_worker.py + + +# Agent Configuration +# ----------------- +agent: + # Type of agent - either sync or async + acp_type: async + + # Unique name for your agent + # Used for task routing and monitoring + name: at050-agent-chat-guardrails + + # Description of what your agent does + # Helps with documentation and discovery + description: An AgentEx agent that demonstrates guardrails with tool-enabled multiturn chat + + # Temporal workflow configuration + # This enables your agent to run as a Temporal workflow for long-running tasks + temporal: + enabled: true + workflows: + # Name of the workflow class + # Must match the @workflow.defn name in your workflow.py + - name: at050-agent-chat-guardrails + + # Queue name for task distribution + # Used by Temporal to route tasks to your agent + # Convention: _task_queue + queue_name: 050_agent_chat_guardrails_queue + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + # credentials: + # - env_var_name: OPENAI_API_KEY + # secret_name: openai-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + # env: + # - name: OPENAI_BASE_URL + # value: "https://api.openai.com/v1" + # - name: ACCOUNT_ID + # value: "your_account_id_here" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: + - name: my-registry-secret # Update with your image pull secret name + + # Global deployment settings that apply to all clusters + # These can be overridden using --override-file with custom configuration files + global: + agent: + name: "at050-agent-chat-guardrails" + description: "An AgentEx agent that demonstrates guardrails with tool-enabled multiturn chat" + + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/__init__.py b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/acp.py b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/acp.py new file mode 100644 index 000000000..744068d77 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/acp.py @@ -0,0 +1,30 @@ +import os + +from agentex.lib.types.fastacp import TemporalACPConfig +from agentex.lib.sdk.fastacp.fastacp import FastACP + +# Create the ACP server +acp = FastACP.create( + acp_type="async", + config=TemporalACPConfig( + # When deployed to the cluster, the Temporal address will automatically be set to the cluster address + # For local development, we set the address manually to talk to the local Temporal service set up via docker compose + type="temporal", + temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233") + ) +) + + +# Notice that we don't need to register any handlers when we use type="temporal" +# If you look at the code in agentex.sdk.fastacp.impl.temporal_acp +# You can see that these handlers are automatically registered when the ACP is created + +# @acp.on_task_create +# This will be handled by the method in your workflow that is decorated with @workflow.run + +# @acp.on_task_event_send +# This will be handled by the method in your workflow that is decorated with @workflow.signal(name=SignalName.RECEIVE_MESSAGE) + +# @acp.on_task_cancel +# This does not need to be handled by your workflow. +# It is automatically handled by the temporal client which cancels the workflow directly \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/run_worker.py b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/run_worker.py new file mode 100644 index 000000000..636e99774 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/run_worker.py @@ -0,0 +1,34 @@ +import asyncio + +from project.workflow import At050AgentChatGuardrailsWorkflow +from agentex.lib.utils.debug import setup_debug_if_enabled +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.activities import get_all_activities +from agentex.lib.core.temporal.workers.worker import AgentexWorker + +environment_variables = EnvironmentVariables.refresh() + +logger = make_logger(__name__) + + +async def main(): + # Setup debug mode if enabled + setup_debug_if_enabled() + + task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE + if task_queue_name is None: + raise ValueError("WORKFLOW_TASK_QUEUE is not set") + + # Create a worker with automatic tracing + worker = AgentexWorker( + task_queue=task_queue_name, + ) + + await worker.run( + activities=get_all_activities(), + workflow=At050AgentChatGuardrailsWorkflow, + ) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/workflow.py b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/workflow.py new file mode 100644 index 000000000..b54c8fade --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/workflow.py @@ -0,0 +1,481 @@ +# ruff: noqa: ARG001 +from __future__ import annotations + +import os +import json +from typing import Any, Dict, List, override + +from mcp import StdioServerParameters +from agents import ModelSettings, RunContextWrapper +from dotenv import load_dotenv + +# Simple guardrail output model for this example +from pydantic import BaseModel +from temporalio import workflow +from openai.types.shared import Reasoning + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CreateTaskParams +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.types.workflow import SignalName +from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow +from agentex.lib.core.tracing.tracing_processor_manager import ( + add_tracing_processor_config, +) +from agentex.lib.core.temporal.activities.adk.providers.openai_activities import ( # noqa: E501 + FunctionTool, + TemporalInputGuardrail, + TemporalOutputGuardrail, +) + + +class GuardrailFunctionOutput(BaseModel): + """Output from a guardrail function.""" + + output_info: Dict[str, Any] + tripwire_triggered: bool + + +# Type alias for the agent parameter in guardrail functions +Agent = Any + +environment_variables = EnvironmentVariables.refresh() +load_dotenv(dotenv_path=".env") + +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SCALE_GP_API_KEY", ""), + sgp_account_id=os.environ.get("SCALE_GP_ACCOUNT_ID", ""), + ) +) + +if not environment_variables.WORKFLOW_NAME: + raise ValueError("Environment variable WORKFLOW_NAME is not set") + +if not environment_variables.AGENT_NAME: + raise ValueError("Environment variable AGENT_NAME is not set") + +logger = make_logger(__name__) + + +class StateModel(BaseModel): + input_list: List[Dict[str, Any]] + turn_number: int + + +MCP_SERVERS = [ + StdioServerParameters( + command="npx", + args=["-y", "@modelcontextprotocol/server-sequential-thinking"], + ), + StdioServerParameters( + command="uvx", + args=["openai-websearch-mcp"], + env={"OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY", "")}, + ), +] + + +async def calculator(context: RunContextWrapper, args: str) -> str: # noqa: ARG001 + """ + Simple calculator that can perform basic arithmetic operations. + + Args: + context: The run context wrapper + args: JSON string containing the operation and operands + + Returns: + String representation of the calculation result + """ + try: + # Parse the JSON arguments + parsed_args = json.loads(args) + operation = parsed_args.get("operation") + a = parsed_args.get("a") + b = parsed_args.get("b") + + if operation is None or a is None or b is None: + return "Error: Missing required parameters. Please provide 'operation', 'a', and 'b'." + + # Convert to numbers + try: + a = float(a) + b = float(b) + except (ValueError, TypeError): + return "Error: 'a' and 'b' must be valid numbers." + + # Perform the calculation + if operation == "add": + result = a + b + elif operation == "subtract": + result = a - b + elif operation == "multiply": + result = a * b + elif operation == "divide": + if b == 0: + return "Error: Division by zero is not allowed." + result = a / b + else: + supported_ops = "add, subtract, multiply, divide" + return f"Error: Unknown operation '{operation}'. Supported operations: {supported_ops}." + + # Format the result nicely + if result == int(result): + return f"The result of {a} {operation} {b} is {int(result)}" + else: + formatted = f"{result:.6f}".rstrip("0").rstrip(".") + return f"The result of {a} {operation} {b} is {formatted}" + + except json.JSONDecodeError: + return "Error: Invalid JSON format in arguments." + except Exception as e: + return f"Error: An unexpected error occurred: {str(e)}" + + +""" +Guardrails for Testing: +- Input Guardrails: + - Spaghetti: Blocks any mention of "spaghetti" in user messages + - Soup: Blocks any mention of "soup" in user messages +- Output Guardrails: + - Pizza: Blocks the AI from mentioning "pizza" in responses + - Sushi: Blocks the AI from mentioning "sushi" in responses + +To test: +- Input: "Tell me about spaghetti" or "What's your favorite soup?" +- Output: Ask "What are popular Italian foods?" (might trigger pizza guardrail) + or "What are popular Japanese foods?" (might trigger sushi guardrail) +""" + + +# Define the spaghetti guardrail function +async def check_spaghetti_guardrail( + ctx: RunContextWrapper[None], agent: Agent, input: str | list +) -> GuardrailFunctionOutput: + """ + A simple guardrail that checks if 'spaghetti' is mentioned in the input. + """ + # Convert input to string to check + input_text = "" + if isinstance(input, str): + input_text = input.lower() + elif isinstance(input, list): + # For list of messages, check all user messages + for msg in input: + if isinstance(msg, dict) and msg.get("role") == "user": + content = msg.get("content", "") + if isinstance(content, str): + input_text += " " + content.lower() + + # Check if spaghetti is mentioned + contains_spaghetti = "spaghetti" in input_text + + return GuardrailFunctionOutput( + output_info={ + "contains_spaghetti": contains_spaghetti, + "checked_text": (input_text[:200] + "..." if len(input_text) > 200 else input_text), + "rejection_message": ( + "I'm sorry, but I cannot process messages about spaghetti. " + "This guardrail was put in place for demonstration purposes. " + "Please ask me about something else!" + ) + if contains_spaghetti + else None, + }, + tripwire_triggered=contains_spaghetti, + ) + + +# Define soup input guardrail function +async def check_soup_guardrail( + ctx: RunContextWrapper[None], agent: Agent, input: str | list +) -> GuardrailFunctionOutput: + """ + A guardrail that checks if 'soup' is mentioned in the input. + """ + # Convert input to string to check + input_text = "" + if isinstance(input, str): + input_text = input.lower() + elif isinstance(input, list): + # For list of messages, check all user messages + for msg in input: + if isinstance(msg, dict) and msg.get("role") == "user": + content = msg.get("content", "") + if isinstance(content, str): + input_text += " " + content.lower() + + # Check if soup is mentioned + contains_soup = "soup" in input_text + + return GuardrailFunctionOutput( + output_info={ + "contains_soup": contains_soup, + "checked_text": (input_text[:200] + "..." if len(input_text) > 200 else input_text), + "rejection_message": ( + "I'm sorry, but I cannot process messages about soup. " + "This is a demonstration guardrail for testing purposes. " + "Please ask about something other than soup!" + ) + if contains_soup + else None, + }, + tripwire_triggered=contains_soup, + ) + + +# Create the input guardrails +SPAGHETTI_GUARDRAIL = TemporalInputGuardrail(guardrail_function=check_spaghetti_guardrail, name="spaghetti_guardrail") + +SOUP_GUARDRAIL = TemporalInputGuardrail(guardrail_function=check_soup_guardrail, name="soup_guardrail") + + +# Define pizza output guardrail function +async def check_pizza_guardrail(ctx: RunContextWrapper[None], agent: Agent, output: str) -> GuardrailFunctionOutput: + """ + An output guardrail that prevents mentioning pizza. + """ + output_text = output.lower() if isinstance(output, str) else "" + contains_pizza = "pizza" in output_text + + return GuardrailFunctionOutput( + output_info={ + "contains_pizza": contains_pizza, + "rejection_message": ( + "I cannot provide this response as it mentions pizza. " + "Due to content policies, I need to avoid discussing pizza. " + "Let me provide a different response." + ) + if contains_pizza + else None, + }, + tripwire_triggered=contains_pizza, + ) + + +# Define sushi output guardrail function +async def check_sushi_guardrail(ctx: RunContextWrapper[None], agent: Agent, output: str) -> GuardrailFunctionOutput: + """ + An output guardrail that prevents mentioning sushi. + """ + output_text = output.lower() if isinstance(output, str) else "" + contains_sushi = "sushi" in output_text + + return GuardrailFunctionOutput( + output_info={ + "contains_sushi": contains_sushi, + "rejection_message": ( + "I cannot mention sushi in my response. " + "This guardrail prevents discussions about sushi for demonstration purposes. " + "Please let me provide information about other topics." + ) + if contains_sushi + else None, + }, + tripwire_triggered=contains_sushi, + ) + + +# Create the output guardrails +PIZZA_GUARDRAIL = TemporalOutputGuardrail(guardrail_function=check_pizza_guardrail, name="pizza_guardrail") + +SUSHI_GUARDRAIL = TemporalOutputGuardrail(guardrail_function=check_sushi_guardrail, name="sushi_guardrail") + + +# Example output guardrail function (kept for reference) +async def check_output_length_guardrail( + ctx: RunContextWrapper[None], agent: Agent, output: str +) -> GuardrailFunctionOutput: + """ + A simple output guardrail that checks if the response is too long. + """ + # Check the length of the output + max_length = 1000 # Maximum allowed characters + is_too_long = len(output) > max_length if isinstance(output, str) else False + + return GuardrailFunctionOutput( + output_info={ + "output_length": len(output) if isinstance(output, str) else 0, + "max_length": max_length, + "is_too_long": is_too_long, + "rejection_message": ( + f"I'm sorry, but my response is too long ({len(output)} characters). " + f"Please ask a more specific question so I can provide a concise answer " + f"(max {max_length} characters)." + ) + if is_too_long + else None, + }, + tripwire_triggered=is_too_long, + ) + + +# Uncomment to use the output guardrail +# from agentex.lib.core.temporal.activities.adk.providers.openai_activities import TemporalOutputGuardrail +# OUTPUT_LENGTH_GUARDRAIL = TemporalOutputGuardrail( +# guardrail_function=check_output_length_guardrail, +# name="output_length_guardrail" +# ) + + +# Create the calculator tool +CALCULATOR_TOOL = FunctionTool( + name="calculator", + description=("Performs basic arithmetic operations (add, subtract, multiply, divide) on two numbers."), + params_json_schema={ + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["add", "subtract", "multiply", "divide"], + "description": "The arithmetic operation to perform", + }, + "a": {"type": "number", "description": "The first number"}, + "b": {"type": "number", "description": "The second number"}, + }, + "required": ["operation", "a", "b"], + "additionalProperties": False, + }, + strict_json_schema=True, + on_invoke_tool=calculator, +) + + +@workflow.defn(name=environment_variables.WORKFLOW_NAME) +class At050AgentChatGuardrailsWorkflow(BaseWorkflow): + """ + Minimal async workflow template for AgentEx Temporal agents. + """ + + def __init__(self): + super().__init__(display_name=environment_variables.AGENT_NAME) + self._complete_task = False + self._state: StateModel | None = None + + @workflow.signal(name=SignalName.RECEIVE_EVENT) + @override + async def on_task_event_send(self, params: SendEventParams) -> None: + if not params.event.content: + return + if params.event.content.type != "text": + raise ValueError(f"Expected text message, got {params.event.content.type}") + + if params.event.content.author != "user": + raise ValueError(f"Expected user message, got {params.event.content.author}") + + if self._state is None: + raise ValueError("State is not initialized") + + # Increment the turn number + self._state.turn_number += 1 + # Add the new user message to the message history + self._state.input_list.append({"role": "user", "content": params.event.content.content}) + + async with adk.tracing.span( + trace_id=params.task.id, + name=f"Turn {self._state.turn_number}", + input=self._state, + ) as span: + # Echo back the user's message so it shows up in the UI. + # This is not done by default so the agent developer has full + # control over what is shown to the user. + await adk.messages.create( + task_id=params.task.id, + trace_id=params.task.id, + content=params.event.content, + parent_span_id=span.id if span else None, + ) + + if not os.environ.get("OPENAI_API_KEY"): + await adk.messages.create( + task_id=params.task.id, + trace_id=params.task.id, + content=TextContent( + author="agent", + content=( + "Hey, sorry I'm unable to respond to your message " + "because you're running this example without an " + "OpenAI API key. Please set the OPENAI_API_KEY " + "environment variable to run this example. Do this " + "by either by adding a .env file to the project/ " + "directory or by setting the environment variable " + "in your terminal." + ), + ), + parent_span_id=span.id if span else None, + ) + + # Call an LLM to respond to the user's message + # When send_as_agent_task_message=True, returns a TaskMessage + result = await adk.providers.openai.run_agent_streamed_auto_send( + task_id=params.task.id, + trace_id=params.task.id, + input_list=self._state.input_list, + mcp_server_params=MCP_SERVERS, + agent_name="Tool-Enabled Assistant", + agent_instructions=( + "You are a helpful assistant that can answer " + "questions using various tools. You have access to " + "sequential thinking and web search capabilities " + "through MCP servers, as well as a calculator tool " + "for performing basic arithmetic operations. Use " + "these tools when appropriate to provide accurate " + "and well-reasoned responses." + ), + parent_span_id=span.id if span else None, + model="gpt-5-mini", + model_settings=ModelSettings( + # Include reasoning items in the response + # (IDs, summaries) + # response_include=["reasoning.encrypted_content"], + # Ask the model to include a short reasoning summary + reasoning=Reasoning(effort="medium", summary="detailed"), + ), + tools=[CALCULATOR_TOOL], + input_guardrails=[SPAGHETTI_GUARDRAIL, SOUP_GUARDRAIL], + output_guardrails=[PIZZA_GUARDRAIL, SUSHI_GUARDRAIL], + ) + + # Update state with the final input list from result + if self._state and result: + final_list = getattr(result, "final_input_list", None) + if final_list is not None: + self._state.input_list = final_list + + # Set the span output to the state for the next turn + if span and self._state: + span.output = self._state.model_dump() + + @workflow.run + @override + async def on_task_create(self, params: CreateTaskParams) -> None: + logger.info(f"Received task create params: {params}") + + # 1. Initialize the state. You can either do this here or in the + # __init__ method. This function is triggered whenever a client + # creates a task for this agent. It is not re-triggered when a new + # event is sent to the task. + self._state = StateModel( + input_list=[], + turn_number=0, + ) + + # 2. Wait for the task to be completed indefinitely. If we don't do + # this the workflow will close as soon as this function returns. + # Temporal can run hundreds of millions of workflows in parallel, + # so you don't need to worry about too many workflows running at once. + + # Thus, if you want this agent to field events indefinitely (or for + # a long time) you need to wait for a condition to be met. + + await workflow.wait_condition( + lambda: self._complete_task, + timeout=None, # Set a timeout if you want to prevent the task + # from running indefinitely. Generally this is not needed. + # Temporal can run hundreds of millions of workflows in parallel + # and more. Only do this if you have a specific reason to do so. + ) diff --git a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/pyproject.toml b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/pyproject.toml new file mode 100644 index 000000000..d3815934f --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/pyproject.toml @@ -0,0 +1,34 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "at010-agent-chat" +version = "0.1.0" +description = "An AgentEx agentthat streams multiturn tool-enabled chat with tracing" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "debugpy>=1.8.15", + "scale-gp", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/tests/test_agent.py new file mode 100644 index 000000000..1b1f7a400 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/tests/test_agent.py @@ -0,0 +1,136 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: at050-agent-chat-guardrails) +""" + +import os + +import pytest +import pytest_asyncio + +from agentex import AsyncAgentex + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "at050-agent-chat-guardrails") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_id: str): + """Test sending an event and polling for the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # TODO: Poll for the initial task creation message (if your agent sends one) + # async for message in poll_messages( + # client=client, + # task_id=task.id, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected initial message + # assert "expected initial text" in message.content.content + # break + + # TODO: Send an event and poll for response using the yielding helper function + # user_message = "Your test message here" + # async for message in send_event_and_poll_yielding( + # client=client, + # agent_id=agent_id, + # task_id=task.id, + # user_message=user_message, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected response + # assert "expected response text" in message.content.content + # break + pass + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_id: str): + """Test sending an event and streaming the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # user_message = "Your test message here" + + # # Collect events from stream + # all_events = [] + + # async def collect_stream_events(): + # async for event in stream_agent_response( + # client=client, + # task_id=task.id, + # timeout=30, + # ): + # all_events.append(event) + + # # Start streaming task + # stream_task = asyncio.create_task(collect_stream_events()) + + # # Send the event + # event_content = TextContentParam(type="text", author="user", content=user_message) + # await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + + # # Wait for streaming to complete + # await stream_task + + # # TODO: Add your validation here + # assert len(all_events) > 0, "No events received in streaming response" + pass + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/.dockerignore b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/.dockerignore new file mode 100644 index 000000000..c49489471 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/Dockerfile b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/Dockerfile new file mode 100644 index 000000000..d38075e55 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/Dockerfile @@ -0,0 +1,62 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +# Install tctl (Temporal CLI) +RUN curl -L https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_arm64.tar.gz -o /tmp/tctl.tar.gz && \ + tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ + chmod +x /usr/local/bin/tctl && \ + rm /tmp/tctl.tar.gz + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 10_async/10_temporal/060_open_ai_agents_sdk_hello_world/pyproject.toml /app/060_open_ai_agents_sdk_hello_world/pyproject.toml +COPY 10_async/10_temporal/060_open_ai_agents_sdk_hello_world/README.md /app/060_open_ai_agents_sdk_hello_world/README.md + +WORKDIR /app/060_open_ai_agents_sdk_hello_world + +# Copy the project code +COPY 10_async/10_temporal/060_open_ai_agents_sdk_hello_world/project /app/060_open_ai_agents_sdk_hello_world/project + +# Copy the test files +COPY 10_async/10_temporal/060_open_ai_agents_sdk_hello_world/tests /app/060_open_ai_agents_sdk_hello_world/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies +RUN uv pip install --system .[dev] + +WORKDIR /app/060_open_ai_agents_sdk_hello_world + +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=at060-open-ai-agents-sdk-hello-world + +# Run the ACP server using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] diff --git a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/README.md b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/README.md new file mode 100644 index 000000000..00b1fcea0 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/README.md @@ -0,0 +1,105 @@ +# [Temporal] OpenAI Agents SDK - Hello World + +**Part of the [OpenAI SDK + Temporal integration series](../README.md)** + +## What You'll Learn + +The OpenAI Agents SDK plugin automatically converts LLM calls into durable Temporal activities. When `Runner.run()` executes, the LLM invocation becomes an `invoke_model_activity` visible in Temporal UI with full observability, automatic retries, and durability. + +**Key insight:** You don't need to wrap agent calls in activities manually - the plugin handles this automatically, making non-deterministic LLM calls work seamlessly in Temporal workflows. + +## Prerequisites +- Development environment set up (see [main repo README](https://github.com/scaleapi/scale-agentex)) +- Backend services running: `make dev` from repository root (includes Temporal) +- Temporal UI available at http://localhost:8233 +- OpenAI API key configured (see setup below) +- Understanding of Temporal workflows (see [000_hello_acp](../000_hello_acp/)) + +## Setup + +This tutorial uses the OpenAI Agents SDK plugin, which needs to be added in two places: + +### 1. Add Plugin to ACP (`project/acp.py`) +```python +from agentex.lib.plugins.openai_agents import OpenAIAgentsPlugin + +acp = FastACP.create( + config=TemporalACPConfig( + plugins=[OpenAIAgentsPlugin()] # Add this + ) +) +``` + +### 2. Add Plugin to Worker (`project/run_worker.py`) +```python +from agentex.lib.plugins.openai_agents import OpenAIAgentsPlugin + +worker = AgentexWorker( + task_queue=task_queue_name, + plugins=[OpenAIAgentsPlugin()], # Add this +) +``` + +### 3. Configure OpenAI API Key +Add to `manifest.yaml`: +```yaml +secrets: + - name: OPENAI_API_KEY + value: "your-openai-api-key-here" +``` + +Or set in `.env` file: `OPENAI_API_KEY=your-key-here` + +## Quick Start + +```bash +cd examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world +uv run agentex agents run --manifest manifest.yaml +``` + +**Monitor:** Open Temporal UI at http://localhost:8233 to see automatic activity creation. + +## Try It + +1. Send a message to the agent (it responds in haikus) +2. Check the agent response: + +![Agent Response](../_images/hello_world_response.png) + +3. Open Temporal UI at http://localhost:8233 +4. Find your workflow execution +5. Look for the `invoke_model_activity` - this was created automatically: + +![Temporal UI](../_images/hello_world_temporal.png) + +6. Inspect the activity to see: + - Input parameters (your message) + - Output (agent's haiku response) + - Execution time + - Retry attempts (if any failures occurred) + +## Key Code + +```python +# This simple call automatically becomes a durable Temporal activity: +agent = Agent(name="Haiku Assistant", instructions="...") +result = await Runner.run(agent, user_message) +``` + +The magic happens behind the scenes - no manual activity wrapping needed. The conversation is now durable and survives process restarts. + +## Why This Matters + +**Durability:** If your worker crashes mid-conversation, Temporal resumes exactly where it left off. No lost context, no repeated work. + +**Observability:** Every LLM call is tracked as an activity with full execution history. + +**Reliability:** Failed LLM calls are automatically retried with exponential backoff. + +## When to Use +- Building agents with OpenAI's SDK +- Need durability for LLM calls +- Want automatic activity creation without manual wrapping +- Leveraging OpenAI's agent patterns with Temporal's durability + +**Next:** [070_open_ai_agents_sdk_tools](../070_open_ai_agents_sdk_tools/) - Add durable tools to your agents diff --git a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/dev.ipynb b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/dev.ipynb new file mode 100644 index 000000000..ae143b89f --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/dev.ipynb @@ -0,0 +1,124 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": "AGENT_NAME = \"at060-open-ai-agents-sdk-hello-world\"" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [], + "source": [ + "# (REQUIRED) Create a new task. For Agentic agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b03b0d37", + "metadata": {}, + "outputs": [], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a6927cc0", + "metadata": {}, + "outputs": [], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4864e354", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/environments.yaml b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/environments.yaml new file mode 100644 index 000000000..f90511911 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/environments.yaml @@ -0,0 +1,64 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-example-tutorial" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + # This is used to override the global helm values.yaml file in the agentex-agent helm charts + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + temporal-worker: + enabled: true + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/manifest.yaml b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/manifest.yaml new file mode 100644 index 000000000..b339542d5 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/manifest.yaml @@ -0,0 +1,140 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../../../ # Up to tutorials level to include test_utils + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - 10_async/10_temporal/060_open_ai_agents_sdk_hello_world + - test_utils + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: 10_async/10_temporal/060_open_ai_agents_sdk_hello_world/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: 10_async/10_temporal/060_open_ai_agents_sdk_hello_world/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + # Path to temporal worker file + # Examples: + # project/run_worker.py (standard) + # workers/temporal.py (custom structure) + # ../shared/worker.py (shared across projects) + worker: project/run_worker.py + + +# Agent Configuration +# ----------------- +agent: + # Type of agent - either sync or async + acp_type: async + + # Unique name for your agent + # Used for task routing and monitoring + name: at060-open-ai-agents-sdk-hello-world + + # Description of what your agent does + # Helps with documentation and discovery + description: An AgentEx agent + + # Temporal workflow configuration + # This enables your agent to run as a Temporal workflow for long-running tasks + temporal: + enabled: true + workflows: + # Name of the workflow class + # Must match the @workflow.defn name in your workflow.py + - name: at060-open-ai-agents-sdk-hello-world + + # Queue name for task distribution + # Used by Temporal to route tasks to your agent + # Convention: _task_queue + queue_name: at060_open_ai_agents_sdk_hello_world_queue + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + credentials: + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + # - env_var_name: OPENAI_API_KEY + # secret_name: openai-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + env: + # OPENAI_BASE_URL: "" + OPENAI_ORG_ID: "" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: + - name: my-registry-secret # Update with your image pull secret name + + # Global deployment settings that apply to all clusters + # These can be overridden using --override-file with custom configuration files + global: + agent: + name: "at060-open-ai-agents-sdk-hello-world" + description: "An AgentEx agent" + + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/project/__init__.py b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/project/acp.py b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/project/acp.py new file mode 100644 index 000000000..fcdbba155 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/project/acp.py @@ -0,0 +1,72 @@ +import os +import sys + +from temporalio.contrib.openai_agents import OpenAIAgentsPlugin + +# === DEBUG SETUP (AgentEx CLI Debug Support) === +if os.getenv("AGENTEX_DEBUG_ENABLED") == "true": + try: + import debugpy + debug_port = int(os.getenv("AGENTEX_DEBUG_PORT", "5679")) + debug_type = os.getenv("AGENTEX_DEBUG_TYPE", "acp") + wait_for_attach = os.getenv("AGENTEX_DEBUG_WAIT_FOR_ATTACH", "false").lower() == "true" + + # Configure debugpy + debugpy.configure(subProcess=False) + debugpy.listen(debug_port) + + print(f"🐛 [{debug_type.upper()}] Debug server listening on port {debug_port}") + + if wait_for_attach: + print(f"⏳ [{debug_type.upper()}] Waiting for debugger to attach...") + debugpy.wait_for_client() + print(f"✅ [{debug_type.upper()}] Debugger attached!") + else: + print(f"📡 [{debug_type.upper()}] Ready for debugger attachment") + + except ImportError: + print("❌ debugpy not available. Install with: pip install debugpy") + sys.exit(1) + except Exception as e: + print(f"❌ Debug setup failed: {e}") + sys.exit(1) +# === END DEBUG SETUP === + +from agentex.lib.types.fastacp import TemporalACPConfig +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model import ( + TemporalStreamingModelProvider, +) +from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ContextInterceptor + +context_interceptor = ContextInterceptor() +temporal_streaming_model_provider = TemporalStreamingModelProvider() + +# Create the ACP server +acp = FastACP.create( + acp_type="async", + config=TemporalACPConfig( + # When deployed to the cluster, the Temporal address will automatically be set to the cluster address + # For local development, we set the address manually to talk to the local Temporal service set up via docker compose + # We are also adding the Open AI Agents SDK plugin to the ACP. + type="temporal", + temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[OpenAIAgentsPlugin(model_provider=temporal_streaming_model_provider)], + interceptors=[context_interceptor] + ) +) + + +# Notice that we don't need to register any handlers when we use type="temporal" +# If you look at the code in agentex.sdk.fastacp.impl.temporal_acp +# You can see that these handlers are automatically registered when the ACP is created + +# @acp.on_task_create +# This will be handled by the method in your workflow that is decorated with @workflow.run + +# @acp.on_task_event_send +# This will be handled by the method in your workflow that is decorated with @workflow.signal(name=SignalName.RECEIVE_MESSAGE) + +# @acp.on_task_cancel +# This does not need to be handled by your workflow. +# It is automatically handled by the temporal client which cancels the workflow directly \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/project/run_worker.py b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/project/run_worker.py new file mode 100644 index 000000000..df281b586 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/project/run_worker.py @@ -0,0 +1,69 @@ +import asyncio + +from temporalio.contrib.openai_agents import OpenAIAgentsPlugin + +from project.workflow import At060OpenAiAgentsSdkHelloWorldWorkflow +from agentex.lib.utils.debug import setup_debug_if_enabled +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.activities import get_all_activities +from agentex.lib.core.temporal.workers.worker import AgentexWorker +from agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model import ( + TemporalStreamingModelProvider, +) +from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ContextInterceptor + +environment_variables = EnvironmentVariables.refresh() + +logger = make_logger(__name__) + + +async def main(): + # Setup debug mode if enabled + setup_debug_if_enabled() + + task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE + if task_queue_name is None: + raise ValueError("WORKFLOW_TASK_QUEUE is not set") + + # Add activities to the worker + all_activities = get_all_activities() + [] # add your own activities here + + # ============================================================================ + # STREAMING SETUP: Interceptor + Model Provider + # ============================================================================ + # This is where the streaming magic is configured! Two key components: + # + # 1. ContextInterceptor + # - Threads task_id through activity headers using Temporal's interceptor pattern + # - Outbound: Reads _task_id from workflow instance, injects into activity headers + # - Inbound: Extracts task_id from headers, sets streaming_task_id ContextVar + # - This enables runtime context without forking the Temporal plugin! + # + # 2. TemporalStreamingModelProvider + # - Returns TemporalStreamingModel instances that read task_id from ContextVar + # - TemporalStreamingModel.get_response() streams tokens to Redis in real-time + # - Still returns complete response to Temporal for determinism/replay safety + # - Uses AgentEx ADK streaming infrastructure (Redis XADD to stream:{task_id}) + # + # Together, these enable real-time LLM streaming while maintaining Temporal's + # durability guarantees. No forked components - uses STANDARD OpenAIAgentsPlugin! + context_interceptor = ContextInterceptor() + temporal_streaming_model_provider = TemporalStreamingModelProvider() + + # Create a worker with automatic tracing + # IMPORTANT: We use the STANDARD temporalio.contrib.openai_agents.OpenAIAgentsPlugin + # No forking needed! The interceptor + model provider handle all streaming logic. + worker = AgentexWorker( + task_queue=task_queue_name, + plugins=[OpenAIAgentsPlugin(model_provider=temporal_streaming_model_provider)], + interceptors=[context_interceptor] + ) + + await worker.run( + activities=all_activities, + workflow=At060OpenAiAgentsSdkHelloWorldWorkflow, + ) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/project/workflow.py b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/project/workflow.py new file mode 100644 index 000000000..e01f40ce6 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/project/workflow.py @@ -0,0 +1,313 @@ +""" +OpenAI Agents SDK + Temporal Integration: Hello World Tutorial + +This tutorial demonstrates the fundamental integration between OpenAI Agents SDK and Temporal workflows. +It shows how to: + +1. Set up a basic Temporal workflow with OpenAI Agents SDK +2. Create a simple agent that responds to user messages +3. See how agent conversations become durable through Temporal +4. Understand the automatic activity creation for model invocations + +KEY CONCEPTS DEMONSTRATED: +- Basic agent creation with OpenAI Agents SDK +- Temporal workflow durability for agent conversations +- Automatic activity creation for LLM calls (visible in Temporal UI) +- Long-running agent workflows that can survive restarts + +This is the foundation before moving to more advanced patterns with tools and activities. +""" + +import os +import json +from typing import Any, Dict, List + +from agents import Agent, Runner +from temporalio import workflow + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CreateTaskParams +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.types.workflow import SignalName +from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow +from agentex.lib.core.tracing.tracing_processor_manager import ( + add_tracing_processor_config, +) + +# Configure tracing processor (optional - only if you have SGP credentials) +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + ) +) + +environment_variables = EnvironmentVariables.refresh() + +if environment_variables.WORKFLOW_NAME is None: + raise ValueError("Environment variable WORKFLOW_NAME is not set") + +if environment_variables.AGENT_NAME is None: + raise ValueError("Environment variable AGENT_NAME is not set") + +# Validate OpenAI API key is set +if not os.environ.get("OPENAI_API_KEY"): + raise ValueError( + "OPENAI_API_KEY environment variable is not set. " + "This tutorial requires an OpenAI API key to run the OpenAI Agents SDK. " + "Please set OPENAI_API_KEY in your environment or manifest.yaml file." + ) + +logger = make_logger(__name__) + + +class StateModel(BaseModel): + """ + State model for preserving conversation history across turns. + + This allows the agent to maintain context throughout the conversation, + making it possible to reference previous messages and build on the discussion. + """ + + input_list: List[Dict[str, Any]] + turn_number: int + + +@workflow.defn(name=environment_variables.WORKFLOW_NAME) +class At060OpenAiAgentsSdkHelloWorldWorkflow(BaseWorkflow): + """ + Hello World Temporal Workflow with OpenAI Agents SDK Integration + + This workflow demonstrates the basic pattern for integrating OpenAI Agents SDK + with Temporal workflows. It shows how agent conversations become durable and + observable through Temporal's workflow engine. + + KEY FEATURES: + - Durable agent conversations that survive process restarts + - Automatic activity creation for LLM calls (visible in Temporal UI) + - Long-running workflows that can handle multiple user interactions + - Full observability and monitoring through Temporal dashboard + """ + + def __init__(self): + super().__init__(display_name=environment_variables.AGENT_NAME) + self._complete_task = False + self._state: StateModel | None = None + self._task_id = None + self._trace_id = None + self._parent_span_id = None + + @workflow.signal(name=SignalName.RECEIVE_EVENT) + async def on_task_event_send(self, params: SendEventParams) -> None: + """ + Handle incoming user messages and respond using OpenAI Agents SDK + + This signal handler demonstrates the basic integration pattern: + 1. Receive user message through Temporal signal + 2. Echo message back to UI for visibility + 3. Create and run OpenAI agent (automatically becomes a Temporal activity) + 4. Return agent's response to user + + TEMPORAL INTEGRATION MAGIC: + - When Runner.run() executes, it automatically creates a "invoke_model_activity" + - This activity is visible in Temporal UI with full observability + - If the LLM call fails, Temporal automatically retries it + - The entire conversation is durable and survives process restarts + """ + logger.info(f"Received task message instruction: {params}") + + if self._state is None: + raise ValueError("State is not initialized") + + # Increment turn number for tracing + self._state.turn_number += 1 + + self._task_id = params.task.id + self._trace_id = params.task.id + + # Add the user message to conversation history + self._state.input_list.append({"role": "user", "content": params.event.content.content}) + + # ============================================================================ + # STEP 1: Echo User Message + # ============================================================================ + # Echo back the client's message to show it in the UI. This is not done by default + # so the agent developer has full control over what is shown to the user. + await adk.messages.create(task_id=params.task.id, content=params.event.content) + + # ============================================================================ + # STEP 2: Wrap execution in tracing span + # ============================================================================ + # Create a span to track this turn of the conversation + async with adk.tracing.span( + trace_id=params.task.id, + name=f"Turn {self._state.turn_number}", + input=self._state.model_dump(), + ) as span: + self._parent_span_id = span.id if span else None + + # ============================================================================ + # STEP 3: Create OpenAI Agent + # ============================================================================ + # Create a simple agent using OpenAI Agents SDK. This agent will respond in haikus + # to demonstrate the basic functionality. No tools needed for this hello world example. + # + # IMPORTANT: The OpenAI Agents SDK plugin (configured in acp.py and run_worker.py) + # automatically converts agent interactions into Temporal activities for durability. + + agent = Agent( + name="Haiku Assistant", + instructions="You are a friendly assistant who always responds in the form of a haiku. " + "Each response should be exactly 3 lines following the 5-7-5 syllable pattern.", + ) + + # ============================================================================ + # STEP 4: Run Agent with Temporal Durability + Streaming + Conversation History + # ============================================================================ + # This is where the magic happens! When Runner.run() executes: + # 1. The OpenAI Agents SDK makes LLM calls to generate responses + # 2. The plugin automatically wraps these calls as Temporal activities + # 3. You'll see "invoke_model_activity" appear in the Temporal UI + # 4. If the LLM call fails, Temporal retries it automatically + # 5. The conversation state is preserved even if the worker restarts + # + # STREAMING MAGIC (via Interceptors + Model Provider): + # - The ContextInterceptor threads task_id through activity headers + # - The TemporalStreamingModelProvider returns a model that streams to Redis + # - The model streams tokens in real-time while maintaining determinism + # - Complete response is still returned to Temporal for replay safety + # + # CONVERSATION HISTORY: + # - We pass self._state.input_list which contains the full conversation history + # - This allows the agent to maintain context across multiple turns + # - The agent can reference previous messages and build on the discussion + + # IMPORTANT NOTE ABOUT AGENT RUN CALLS: + # ===================================== + # Notice that we don't need to wrap the Runner.run() call in an activity! + # This might feel weird for anyone who has used Temporal before, as typically + # non-deterministic operations like LLM calls would need to be wrapped in activities. + # However, the OpenAI Agents SDK plugin is handling all of this automatically + # behind the scenes. + # + # Another benefit of this approach is that we don't have to serialize the arguments, + # which would typically be the case with Temporal activities - the plugin handles + # all of this for us, making the developer experience much smoother. + + # Pass the conversation history to Runner.run to maintain context + # The input_list contains all previous messages in OpenAI format + result = await Runner.run(agent, self._state.input_list) + + # Update the state with the assistant's response for the next turn + # The result contains the full updated conversation including the assistant's response + if hasattr(result, "messages") and result.messages: + # Extract the assistant message from the result + # OpenAI Agents SDK returns the full conversation including the new assistant message + for msg in result.messages: + # Add new assistant messages to history + # Skip messages we already have (user messages we just added) + if msg.get("role") == "assistant" and msg not in self._state.input_list: + self._state.input_list.append(msg) + + # Set span output for tracing - include full state + span.output = self._state.model_dump() + + # ============================================================================ + # WHAT YOU'LL SEE IN TEMPORAL UI: + # ============================================================================ + # After running this: + # 1. Go to localhost:8080 (Temporal UI) + # 2. Find your workflow execution + # 3. You'll see an "invoke_model_activity" that shows: + # - Execution time for the LLM call + # - Input parameters (user message) + # - Output (agent's haiku response) + # - Retry attempts (if any failures occurred) + # + # This gives you full observability into your agent's LLM interactions! + # ============================================================================ + + @workflow.run + async def on_task_create(self, params: CreateTaskParams) -> str: + """ + Temporal Workflow Entry Point - Long-Running Agent Conversation + + This method runs when the workflow starts and keeps the agent conversation alive. + It demonstrates Temporal's ability to run workflows for extended periods (minutes, + hours, days, or even years) while maintaining full durability. + + TEMPORAL WORKFLOW LIFECYCLE: + 1. Workflow starts when a task is created + 2. Sends initial acknowledgment message to user + 3. Waits indefinitely for user messages (handled by on_task_event_send signal) + 4. Each user message triggers the signal handler which runs the OpenAI agent + 5. Workflow continues running until explicitly completed or canceled + + DURABILITY BENEFITS: + - Workflow survives worker restarts, deployments, infrastructure failures + - All agent conversation history is preserved in Temporal's event store + - Can resume from exact point of failure without losing context + - Scales to handle millions of concurrent agent conversations + """ + logger.info(f"Received task create params: {params}") + + # ============================================================================ + # WORKFLOW INITIALIZATION: Initialize State + # ============================================================================ + # Initialize the conversation state with an empty history + # This will be populated as the conversation progresses + self._state = StateModel( + input_list=[], + turn_number=0, + ) + + # ============================================================================ + # WORKFLOW INITIALIZATION: Send Welcome Message + # ============================================================================ + # Acknowledge that the task has been created and the agent is ready. + # This message appears once when the conversation starts. + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"🌸 Hello! I'm your Haiku Assistant, powered by OpenAI Agents SDK + Temporal! 🌸\n\n" + f"I'll respond to all your messages in beautiful haiku form. " + f"This conversation is now durable - even if I restart, our chat continues!\n\n" + f"Task created with params:\n{json.dumps(params.params, indent=2)}\n\n" + f"Send me a message and I'll respond with a haiku! 🎋", + ), + ) + + # ============================================================================ + # WORKFLOW PERSISTENCE: Wait for Completion Signal + # ============================================================================ + # This is the key to Temporal's power: the workflow runs indefinitely, + # handling user messages through signals (on_task_event_send) until + # explicitly told to complete. + # + # IMPORTANT: This wait_condition keeps the workflow alive and durable: + # - No timeout = workflow can run forever (perfect for ongoing conversations) + # - Temporal can handle millions of such concurrent workflows + # - If worker crashes, workflow resumes exactly where it left off + # - All conversation state is preserved in Temporal's event log + await workflow.wait_condition( + lambda: self._complete_task, + timeout=None, # No timeout = truly long-running agent conversation + ) + return "Agent conversation completed" + + @workflow.signal + async def complete_task_signal(self) -> None: + """ + Signal to gracefully complete the agent conversation workflow + + This signal can be sent to end the workflow cleanly. In a real application, + you might trigger this when a user ends the conversation or after a period + of inactivity. + """ + logger.info("Received signal to complete the agent conversation") + self._complete_task = True diff --git a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/pyproject.toml b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/pyproject.toml new file mode 100644 index 000000000..28cfa2f1d --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "at060_open_ai_agents_sdk_hello_world" +version = "0.1.0" +description = "An AgentEx agent" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk>=0.6.0", + "openai-agents>=0.4.2", + "temporalio>=1.18.2", + "scale-gp", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", + "debugpy>=1.8.15", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/tests/test_agent.py new file mode 100644 index 000000000..437a8f16c --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/tests/test_agent.py @@ -0,0 +1,132 @@ +# ci: touch to re-run tutorial integration tests for the openai-agents>=0.14.3 bump +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: example-tutorial) +""" + +import os +import uuid + +import pytest +import pytest_asyncio +from test_utils.async_utils import ( + poll_messages, + send_event_and_poll_yielding, +) + +from agentex import AsyncAgentex +from agentex.types.task_message import TaskMessage +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "at060-open-ai-agents-sdk-hello-world") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_id: str): + """Test sending an event and polling for the response.""" + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Poll for the initial task creation message + task_creation_found = False + async for message in poll_messages( + client=client, + task_id=task.id, + timeout=30, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + if message.content and message.content.type == "text" and message.content.author == "agent": + # Check for the Haiku Assistant welcome message + assert "Haiku Assistant" in message.content.content + assert "Temporal" in message.content.content + task_creation_found = True + break + + assert task_creation_found, "Task creation message not found" + + # Send event and poll for response with streaming updates + user_message = "Hello how is life?" + + # Use yield_updates=True to get all streaming chunks as they're written + final_message = None + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message, + timeout=30, + sleep_interval=1.0, + yield_updates=True, # Get updates as streaming writes chunks + ): + if message.content and message.content.type == "text" and message.content.author == "agent": + final_message = message + + # Stop polling once we get a DONE message + if message.streaming_status == "DONE": + break + + # Verify the final message has content (the haiku) + assert final_message is not None, "Should have received an agent message" + assert final_message.content is not None, "Final message should have content" + assert len(final_message.content.content) > 0, "Final message should have haiku content" + + +class TestStreamingEvents: + """Test streaming event sending (backend verification via polling).""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_id: str): + """ + Streaming test placeholder. + + NOTE: SSE streaming is tested via the UI (agentex-ui subscribeTaskState). + Backend streaming functionality is verified in test_send_event_and_poll. + """ + pass + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/.dockerignore b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/.dockerignore new file mode 100644 index 000000000..c49489471 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/Dockerfile b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/Dockerfile new file mode 100644 index 000000000..d4b343603 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/Dockerfile @@ -0,0 +1,63 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +# Install tctl (Temporal CLI) +RUN curl -L https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_arm64.tar.gz -o /tmp/tctl.tar.gz && \ + tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ + chmod +x /usr/local/bin/tctl && \ + rm /tmp/tctl.tar.gz + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 10_async/10_temporal/070_open_ai_agents_sdk_tools/pyproject.toml /app/070_open_ai_agents_sdk_tools/pyproject.toml +COPY 10_async/10_temporal/070_open_ai_agents_sdk_tools/README.md /app/070_open_ai_agents_sdk_tools/README.md + +WORKDIR /app/070_open_ai_agents_sdk_tools + +# Copy the project code +COPY 10_async/10_temporal/070_open_ai_agents_sdk_tools/project /app/070_open_ai_agents_sdk_tools/project + +# Copy the test files +COPY 10_async/10_temporal/070_open_ai_agents_sdk_tools/tests /app/070_open_ai_agents_sdk_tools/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies +RUN uv pip install --system .[dev] + +WORKDIR /app/070_open_ai_agents_sdk_tools + +# Set environment variables +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=at070-open-ai-agents-sdk-tools + +# Run the ACP server using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] diff --git a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/README.md b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/README.md new file mode 100644 index 000000000..ea2c827a4 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/README.md @@ -0,0 +1,180 @@ +# [Temporal] OpenAI Agents SDK - Tools + +**Part of the [OpenAI SDK + Temporal integration series](../README.md)** → Previous: [060 Hello World](../060_open_ai_agents_sdk_hello_world/) + +## What You'll Learn + +Two patterns for making agent tools durable with Temporal: + +**Pattern 1: `activity_as_tool()`** - Single activity per tool call +- Use for: Single API calls, DB queries, external operations +- Example: `get_weather` tool → creates one `get_weather` activity +- 1:1 mapping between tool calls and activities + +**Pattern 2: Function tools with multiple activities** - Multiple activities per tool call +- Use for: Multi-step operations needing guaranteed sequencing +- Example: `move_money` tool → creates `withdraw_money` activity THEN `deposit_money` activity +- 1:many mapping - your code controls execution order, not the LLM +- Ensures atomic operations (withdraw always happens before deposit) + +## Prerequisites +- Development environment set up (see [main repo README](https://github.com/scaleapi/scale-agentex)) +- Backend services running: `make dev` from repository root +- Temporal UI available at http://localhost:8233 +- OpenAI Agents SDK plugin configured (see [060_hello_world](../060_open_ai_agents_sdk_hello_world/)) + +## Quick Start + +```bash +cd examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools +uv run agentex agents run --manifest manifest.yaml +``` + +**Monitor:** Open Temporal UI at http://localhost:8233 to see tool calls as activities. + +## Try It + +### Pattern 1: Single Activity Tool + +Ask "What's the weather in San Francisco?" + +1. Check the agent response: + +![Weather Response](../_images/weather_response.png) + +2. Open Temporal UI (localhost:8233) +3. See a single `get_weather` activity created: + +![Weather Activity](../_images/weather_activity_tool.png) + +The activity shows the external call with retry capability. Each step (model invocation → tool call → model invocation) is durable. + +### Pattern 2: Multi-Activity Tool (Optional) + +To try the advanced banking example, uncomment the `move_money` sections in the code, then ask to move money. + +1. Check the agent response: + +![Money Transfer Response](../_images/move_money_response.png) + +2. Open Temporal UI and see TWO sequential activities: + +![Money Transfer Workflow](../_images/move_money_temporal.png) + +- First: `withdraw_money` activity executes +- Then: `deposit_money` activity executes +- Each activity shows its parameters and execution time + +**Critical insight:** If the system crashes after withdraw but before deposit, Temporal resumes exactly where it left off. The deposit will still happen - guaranteed transactional integrity. + +## Key Code + +### Pattern 1: Single Activity Tool +```python +# Define the activity +@activity.defn +async def get_weather(city: str) -> str: + """Get the weather for a given city""" + # This could be an API call - Temporal handles retries + return f"The weather in {city} is sunny" + +# Use activity_as_tool to convert it +weather_agent = Agent( + name="Weather Assistant", + instructions="Use the get_weather tool to answer weather questions.", + tools=[ + activity_as_tool(get_weather, start_to_close_timeout=timedelta(seconds=10)) + ] +) +``` + +### Pattern 2: Multi-Activity Tool +```python +# Define individual activities +@activity.defn +async def withdraw_money(from_account: str, amount: float) -> str: + # Simulate API call + await asyncio.sleep(5) + return f"Withdrew ${amount} from {from_account}" + +@activity.defn +async def deposit_money(to_account: str, amount: float) -> str: + # Simulate API call + await asyncio.sleep(10) + return f"Deposited ${amount} into {to_account}" + +# Create a function tool that orchestrates both activities +@function_tool +async def move_money(from_account: str, to_account: str, amount: float) -> str: + """Move money from one account to another""" + + # Step 1: Withdraw (becomes an activity) + await workflow.start_activity( + "withdraw_money", + args=[from_account, amount], + start_to_close_timeout=timedelta(days=1) + ) + + # Step 2: Deposit (becomes an activity) + await workflow.start_activity( + "deposit_money", + args=[to_account, amount], + start_to_close_timeout=timedelta(days=1) + ) + + return "Money transferred successfully" + +# Use the tool in your agent +money_agent = Agent( + name="Money Mover", + instructions="Use move_money to transfer funds between accounts.", + tools=[move_money] +) +``` + +## When to Use Each Pattern + +### Use Pattern 1 when: +- Tool performs a single external operation (API call, DB query) +- Operation is already idempotent +- No sequencing guarantees needed + +### Use Pattern 2 when: +- Tool requires multiple sequential operations +- Order must be guaranteed (withdraw THEN deposit) +- Operations need to be atomic from the agent's perspective +- You want transactional integrity across steps + +## Why This Matters + +**Without Temporal:** If you withdraw money but crash before depositing, you're stuck in a broken state. The money is gone from the source account with no way to recover. + +**With Temporal (Pattern 2):** +- Guaranteed execution with exact resumption after failures +- If the system crashes after withdraw, Temporal resumes and completes deposit +- Each step is tracked and retried independently +- Full observability of the entire operation + +**Key insight:** Pattern 2 moves sequencing control from the LLM (which might call tools in wrong order) to your deterministic code (which guarantees correct order). The LLM still decides *when* to call the tool, but your code controls *how* the operations execute. + +This makes agents production-ready for: +- Financial transactions +- Order fulfillment workflows +- Multi-step API integrations +- Any operation where partial completion is dangerous + +## When to Use + +**Pattern 1 (activity_as_tool):** +- Single API calls +- Database queries +- External service integrations +- Operations that are naturally atomic + +**Pattern 2 (Multi-activity tools):** +- Financial transactions requiring sequencing +- Multi-step operations with dependencies +- Operations where order matters critically +- Workflows needing guaranteed atomicity + +**Next:** [080_open_ai_agents_sdk_human_in_the_loop](../080_open_ai_agents_sdk_human_in_the_loop/) - Add human approval workflows diff --git a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/dev.ipynb b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/dev.ipynb new file mode 100644 index 000000000..bcfc7182e --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/dev.ipynb @@ -0,0 +1,124 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": "AGENT_NAME = \"at070-open-ai-agents-sdk-tools\"" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [], + "source": [ + "# (REQUIRED) Create a new task. For Agentic agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b03b0d37", + "metadata": {}, + "outputs": [], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a6927cc0", + "metadata": {}, + "outputs": [], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4864e354", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/environments.yaml b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/environments.yaml new file mode 100644 index 000000000..f90511911 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/environments.yaml @@ -0,0 +1,64 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-example-tutorial" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + # This is used to override the global helm values.yaml file in the agentex-agent helm charts + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + temporal-worker: + enabled: true + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/manifest.yaml b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/manifest.yaml new file mode 100644 index 000000000..d28da57b6 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/manifest.yaml @@ -0,0 +1,139 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../../../ # Up to tutorials level to include test_utils + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - 10_async/10_temporal/070_open_ai_agents_sdk_tools + - test_utils + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: 10_async/10_temporal/070_open_ai_agents_sdk_tools/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: 10_async/10_temporal/070_open_ai_agents_sdk_tools/.dockerignore + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + # Path to temporal worker file + # Examples: + # project/run_worker.py (standard) + # workers/temporal.py (custom structure) + # ../shared/worker.py (shared across projects) + worker: project/run_worker.py + +# Agent Configuration +# ----------------- +agent: + # Type of agent - either sync or async + acp_type: async + + # Unique name for your agent + # Used for task routing and monitoring + name: at070-open-ai-agents-sdk-tools + + # Description of what your agent does + # Helps with documentation and discovery + description: An AgentEx agent + + # Temporal workflow configuration + # This enables your agent to run as a Temporal workflow for long-running tasks + temporal: + enabled: true + workflows: + # Name of the workflow class + # Must match the @workflow.defn name in your workflow.py + - name: at070-open-ai-agents-sdk-tools + + # Queue name for task distribution + # Used by Temporal to route tasks to your agent + # Convention: _task_queue + queue_name: at070_open_ai_agents_sdk_tools_queue + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + credentials: + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + # - env_var_name: OPENAI_API_KEY + # secret_name: openai-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + env: + # OPENAI_BASE_URL: "" + OPENAI_ORG_ID: "" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: + - name: my-registry-secret # Update with your image pull secret name + + # Global deployment settings that apply to all clusters + # These can be overridden using --override-file with custom configuration files + global: + agent: + name: "at070-open-ai-agents-sdk-tools" + description: "An AgentEx agent" + + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + diff --git a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/__init__.py b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/acp.py b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/acp.py new file mode 100644 index 000000000..3028093b9 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/acp.py @@ -0,0 +1,72 @@ +import os +import sys + +from temporalio.contrib.openai_agents import OpenAIAgentsPlugin + +# === DEBUG SETUP (AgentEx CLI Debug Support) === +if os.getenv("AGENTEX_DEBUG_ENABLED") == "true": + try: + import debugpy + debug_port = int(os.getenv("AGENTEX_DEBUG_PORT", "5679")) + debug_type = os.getenv("AGENTEX_DEBUG_TYPE", "acp") + wait_for_attach = os.getenv("AGENTEX_DEBUG_WAIT_FOR_ATTACH", "false").lower() == "true" + + # Configure debugpy + debugpy.configure(subProcess=False) + debugpy.listen(debug_port) + + print(f"🐛 [{debug_type.upper()}] Debug server listening on port {debug_port}") + + if wait_for_attach: + print(f"⏳ [{debug_type.upper()}] Waiting for debugger to attach...") + debugpy.wait_for_client() + print(f"✅ [{debug_type.upper()}] Debugger attached!") + else: + print(f"📡 [{debug_type.upper()}] Ready for debugger attachment") + + except ImportError: + print("❌ debugpy not available. Install with: pip install debugpy") + sys.exit(1) + except Exception as e: + print(f"❌ Debug setup failed: {e}") + sys.exit(1) +# === END DEBUG SETUP === + +from agentex.lib.types.fastacp import TemporalACPConfig +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model import ( + TemporalStreamingModelProvider, +) +from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ContextInterceptor + +context_interceptor = ContextInterceptor() +temporal_streaming_model_provider = TemporalStreamingModelProvider() + +# Create the ACP server +acp = FastACP.create( + acp_type="async", + config=TemporalACPConfig( + # When deployed to the cluster, the Temporal address will automatically be set to the cluster address + # For local development, we set the address manually to talk to the local Temporal service set up via docker compose + # We are also adding the Open AI Agents SDK plugin to the ACP. + type="temporal", + temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[OpenAIAgentsPlugin(model_provider=temporal_streaming_model_provider)], + interceptors=[context_interceptor] + ) +) + + +# Notice that we don't need to register any handlers when we use type="temporal" +# If you look at the code in agentex.sdk.fastacp.impl.temporal_acp +# You can see that these handlers are automatically registered when the ACP is created + +# @acp.on_task_create +# This will be handled by the method in your workflow that is decorated with @workflow.run + +# @acp.on_task_event_send +# This will be handled by the method in your workflow that is decorated with @workflow.signal(name=SignalName.RECEIVE_MESSAGE) + +# @acp.on_task_cancel +# This does not need to be handled by your workflow. +# It is automatically handled by the temporal client which cancels the workflow directly \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/activities.py b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/activities.py new file mode 100644 index 000000000..35ab678dc --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/activities.py @@ -0,0 +1,104 @@ +import random +import asyncio + +from temporalio import activity + +from agentex.lib.utils.logging import make_logger + +logger = make_logger(__name__) +# ============================================================================ +# Temporal Activities for OpenAI Agents SDK Integration +# ============================================================================ +# This file defines Temporal activities that can be used in two different patterns: +# +# PATTERN 1: Direct conversion to agent tools using activity_as_tool() +# PATTERN 2: Called internally by function_tools for multi-step operations +# +# Activities represent NON-DETERMINISTIC operations that need durability: +# - API calls, database queries, file I/O, network operations +# - Any operation that could fail and needs automatic retries +# - Operations with variable latency or external dependencies + +# ============================================================================ +# PATTERN 1 EXAMPLE: Simple External Tool as Activity +# ============================================================================ +# This activity demonstrates PATTERN 1 usage: +# - Single non-deterministic operation (simulated API call) +# - Converted directly to an agent tool using activity_as_tool() +# - Each tool call creates exactly ONE activity in the workflow + +@activity.defn +async def get_weather(city: str) -> str: + """Get the weather for a given city. + + PATTERN 1 USAGE: This activity gets converted to an agent tool using: + activity_as_tool(get_weather, start_to_close_timeout=timedelta(seconds=10)) + + When the agent calls the weather tool: + 1. This activity runs with Temporal durability guarantees + 2. If it fails, Temporal automatically retries it + 3. The result is returned directly to the agent + """ + # Simulate API call to weather service + if city == "New York City": + return "The weather in New York City is 22 degrees Celsius" + else: + return "The weather is unknown" + +# ============================================================================ +# PATTERN 2 EXAMPLES: Activities Used Within Function Tools +# ============================================================================ +# These activities demonstrate PATTERN 2 usage: +# - Called internally by the move_money function tool (see tools.py) +# - Multiple activities coordinated by a single tool +# - Guarantees execution sequence and atomicity + +@activity.defn +async def withdraw_money(from_account: str, amount: float) -> str: + """Withdraw money from an account. + + PATTERN 2 USAGE: This activity is called internally by the move_money tool. + It's NOT converted to an agent tool directly - instead, it's orchestrated + by code inside the function_tool to guarantee proper sequencing. + """ + # Simulate variable API call latency (realistic for banking operations) + random_delay = random.randint(1, 5) + await asyncio.sleep(random_delay) + + # In a real implementation, this would make an API call to a banking service + logger.info(f"Withdrew ${amount} from {from_account}") + return f"Successfully withdrew ${amount} from {from_account}" + +@activity.defn +async def deposit_money(to_account: str, amount: float) -> str: + """Deposit money into an account. + + PATTERN 2 USAGE: This activity is called internally by the move_money tool + AFTER the withdraw_money activity succeeds. This guarantees the proper + sequence: withdraw → deposit, making the operation atomic. + """ + # Simulate banking API latency + await asyncio.sleep(2) + + # In a real implementation, this would make an API call to a banking service + logger.info(f"Successfully deposited ${amount} into {to_account}") + return f"Successfully deposited ${amount} into {to_account}" + +# ============================================================================ +# KEY INSIGHTS: +# ============================================================================ +# +# 1. ACTIVITY DURABILITY: All activities are automatically retried by Temporal +# if they fail, providing resilience against network issues, service outages, etc. +# +# 2. PATTERN 1 vs PATTERN 2 CHOICE: +# - Use Pattern 1 for simple, independent operations +# - Use Pattern 2 when you need guaranteed sequencing of multiple operations +# +# 3. OBSERVABILITY: Each activity execution appears in the Temporal UI with: +# - Execution time, retry attempts, input parameters, return values +# - Full traceability from agent tool call to activity execution +# +# 4. PARAMETERS: Notice how Pattern 2 activities now accept proper parameters +# (from_account, to_account, amount) that get passed through from the tool +# ============================================================================ diff --git a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/run_worker.py b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/run_worker.py new file mode 100644 index 000000000..4aa50e182 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/run_worker.py @@ -0,0 +1,71 @@ +import asyncio + +from temporalio.contrib.openai_agents import OpenAIAgentsPlugin + +from project.workflow import At070OpenAiAgentsSdkToolsWorkflow +from project.activities import get_weather, deposit_money, withdraw_money +from agentex.lib.utils.debug import setup_debug_if_enabled +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.activities import get_all_activities +from agentex.lib.core.temporal.workers.worker import AgentexWorker +from agentex.lib.core.temporal.plugins.openai_agents.hooks.activities import stream_lifecycle_content +from agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model import ( + TemporalStreamingModelProvider, +) +from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ContextInterceptor + +environment_variables = EnvironmentVariables.refresh() + +logger = make_logger(__name__) + + +async def main(): + # Setup debug mode if enabled + setup_debug_if_enabled() + + task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE + if task_queue_name is None: + raise ValueError("WORKFLOW_TASK_QUEUE is not set") + + # Add activities to the worker + all_activities = get_all_activities() + [withdraw_money, deposit_money, get_weather, stream_lifecycle_content] # add your own activities here + + # ============================================================================ + # STREAMING SETUP: Interceptor + Model Provider + # ============================================================================ + # This is where the streaming magic is configured! Two key components: + # + # 1. ContextInterceptor + # - Threads task_id through activity headers using Temporal's interceptor pattern + # - Outbound: Reads _task_id from workflow instance, injects into activity headers + # - Inbound: Extracts task_id from headers, sets streaming_task_id ContextVar + # - This enables runtime context without forking the Temporal plugin! + # + # 2. TemporalStreamingModelProvider + # - Returns TemporalStreamingModel instances that read task_id from ContextVar + # - TemporalStreamingModel.get_response() streams tokens to Redis in real-time + # - Still returns complete response to Temporal for determinism/replay safety + # - Uses AgentEx ADK streaming infrastructure (Redis XADD to stream:{task_id}) + # + # Together, these enable real-time LLM streaming while maintaining Temporal's + # durability guarantees. No forked components - uses STANDARD OpenAIAgentsPlugin! + context_interceptor = ContextInterceptor() + temporal_streaming_model_provider = TemporalStreamingModelProvider() + + # Create a worker with automatic tracing + # IMPORTANT: We use the STANDARD temporalio.contrib.openai_agents.OpenAIAgentsPlugin + # No forking needed! The interceptor + model provider handle all streaming logic. + worker = AgentexWorker( + task_queue=task_queue_name, + plugins=[OpenAIAgentsPlugin(model_provider=temporal_streaming_model_provider)], + interceptors=[context_interceptor], + ) + + await worker.run( + activities=all_activities, + workflow=At070OpenAiAgentsSdkToolsWorkflow, + ) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/tools.py b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/tools.py new file mode 100644 index 000000000..142bcc55c --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/tools.py @@ -0,0 +1,49 @@ +from datetime import timedelta + +from agents import function_tool +from temporalio import workflow + +from project.activities import deposit_money, withdraw_money + +# ============================================================================ +# PATTERN 2 EXAMPLE: Multiple Activities Within Tools +# ============================================================================ +# This demonstrates how to create a single tool that orchestrates multiple +# Temporal activities internally. This pattern is ideal when you need to: +# 1. Guarantee the sequence of operations (withdraw THEN deposit) +# 2. Make the entire operation atomic from the agent's perspective +# 3. Avoid relying on the LLM to correctly sequence multiple tool calls + +@function_tool +async def move_money(from_account: str, to_account: str, amount: float) -> str: + """Move money from one account to another atomically. + + This tool demonstrates PATTERN 2: Instead of having the LLM make two separate + tool calls (withdraw + deposit), we create ONE tool that internally coordinates + multiple activities. This guarantees: + - withdraw_money activity runs first + - deposit_money activity only runs if withdrawal succeeds + - Both operations are durable and will retry on failure + - The entire operation appears atomic to the agent + """ + + # STEP 1: Start the withdrawal activity + # This creates a Temporal activity that will be retried if it fails + withdraw_result = await workflow.execute_activity( + withdraw_money, + args=[from_account, amount], + start_to_close_timeout=timedelta(days=1) # Long timeout for banking operations + ) + + # STEP 2: Only after successful withdrawal, start the deposit activity + # This guarantees the sequence: withdraw THEN deposit + deposit_result = await workflow.execute_activity( + deposit_money, + args=[to_account, amount], + start_to_close_timeout=timedelta(days=1) + ) + + # PATTERN 2 BENEFIT: From the agent's perspective, this was ONE tool call + # But in Temporal UI, you'll see TWO activities executed in sequence + # Each activity gets its own retry logic and durability guarantees + return f"Successfully moved ${amount} from {from_account} to {to_account}" diff --git a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/workflow.py b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/workflow.py new file mode 100644 index 000000000..2204d3a05 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/workflow.py @@ -0,0 +1,358 @@ +""" +OpenAI Agents SDK + Temporal Integration Tutorial + +This tutorial demonstrates two key patterns for integrating OpenAI Agents SDK with Temporal workflows: + +PATTERN 1: Simple External Tools as Activities (activity_as_tool) +- Convert individual Temporal activities directly into agent tools +- 1:1 mapping between tool calls and activities +- Best for: single non-deterministic operations (API calls, DB queries) +- Example: get_weather activity → weather tool + +PATTERN 2: Multiple Activities Within Tools (function_tool with internal activities) +- Create function tools that coordinate multiple activities internally +- 1:many mapping between tool calls and activities +- Best for: complex multi-step operations that need guaranteed sequencing +- Example: move_money tool → withdraw_money + deposit_money activities + +Both patterns provide durability, automatic retries, and full observability through Temporal. + +WHY THIS APPROACH IS GAME-CHANGING: +=================================== +There's a crucial meta-point that should be coming through here: **why is this different?** +This approach is truly transactional because of how the `await` works in Temporal workflows. +Consider a "move money" example - if the operation fails between the withdraw and deposit, +Temporal will resume exactly where it left off - the agent gets real-world flexibility even +if systems die. + +**Why even use Temporal? Why are we adding complexity?** The gain is enormous when you +consider what happens without it: + +In a traditional approach without Temporal, if you withdraw money but then the system crashes +before depositing, you're stuck in a broken state. The money has been withdrawn, but never +deposited. In a banking scenario, you can't just "withdraw again" - the money is already gone +from the source account, and your agent has no way to recover or know what state it was in. + +This is why you can't build very complicated agents without this confidence in transactional +behavior. Temporal gives us: + +- **Guaranteed execution**: If the workflow starts, it will complete, even through failures +- **Exact resumption**: Pick up exactly where we left off, not start over +- **Transactional integrity**: Either both operations complete, or the workflow can be designed + to handle partial completion +- **Production reliability**: Build agents that can handle real-world complexity and failures + +Without this foundation, agents remain fragile toys. With Temporal, they become production-ready +systems that can handle the complexities of the real world. +""" + +import os +import json +import asyncio +from typing import Any, Dict, List +from datetime import timedelta + +from agents import Agent, Runner +from temporalio import workflow +from temporalio.contrib import openai_agents + +from agentex.lib import adk +from project.activities import get_weather +from agentex.lib.types.acp import SendEventParams, CreateTaskParams +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.types.workflow import SignalName +from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow +from agentex.lib.core.tracing.tracing_processor_manager import ( + add_tracing_processor_config, +) +from agentex.lib.core.temporal.plugins.openai_agents.hooks.hooks import TemporalStreamingHooks + +# Configure tracing processor (optional - only if you have SGP credentials) +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + ) +) + +environment_variables = EnvironmentVariables.refresh() + +if environment_variables.WORKFLOW_NAME is None: + raise ValueError("Environment variable WORKFLOW_NAME is not set") + +if environment_variables.AGENT_NAME is None: + raise ValueError("Environment variable AGENT_NAME is not set") + +# Validate OpenAI API key is set +if not os.environ.get("OPENAI_API_KEY"): + raise ValueError( + "OPENAI_API_KEY environment variable is not set. " + "This tutorial requires an OpenAI API key to run the OpenAI Agents SDK. " + "Please set OPENAI_API_KEY in your environment or manifest.yaml file." + ) + +logger = make_logger(__name__) + + +class StateModel(BaseModel): + """ + State model for preserving conversation history across turns. + + This allows the agent to maintain context throughout the conversation, + making it possible to reference previous messages and build on the discussion. + """ + + input_list: List[Dict[str, Any]] + turn_number: int + + +@workflow.defn(name=environment_variables.WORKFLOW_NAME) +class At070OpenAiAgentsSdkToolsWorkflow(BaseWorkflow): + """ + Minimal async workflow template for AgentEx Temporal agents. + """ + + def __init__(self): + super().__init__(display_name=environment_variables.AGENT_NAME) + self._complete_task = False + self._state: StateModel | None = None + self._pending_confirmation: asyncio.Queue[str] = asyncio.Queue() + self._task_id = None + self._trace_id = None + self._parent_span_id = None + + @workflow.signal(name=SignalName.RECEIVE_EVENT) + async def on_task_event_send(self, params: SendEventParams) -> None: + logger.info(f"Received task message instruction: {params}") + + if self._state is None: + raise ValueError("State is not initialized") + + # Increment turn number for tracing + self._state.turn_number += 1 + + self._task_id = params.task.id + self._trace_id = params.task.id + + # Add the user message to conversation history + self._state.input_list.append({"role": "user", "content": params.event.content.content}) + + # Echo back the client's message to show it in the UI. This is not done by default + # so the agent developer has full control over what is shown to the user. + await adk.messages.create(task_id=params.task.id, content=params.event.content) + + # ============================================================================ + # OpenAI Agents SDK + Temporal Integration: Two Patterns for Tool Creation + # ============================================================================ + + # #### When to Use Activities for Tools + # + # You'll want to use the activity pattern for tools in the following scenarios: + # + # - **API calls within the tool**: Whenever your tool makes an API call (external + # service, database, etc.), you must wrap it as an activity since these are + # non-deterministic operations that could fail or return different results + # - **Idempotent single operations**: When the tool performs an already idempotent + # single call that you want to ensure gets executed reliably with Temporal's retry + # guarantees + # + # Let's start with the case where it is non-deterministic. If this is the case, we + # want this tool to be an activity to guarantee that it will be executed. The way to + # do this is to add some syntax to make the tool call an activity. Let's create a tool + # that gives us the weather and create a weather agent. For this example, we will just + # return a hard-coded string but we can easily imagine this being an API call to a + # weather service which would make it non-deterministic. First we will create a new + # file called `activities.py`. Here we will create a function to get the weather and + # simply add an activity annotation on top. + + # There are TWO key patterns for integrating tools with the OpenAI Agents SDK in Temporal: + # + # PATTERN 1: Simple External Tools as Activities + # PATTERN 2: Multiple Activities Within Tools + # + # Choose the right pattern based on your use case: + + # ============================================================================ + # PATTERN 1: Simple External Tools as Activities + # ============================================================================ + # Use this pattern when: + # - You have a single non-deterministic operation (API call, DB query, etc.) + # - You want each tool call to be a single Temporal activity + # - You want simple 1:1 mapping between tool calls and activities + # + # HOW IT WORKS: + # 1. Define your function as a Temporal activity with @activity.defn (see activities.py) + # 2. Convert the activity to a tool using activity_as_tool() + # 3. Each time the agent calls this tool, it creates ONE activity in the workflow + # + # BENEFITS: + # - Automatic retries and durability for each tool call + # - Clear observability - each tool call shows as an activity in Temporal UI + # - Temporal handles all the failure recovery automatically + + weather_agent = Agent( + name="Weather Assistant", + instructions="You are a helpful weather agent. Use the get_weather tool to get the weather for a given city.", + tools=[ + # activity_as_tool() converts a Temporal activity into an agent tool + # The get_weather activity will be executed with durability guarantees + openai_agents.workflow.activity_as_tool( + get_weather, # This is defined in activities.py as @activity.defn + start_to_close_timeout=timedelta(seconds=10), + ), + ], + ) + + # ============================================================================ + # STREAMING SETUP: Store task_id for the Interceptor + # ============================================================================ + # These instance variables are read by ContextWorkflowOutboundInterceptor + # which injects them into activity headers. This enables streaming without + # forking the Temporal plugin! + # + # How streaming works (Interceptor + Model Provider + Hooks): + # 1. We store task_id in workflow instance variable (here) + # 2. ContextWorkflowOutboundInterceptor reads it via workflow.instance() + # 3. Interceptor injects task_id into activity headers + # 4. ContextActivityInboundInterceptor extracts from headers + # 5. Sets streaming_task_id ContextVar inside the activity + # 6. TemporalStreamingModel reads from ContextVar and streams to Redis + # 7. TemporalStreamingHooks creates placeholder messages for tool calls + # + # This approach uses STANDARD Temporal components - no forked plugin needed! + self._task_id = params.task.id + self._trace_id = params.task.id + self._parent_span_id = params.task.id + + # ============================================================================ + # HOOKS: Create Streaming Lifecycle Messages + # ============================================================================ + # TemporalStreamingHooks integrates with OpenAI Agents SDK lifecycle events + # to create messages in the database for tool calls, reasoning, etc. + # + # What hooks do: + # - on_tool_call_start(): Creates tool_request message with arguments + # - on_tool_call_done(): Creates tool_response message with result + # - on_model_stream_part(): Called for each streaming chunk (handled by TemporalStreamingModel) + # - on_run_done(): Marks the final response as complete + # + # These hooks create the messages you see in the test output: + # - Type: tool_request - Agent deciding to call get_weather + # - Type: tool_response - Result from get_weather activity + # - Type: text - Final agent response with weather info + # + # The hooks work alongside the interceptor/model streaming to provide + # a complete view of the agent's execution in the UI. + hooks = TemporalStreamingHooks(task_id=params.task.id) + + # Run the agent - when it calls the weather tool, it will create a get_weather activity + # Hooks will create messages for tool calls, interceptor enables token streaming + # Wrap in tracing span to track this turn + async with adk.tracing.span( + trace_id=params.task.id, + name=f"Turn {self._state.turn_number}", + input=self._state.model_dump(), + ) as span: + self._parent_span_id = span.id if span else None + # Pass the conversation history to Runner.run to maintain context + result = await Runner.run(weather_agent, self._state.input_list, hooks=hooks) + + # Update the state with the assistant's response for the next turn + if hasattr(result, "messages") and result.messages: + for msg in result.messages: + # Add new assistant messages to history + # Skip messages we already have (user messages we just added) + if msg.get("role") == "assistant" and msg not in self._state.input_list: + self._state.input_list.append(msg) + + # Set span output for tracing - include full state + span.output = self._state.model_dump() + + # ============================================================================ + # PATTERN 2: Multiple Activities Within Tools + # ============================================================================ + # Use this pattern when: + # - You need multiple sequential non-deterministic operations within one tool + # - You want to guarantee the sequence of operations (not rely on LLM sequencing) + # - You need atomic operations that involve multiple steps + # + # HOW IT WORKS: + # 1. Create individual activities for each non-deterministic step (see activities.py) + # 2. Create a function tool using @function_tool that calls multiple activities internally + # 3. Each activity call uses workflow.start_activity_method() for durability + # 4. The tool coordinates the sequence deterministically (not the LLM) + # + # BENEFITS: + # - Guaranteed execution order (withdraw THEN deposit) + # - Each step is durable and retryable individually + # - Atomic operations from the agent's perspective + # - Better than having LLM make multiple separate tool calls + + # UNCOMMENT THIS SECTION TO SEE PATTERN 2 IN ACTION: + # money_mover_agent = Agent( + # name="Money Mover", + # instructions="You are a helpful money mover agent. Use the move_money tool to move money from one account to another.", + # tools=[ + # # move_money is defined in tools.py as @function_tool + # # Internally, it calls withdraw_money activity THEN deposit_money activity + # # This guarantees the sequence and makes both operations durable + # move_money, + # ], + # ) + + # # Run the agent - when it calls move_money tool, it will create TWO activities: + # # 1. withdraw_money activity + # # 2. deposit_money activity (only after withdraw succeeds) + # result = await Runner.run(money_mover_agent, params.event.content.content) + + # ============================================================================ + # PATTERN COMPARISON SUMMARY: + # ============================================================================ + # + # Pattern 1 (activity_as_tool): | Pattern 2 (function_tool with activities): + # - Single activity per tool call | - Multiple activities per tool call + # - 1:1 tool to activity mapping | - 1:many tool to activity mapping + # - Simple non-deterministic ops | - Complex multi-step operations + # - Let LLM sequence multiple tools | - Code controls activity sequencing + # - Example: get_weather, db_lookup | - Example: money_transfer, multi_step_workflow + # + # BOTH patterns provide: + # - Automatic retries and failure recovery + # - Full observability in Temporal UI + # - Durable execution guarantees + # - Seamless integration with OpenAI Agents SDK + # ============================================================================ + + @workflow.run + async def on_task_create(self, params: CreateTaskParams) -> str: + logger.info(f"Received task create params: {params}") + + # Initialize the conversation state with an empty history + self._state = StateModel( + input_list=[], + turn_number=0, + ) + + # 1. Acknowledge that the task has been created. + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"Hello! I've received your task. Normally you can do some state initialization here, or just pass and do nothing until you get your first event. For now I'm just acknowledging that I've received a task with the following params:\n\n{json.dumps(params.params, indent=2)}.\n\nYou should only see this message once, when the task is created. All subsequent events will be handled by the `on_task_event_send` handler.", + ), + ) + + await workflow.wait_condition( + lambda: self._complete_task, + timeout=None, # Set a timeout if you want to prevent the task from running indefinitely. Generally this is not needed. Temporal can run hundreds of millions of workflows in parallel and more. Only do this if you have a specific reason to do so. + ) + return "Task completed" + + @workflow.signal + async def fulfill_order_signal(self, success: bool) -> None: + if success == True: + await self._pending_confirmation.put(True) diff --git a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/pyproject.toml b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/pyproject.toml new file mode 100644 index 000000000..343d4e01a --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "at070_open_ai_agents_sdk_tools" +version = "0.1.0" +description = "An AgentEx agent" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk>=0.6.0", + "openai-agents>=0.4.2", + "temporalio>=1.18.2", + "scale-gp", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", + "debugpy>=1.8.15", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/tests/test_agent.py new file mode 100644 index 000000000..e5f2982f9 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/tests/test_agent.py @@ -0,0 +1,158 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: example-tutorial) +""" + +import os +import uuid + +import pytest +import pytest_asyncio +from test_utils.async_utils import ( + poll_messages, + send_event_and_poll_yielding, +) + +from agentex import AsyncAgentex +from agentex.types.task_message import TaskMessage +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "at070-open-ai-agents-sdk-tools") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_id: str): + """Test sending an event and polling for the response.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Poll for the initial task creation message + + task_creation_found = False + async for message in poll_messages( + client=client, + task_id=task.id, + timeout=30, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + if message.content and message.content.type == "text" and message.content.author == "agent": + # Check for the initial acknowledgment message + assert "task" in message.content.content.lower() or "received" in message.content.content.lower() + task_creation_found = True + break + + assert task_creation_found, "Task creation message not found" + + # Send an event asking about the weather in NYC and poll for response with streaming + user_message = "What is the weather in New York City?" + + # Track what we've seen to ensure tool calls happened + seen_tool_request = False + seen_tool_response = False + final_message = None + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message, + timeout=60, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + + # Track tool_request messages (agent calling get_weather) + if message.content and message.content.type == "tool_request": + seen_tool_request = True + + # Track tool_response messages (get_weather result) + if message.content and message.content.type == "tool_response": + seen_tool_response = True + # If we already saw DONE but were waiting for tool_response, exit now + if final_message and getattr(final_message, "streaming_status", None) == "DONE": + break + + # Track agent text messages and their streaming updates + if message.content and message.content.type == "text" and message.content.author == "agent": + agent_text = getattr(message.content, "content", "") or "" + content_length = len(str(agent_text)) + final_message = message + + # Stop when we get DONE with content, but only if tool_response + # is already visible. The DONE text can be persisted before the + # lifecycle activity persists tool_response to the message list. + if message.streaming_status == "DONE" and content_length > 0: + if not seen_tool_request or seen_tool_response: + break + + # Verify we got all the expected pieces + assert seen_tool_request, "Expected to see tool_request message (agent calling get_weather)" + assert seen_tool_response, "Expected to see tool_response message (get_weather result)" + assert final_message is not None, "Expected to see final agent text message" + final_text = getattr(final_message.content, "content", None) if final_message.content else None + assert isinstance(final_text, str) and len(final_text) > 0, "Final message should have content" + + # Check that the response contains the temperature (22 degrees) + # The get_weather activity returns "The weather in New York City is 22 degrees Celsius" + assert "22" in final_text, "Expected weather response to contain temperature (22 degrees)" + + +class TestStreamingEvents: + """Test streaming event sending (backend verification via polling).""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_id: str): + """ + Streaming test placeholder. + + NOTE: SSE streaming is tested via the UI (agentex-ui subscribeTaskState). + Backend streaming functionality is verified in test_send_event_and_poll. + """ + pass + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/.dockerignore b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/.dockerignore new file mode 100644 index 000000000..c49489471 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/Dockerfile b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/Dockerfile new file mode 100644 index 000000000..cc4c06bf6 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/Dockerfile @@ -0,0 +1,62 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +# Install tctl (Temporal CLI) +RUN curl -L https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_arm64.tar.gz -o /tmp/tctl.tar.gz && \ + tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ + chmod +x /usr/local/bin/tctl && \ + rm /tmp/tctl.tar.gz + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/pyproject.toml /app/080_open_ai_agents_sdk_human_in_the_loop/pyproject.toml +COPY 10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/README.md /app/080_open_ai_agents_sdk_human_in_the_loop/README.md + +WORKDIR /app/080_open_ai_agents_sdk_human_in_the_loop + +# Copy the project code +COPY 10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project /app/080_open_ai_agents_sdk_human_in_the_loop/project + +# Copy the test files +COPY 10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/tests /app/080_open_ai_agents_sdk_human_in_the_loop/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies +RUN uv pip install --system .[dev] + +WORKDIR /app/080_open_ai_agents_sdk_human_in_the_loop + +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=at080-open-ai-agents-sdk-human-in-the-loop + +# Run the ACP server using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] diff --git a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/README.md b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/README.md new file mode 100644 index 000000000..8ba2b6781 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/README.md @@ -0,0 +1,199 @@ +# [Temporal] OpenAI Agents SDK - Human in the Loop + +**Part of the [OpenAI SDK + Temporal integration series](../README.md)** → Previous: [070 Tools](../070_open_ai_agents_sdk_tools/) + +## What You'll Learn + +How to pause agent execution and wait indefinitely for human approval using Temporal's child workflows and signals. The agent can wait for hours, days, or weeks for human input without consuming resources - and if the system crashes, it resumes exactly where it left off. + +**Pattern:** +1. Agent calls `wait_for_confirmation` tool +2. Tool spawns a child workflow that waits for a signal +3. Human approves/rejects via Temporal CLI or web UI +4. Child workflow completes, agent continues with the response + +## New Temporal Concepts + +### Signals +Signals are a way for external systems to interact with running workflows. Think of them as secure, durable messages sent to your workflow from the outside world. + +**Use cases:** +- User approving/rejecting an action in a web app +- Payment confirmation triggering shipping +- Live data feeds (stock prices) triggering trades +- Webhooks from external services updating workflow state + +**How it works:** Define a function in your workflow class with the `@workflow.signal` decorator. External systems can then send signals using: +- Temporal SDK (by workflow ID) +- Another Temporal workflow +- Temporal CLI +- Temporal Web UI + +[Learn more about signals](https://docs.temporal.io/develop/python/message-passing#send-signal-from-client) + +### Child Workflows +Child workflows are like spawning a new workflow from within your current workflow. Similar to calling a function in traditional programming, but the child workflow: +- Runs independently with its own execution history +- Inherits all Temporal durability guarantees +- Can be monitored separately in Temporal UI +- Continues running even if the parent has issues + +**Why use child workflows for human-in-the-loop?** +- The parent workflow can continue processing while waiting +- The child workflow can wait indefinitely for human input +- Full isolation between waiting logic and main agent logic +- Clean separation of concerns + +[Learn more about child workflows](https://docs.temporal.io/develop/python/child-workflows) + +## Prerequisites +- Development environment set up (see [main repo README](https://github.com/scaleapi/scale-agentex)) +- Backend services running: `make dev` from repository root +- Temporal UI available at http://localhost:8233 +- OpenAI Agents SDK plugin configured (see [060_hello_world](../060_open_ai_agents_sdk_hello_world/)) +- Understanding of tools (see [070_tools](../070_open_ai_agents_sdk_tools/)) + +## Quick Start + +```bash +cd examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop +uv run agentex agents run --manifest manifest.yaml +``` + +**Monitor:** Open Temporal UI at http://localhost:8233 to see child workflows and signals. + +## Try It + +1. Ask the agent to do something that requires approval (e.g., "Order 100 widgets") +2. The agent will call `wait_for_confirmation` and pause +3. Open Temporal UI (localhost:8233) +4. Find the parent workflow - you'll see it's waiting on the child workflow: + +![Parent Workflow Waiting](../_images/human_in_the_loop_workflow.png) + +5. Find the child workflow - it's waiting for a signal: + +![Child Workflow Waiting](../_images/human_in_the_loop_child_workflow.png) + +6. Send approval signal via CLI: + +```bash +temporal workflow signal \ + --workflow-id="" \ + --name="fulfill_order_signal" \ + --input=true +``` + +7. Watch both workflows complete - the agent resumes and finishes the action + +## Key Code + +### The Tool: Spawning a Child Workflow +```python +from agents import function_tool +from temporalio import workflow +from project.child_workflow import ChildWorkflow +from temporalio.workflow import ParentClosePolicy + +@function_tool +async def wait_for_confirmation(confirmation: bool) -> str: + """Wait for human confirmation before proceeding""" + + # Spawn a child workflow that will wait for a signal + result = await workflow.execute_child_workflow( + ChildWorkflow.on_task_create, + environment_variables.WORKFLOW_NAME + "_child", + id="child-workflow-id", + parent_close_policy=ParentClosePolicy.TERMINATE, + ) + + return result +``` + +### The Child Workflow: Waiting for Signals +```python +import asyncio +from temporalio import workflow + +@workflow.defn(name=environment_variables.WORKFLOW_NAME + "_child") +class ChildWorkflow(): + def __init__(self): + # Queue to hold signals + self._pending_confirmation: asyncio.Queue[bool] = asyncio.Queue() + + @workflow.run + async def on_task_create(self, name: str) -> str: + logger.info(f"Child workflow started: {name}") + + # Wait indefinitely until we receive a signal + await workflow.wait_condition( + lambda: not self._pending_confirmation.empty() + ) + + # Signal received - complete the workflow + return "Task completed" + + @workflow.signal + async def fulfill_order_signal(self, success: bool) -> None: + """External systems call this to approve/reject""" + if success: + await self._pending_confirmation.put(True) +``` + +### Using the Tool in Your Agent +```python +confirm_order_agent = Agent( + name="Confirm Order", + instructions="When user asks to confirm an order, use wait_for_confirmation tool.", + tools=[wait_for_confirmation] +) + +result = await Runner.run(confirm_order_agent, params.event.content.content) +``` + +## How It Works + +1. **Agent calls tool**: The LLM decides to call `wait_for_confirmation` +2. **Child workflow spawned**: A new workflow is created with its own ID +3. **Child waits**: Uses `workflow.wait_condition()` to block until signal arrives +4. **Parent waits**: Parent workflow is blocked waiting for child to complete +5. **Signal sent**: External system (CLI, web app, API) sends signal with workflow ID +6. **Signal received**: Child workflow's `fulfill_order_signal()` method is called +7. **Queue updated**: Signal handler adds item to queue +8. **Wait condition satisfied**: `wait_condition()` unblocks +9. **Child completes**: Returns result to parent +10. **Parent resumes**: Agent continues with the response + +**Critical insight:** At any point, if the system crashes: +- Both workflows are durable and will resume +- No context is lost +- The moment the signal arrives, execution continues + +## Why This Matters + +**Without Temporal:** If your system crashes while waiting for human approval, you lose all context about what was being approved. The user has to start over. + +**With Temporal:** +- The workflow waits durably (hours, days, weeks) +- If the system crashes and restarts, context is preserved +- The moment a human sends approval, workflow resumes exactly where it left off +- Full audit trail of who approved what and when + +**Production use cases:** +- **Financial transactions**: Agent initiates transfer, human approves +- **Legal document processing**: AI extracts data, lawyer reviews +- **Multi-step purchasing**: Agent negotiates, manager approves +- **Compliance workflows**: System flags issue, human decides action +- **High-stakes decisions**: Any operation requiring human judgment + +This pattern transforms agents from fully automated systems into **collaborative AI assistants** that know when to ask for help. + +## When to Use +- Financial transactions requiring approval +- High-stakes decisions needing human judgment +- Compliance workflows with mandatory review steps +- Legal or contractual operations +- Any operation where errors have serious consequences +- Workflows where AI assists but humans decide + +**Congratulations!** You've completed all AgentEx tutorials. You now know how to build production-ready agents from simple sync patterns to complex durable workflows with human oversight. diff --git a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/dev.ipynb b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/dev.ipynb new file mode 100644 index 000000000..3e93e183c --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/dev.ipynb @@ -0,0 +1,124 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": "AGENT_NAME = \"at080-open-ai-agents-sdk-human-in-the-loop\"" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [], + "source": [ + "# (REQUIRED) Create a new task. For Async agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b03b0d37", + "metadata": {}, + "outputs": [], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a6927cc0", + "metadata": {}, + "outputs": [], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4864e354", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/environments.yaml b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/environments.yaml new file mode 100644 index 000000000..f90511911 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/environments.yaml @@ -0,0 +1,64 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-example-tutorial" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + # This is used to override the global helm values.yaml file in the agentex-agent helm charts + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + temporal-worker: + enabled: true + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/manifest.yaml b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/manifest.yaml new file mode 100644 index 000000000..f6fc7e9ca --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/manifest.yaml @@ -0,0 +1,140 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../../../ # Up to tutorials level to include test_utils + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - 10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop + - test_utils + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: 10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: 10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + # Path to temporal worker file + # Examples: + # project/run_worker.py (standard) + # workers/temporal.py (custom structure) + # ../shared/worker.py (shared across projects) + worker: project/run_worker.py + + +# Agent Configuration +# ----------------- +agent: + # Type of agent - either sync or async + acp_type: async + + # Unique name for your agent + # Used for task routing and monitoring + name: at080-open-ai-agents-sdk-human-in-the-loop + + # Description of what your agent does + # Helps with documentation and discovery + description: An AgentEx agent + + # Temporal workflow configuration + # This enables your agent to run as a Temporal workflow for long-running tasks + temporal: + enabled: true + workflows: + # Name of the workflow class + # Must match the @workflow.defn name in your workflow.py + - name: at080-open-ai-agents-sdk-human-in-the-loop + + # Queue name for task distribution + # Used by Temporal to route tasks to your agent + # Convention: _task_queue + queue_name: at080_open_ai_agents_sdk_human_in_the_loop_queue + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + credentials: + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + # - env_var_name: OPENAI_API_KEY + # secret_name: openai-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + env: + # OPENAI_BASE_URL: "" + OPENAI_ORG_ID: "" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: + - name: my-registry-secret # Update with your image pull secret name + + # Global deployment settings that apply to all clusters + # These can be overridden using --override-file with custom configuration files + global: + agent: + name: "at080-open-ai-agents-sdk-human-in-the-loop" + description: "An AgentEx agent" + + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/__init__.py b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/acp.py b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/acp.py new file mode 100644 index 000000000..c05effdbe --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/acp.py @@ -0,0 +1,95 @@ +import os +import sys + +from temporalio.contrib.openai_agents import OpenAIAgentsPlugin + +# === DEBUG SETUP (AgentEx CLI Debug Support) === +if os.getenv("AGENTEX_DEBUG_ENABLED") == "true": + try: + import debugpy + debug_port = int(os.getenv("AGENTEX_DEBUG_PORT", "5679")) + debug_type = os.getenv("AGENTEX_DEBUG_TYPE", "acp") + wait_for_attach = os.getenv("AGENTEX_DEBUG_WAIT_FOR_ATTACH", "false").lower() == "true" + + # Configure debugpy + debugpy.configure(subProcess=False) + debugpy.listen(debug_port) + + print(f"🐛 [{debug_type.upper()}] Debug server listening on port {debug_port}") + + if wait_for_attach: + print(f"⏳ [{debug_type.upper()}] Waiting for debugger to attach...") + debugpy.wait_for_client() + print(f"✅ [{debug_type.upper()}] Debugger attached!") + else: + print(f"📡 [{debug_type.upper()}] Ready for debugger attachment") + + except ImportError: + print("❌ debugpy not available. Install with: pip install debugpy") + sys.exit(1) + except Exception as e: + print(f"❌ Debug setup failed: {e}") + sys.exit(1) +# === END DEBUG SETUP === + +from agentex.lib.types.fastacp import TemporalACPConfig +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model import ( + TemporalStreamingModelProvider, +) +from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ContextInterceptor + +# ============================================================================ +# STREAMING SETUP: Interceptor + Model Provider +# ============================================================================ +# This is where the streaming magic is configured! Two key components: +# +# 1. ContextInterceptor +# - Threads task_id through activity headers using Temporal's interceptor pattern +# - Outbound: Reads _task_id from workflow instance, injects into activity headers +# - Inbound: Extracts task_id from headers, sets streaming_task_id ContextVar +# - This enables runtime context without forking the Temporal plugin! +# +# 2. TemporalStreamingModelProvider +# - Returns TemporalStreamingModel instances that read task_id from ContextVar +# - TemporalStreamingModel.get_response() streams tokens to Redis in real-time +# - Still returns complete response to Temporal for determinism/replay safety +# - Uses AgentEx ADK streaming infrastructure (Redis XADD to stream:{task_id}) +# +# Together, these enable real-time LLM streaming while maintaining Temporal's +# durability guarantees. No forked components - uses STANDARD OpenAIAgentsPlugin! +context_interceptor = ContextInterceptor() +temporal_streaming_model_provider = TemporalStreamingModelProvider() + +# Create the ACP server +# IMPORTANT: We use the STANDARD temporalio.contrib.openai_agents.OpenAIAgentsPlugin +# No forking needed! The interceptor + model provider handle all streaming logic. +# +# Note: ModelActivityParameters with long timeout allows child workflows to wait +# indefinitely for human input without timing out +acp = FastACP.create( + acp_type="async", + config=TemporalACPConfig( + # When deployed to the cluster, the Temporal address will automatically be set to the cluster address + # For local development, we set the address manually to talk to the local Temporal service set up via docker compose + type="temporal", + temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[OpenAIAgentsPlugin(model_provider=temporal_streaming_model_provider)], + interceptors=[context_interceptor], + ) +) + + +# Notice that we don't need to register any handlers when we use type="temporal" +# If you look at the code in agentex.sdk.fastacp.impl.temporal_acp +# You can see that these handlers are automatically registered when the ACP is created + +# @acp.on_task_create +# This will be handled by the method in your workflow that is decorated with @workflow.run + +# @acp.on_task_event_send +# This will be handled by the method in your workflow that is decorated with @workflow.signal(name=SignalName.RECEIVE_MESSAGE) + +# @acp.on_task_cancel +# This does not need to be handled by your workflow. +# It is automatically handled by the temporal client which cancels the workflow directly \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/activities.py b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/activities.py new file mode 100644 index 000000000..4cb056549 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/activities.py @@ -0,0 +1,45 @@ +import random +import asyncio + +from temporalio import activity, workflow +from temporalio.workflow import ParentClosePolicy + +from project.child_workflow import ChildWorkflow +from agentex.lib.environment_variables import EnvironmentVariables + +environment_variables = EnvironmentVariables.refresh() + +@activity.defn +async def get_weather(city: str) -> str: + """Get the weather for a given city""" + if city == "New York City": + return "The weather in New York City is 22 degrees Celsius" + else: + return "The weather is unknown" + +@activity.defn +async def withdraw_money() -> None: + """Withdraw money from an account""" + random_number = random.randint(0, 100) + await asyncio.sleep(random_number) + print("Withdrew money from account") + +@activity.defn +async def deposit_money() -> None: + """Deposit money into an account""" + await asyncio.sleep(10) + print("Deposited money into account") + + +@activity.defn +async def confirm_order() -> bool: + """Confirm order""" + result = await workflow.execute_child_workflow( + ChildWorkflow.on_task_create, + environment_variables.WORKFLOW_NAME + "_child", + id="child-workflow-id", + parent_close_policy=ParentClosePolicy.TERMINATE, + ) + + print(result) + return True diff --git a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/child_workflow.py b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/child_workflow.py new file mode 100644 index 000000000..3dc8520ab --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/child_workflow.py @@ -0,0 +1,68 @@ +""" +Child Workflow for Human-in-the-Loop Pattern + +Child workflow that waits indefinitely for external human input via Temporal signals. +Benefits: Durable waiting, survives system failures, can wait days/weeks without resource consumption. + +Usage: External systems send signals to trigger workflow completion. +Production: Replace CLI with web dashboards, mobile apps, or API integrations. +""" + +import asyncio + +from temporalio import workflow + +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables + +environment_variables = EnvironmentVariables.refresh() +logger = make_logger(__name__) + + +@workflow.defn(name=environment_variables.WORKFLOW_NAME + "_child") +class ChildWorkflow(): + """ + Child workflow that waits for human approval via external signals. + + Lifecycle: Spawned by parent → waits for signal → human approves → completes. + Signal: temporal workflow signal --workflow-id="child-workflow-id" --name="fulfill_order_signal" --input=true + """ + + def __init__(self): + # Queue to handle signals from external systems (human input) + self._pending_confirmation: asyncio.Queue[bool] = asyncio.Queue() + + @workflow.run + async def on_task_create(self, name: str) -> str: + """ + Wait indefinitely for human approval signal. + + Uses workflow.wait_condition() to pause until external signal received. + Survives system failures and resumes exactly where it left off. + """ + logger.info(f"Child workflow started: {name}") + + while True: + # Wait until human sends approval signal (queue becomes non-empty) + await workflow.wait_condition( + lambda: not self._pending_confirmation.empty() + ) + + # Process human input and complete workflow + while not self._pending_confirmation.empty(): + break + + return "Task completed" + + @workflow.signal + async def fulfill_order_signal(self, success: bool) -> None: + """ + Receive human approval decision and trigger workflow completion. + + External systems send this signal to provide human input. + CLI: temporal workflow signal --workflow-id="child-workflow-id" --name="fulfill_order_signal" --input=true + Production: Use Temporal SDK from web apps, mobile apps, APIs, etc. + """ + # Add human decision to queue, which triggers wait_condition to resolve + if success == True: + await self._pending_confirmation.put(True) diff --git a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/run_worker.py b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/run_worker.py new file mode 100644 index 000000000..a07439fd4 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/run_worker.py @@ -0,0 +1,73 @@ +import asyncio + +from temporalio.contrib.openai_agents import OpenAIAgentsPlugin + +from project.workflow import At080OpenAiAgentsSdkHumanInTheLoopWorkflow +from project.activities import confirm_order, deposit_money, withdraw_money +from project.child_workflow import ChildWorkflow +from agentex.lib.utils.debug import setup_debug_if_enabled +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.activities import get_all_activities +from agentex.lib.core.temporal.workers.worker import AgentexWorker +from agentex.lib.core.temporal.plugins.openai_agents.hooks.activities import stream_lifecycle_content +from agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model import ( + TemporalStreamingModelProvider, +) +from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ContextInterceptor + +environment_variables = EnvironmentVariables.refresh() + +logger = make_logger(__name__) + + +async def main(): + # Setup debug mode if enabled + setup_debug_if_enabled() + + task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE + if task_queue_name is None: + raise ValueError("WORKFLOW_TASK_QUEUE is not set") + + # Add activities to the worker + # stream_lifecycle_content is required for hooks to work (creates tool_request/tool_response messages) + all_activities = get_all_activities() + [withdraw_money, deposit_money, confirm_order, stream_lifecycle_content] # add your own activities here + + # ============================================================================ + # STREAMING SETUP: Interceptor + Model Provider + # ============================================================================ + # This is where the streaming magic is configured! Two key components: + # + # 1. ContextInterceptor + # - Threads task_id through activity headers using Temporal's interceptor pattern + # - Outbound: Reads _task_id from workflow instance, injects into activity headers + # - Inbound: Extracts task_id from headers, sets streaming_task_id ContextVar + # - This enables runtime context without forking the Temporal plugin! + # + # 2. TemporalStreamingModelProvider + # - Returns TemporalStreamingModel instances that read task_id from ContextVar + # - TemporalStreamingModel.get_response() streams tokens to Redis in real-time + # - Still returns complete response to Temporal for determinism/replay safety + # - Uses AgentEx ADK streaming infrastructure (Redis XADD to stream:{task_id}) + # + # Together, these enable real-time LLM streaming while maintaining Temporal's + # durability guarantees. No forked components - uses STANDARD OpenAIAgentsPlugin! + context_interceptor = ContextInterceptor() + temporal_streaming_model_provider = TemporalStreamingModelProvider() + + # Create a worker with automatic tracing + # IMPORTANT: We use the STANDARD temporalio.contrib.openai_agents.OpenAIAgentsPlugin + # No forking needed! The interceptor + model provider handle all streaming logic. + worker = AgentexWorker( + task_queue=task_queue_name, + plugins=[OpenAIAgentsPlugin(model_provider=temporal_streaming_model_provider)], + interceptors=[context_interceptor], + ) + + await worker.run( + activities=all_activities, + workflows=[At080OpenAiAgentsSdkHumanInTheLoopWorkflow, ChildWorkflow] + ) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/tools.py b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/tools.py new file mode 100644 index 000000000..92208ac4d --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/tools.py @@ -0,0 +1,37 @@ +""" +Human-in-the-Loop Tools for OpenAI Agents SDK + Temporal Integration + +Tools that pause agent execution and wait for human input using child workflows and signals. +Pattern: Agent calls tool → spawns child workflow → waits for signal → human approves → continues. +""" + +from agents import function_tool +from temporalio import workflow +from temporalio.workflow import ParentClosePolicy + +from project.child_workflow import ChildWorkflow +from agentex.lib.environment_variables import EnvironmentVariables + +environment_variables = EnvironmentVariables.refresh() + +@function_tool +async def wait_for_confirmation() -> str: + """ + Pause agent execution and wait for human approval via child workflow. + + Spawns a child workflow that waits for external signal. Human approves via: + temporal workflow signal --workflow-id="child-workflow-id" --name="fulfill_order_signal" --input=true + + Benefits: Durable waiting, survives system failures, scalable to millions of workflows. + """ + + # Spawn child workflow that waits for human signal + # Child workflow has fixed ID "child-workflow-id" so external systems can signal it + result = await workflow.execute_child_workflow( + ChildWorkflow.on_task_create, + environment_variables.WORKFLOW_NAME + "_child", + id="child-workflow-id", + parent_close_policy=ParentClosePolicy.TERMINATE, + ) + + return result \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/workflow.py b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/workflow.py new file mode 100644 index 000000000..4f11ac4c0 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/workflow.py @@ -0,0 +1,248 @@ +""" +OpenAI Agents SDK + Temporal Integration: Human-in-the-Loop Tutorial + +This tutorial demonstrates how to pause agent execution and wait for human approval +using Temporal's child workflows and signals. + +KEY CONCEPTS: +- Child workflows: Independent workflows spawned by parent for human interaction +- Signals: External systems can send messages to running workflows +- Durable waiting: Agents can wait indefinitely for human input without losing state + +WHY THIS MATTERS: +Without Temporal, if your system crashes while waiting for human approval, you lose +all context. With Temporal, the agent resumes exactly where it left off after +system failures, making human-in-the-loop workflows production-ready. + +PATTERN: +1. Agent calls wait_for_confirmation tool +2. Tool spawns child workflow that waits for signal +3. Human approves via CLI/web app +4. Child workflow completes, agent continues + +Usage: `temporal workflow signal --workflow-id="child-workflow-id" --name="fulfill_order_signal" --input=true` +""" + +import os +import json +import asyncio +from typing import Any, Dict, List + +from agents import Agent, Runner +from temporalio import workflow + +from agentex.lib import adk +from project.tools import wait_for_confirmation +from agentex.lib.types.acp import SendEventParams, CreateTaskParams +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.types.workflow import SignalName +from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow +from agentex.lib.core.tracing.tracing_processor_manager import ( + add_tracing_processor_config, +) +from agentex.lib.core.temporal.plugins.openai_agents.hooks.hooks import TemporalStreamingHooks + +# Configure tracing processor (optional - only if you have SGP credentials) +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + ) +) + +environment_variables = EnvironmentVariables.refresh() + +if environment_variables.WORKFLOW_NAME is None: + raise ValueError("Environment variable WORKFLOW_NAME is not set") + +if environment_variables.AGENT_NAME is None: + raise ValueError("Environment variable AGENT_NAME is not set") + +# Validate OpenAI API key is set +if not os.environ.get("OPENAI_API_KEY"): + raise ValueError( + "OPENAI_API_KEY environment variable is not set. " + "This tutorial requires an OpenAI API key to run the OpenAI Agents SDK. " + "Please set OPENAI_API_KEY in your environment or manifest.yaml file." + ) + +logger = make_logger(__name__) + + +class StateModel(BaseModel): + """ + State model for preserving conversation history across turns. + + This allows the agent to maintain context throughout the conversation, + making it possible to reference previous messages and build on the discussion. + """ + + input_list: List[Dict[str, Any]] + turn_number: int + + +@workflow.defn(name=environment_variables.WORKFLOW_NAME) +class At080OpenAiAgentsSdkHumanInTheLoopWorkflow(BaseWorkflow): + """ + Human-in-the-Loop Temporal Workflow + + Demonstrates agents that can pause execution and wait for human approval. + When approval is needed, the agent spawns a child workflow that waits for + external signals (human input) before continuing. + + Benefits: Durable waiting, survives system failures, scalable to millions of workflows. + """ + + def __init__(self): + super().__init__(display_name=environment_variables.AGENT_NAME) + self._complete_task = False + self._state: StateModel | None = None + self._pending_confirmation: asyncio.Queue[str] = asyncio.Queue() + self._task_id = None + self._trace_id = None + self._parent_span_id = None + + @workflow.signal(name=SignalName.RECEIVE_EVENT) + async def on_task_event_send(self, params: SendEventParams) -> None: + """ + Handle user messages with human-in-the-loop approval capability. + + When the agent needs human approval, it calls wait_for_confirmation which spawns + a child workflow that waits for external signals before continuing. + """ + logger.info(f"Received task message instruction: {params}") + + if self._state is None: + raise ValueError("State is not initialized") + + # Increment turn number for tracing + self._state.turn_number += 1 + + self._task_id = params.task.id + self._trace_id = params.task.id + + # Add the user message to conversation history + self._state.input_list.append({"role": "user", "content": params.event.content.content}) + + # Echo user message back to UI + await adk.messages.create(task_id=params.task.id, content=params.event.content) + + # ============================================================================ + # STREAMING SETUP: Store task_id for the Interceptor + # ============================================================================ + # These instance variables are read by ContextWorkflowOutboundInterceptor + # which injects them into activity headers. This enables streaming without + # forking the Temporal plugin! + # + # How streaming works (Interceptor + Model Provider + Hooks): + # 1. We store task_id in workflow instance variable (here) + # 2. ContextWorkflowOutboundInterceptor reads it via workflow.instance() + # 3. Interceptor injects task_id into activity headers + # 4. ContextActivityInboundInterceptor extracts from headers + # 5. Sets streaming_task_id ContextVar inside the activity + # 6. TemporalStreamingModel reads from ContextVar and streams to Redis + # 7. TemporalStreamingHooks creates placeholder messages for tool calls + # + # This approach uses STANDARD Temporal components - no forked plugin needed! + self._task_id = params.task.id + self._trace_id = params.task.id + self._parent_span_id = params.task.id + + # ============================================================================ + # HOOKS: Create Streaming Lifecycle Messages + # ============================================================================ + # TemporalStreamingHooks integrates with OpenAI Agents SDK lifecycle events + # to create messages in the database for tool calls, reasoning, etc. + # + # What hooks do: + # - on_tool_call_start(): Creates tool_request message with arguments + # - on_tool_call_done(): Creates tool_response message with result + # - on_model_stream_part(): Called for each streaming chunk (handled by TemporalStreamingModel) + # - on_run_done(): Marks the final response as complete + # + # For human-in-the-loop workflows, hooks create messages showing: + # - Type: tool_request - Agent deciding to call wait_for_confirmation + # - Type: tool_response - Result after human approval (child workflow completion) + # - Type: text - Final agent response after approval received + # + # The hooks work alongside the interceptor/model streaming to provide + # a complete view of the agent's execution in the UI. + hooks = TemporalStreamingHooks(task_id=params.task.id) + + # Create agent with human-in-the-loop capability + # The wait_for_confirmation tool spawns a child workflow that waits for external signals + confirm_order_agent = Agent( + name="Confirm Order", + instructions="You are a helpful confirm order agent. When a user asks you to confirm an order, use the wait_for_confirmation tool to wait for confirmation.", + tools=[ + wait_for_confirmation, + ], + ) + + # Run agent - when human approval is needed, it will spawn child workflow and wait + # Hooks will create messages for tool calls, interceptor enables token streaming + # Wrap in tracing span to track this turn + async with adk.tracing.span( + trace_id=params.task.id, + name=f"Turn {self._state.turn_number}", + input=self._state.model_dump(), + ) as span: + self._parent_span_id = span.id if span else None + # Pass the conversation history to Runner.run to maintain context + result = await Runner.run(confirm_order_agent, self._state.input_list, hooks=hooks) + + # Update the state with the assistant's response for the next turn + if hasattr(result, "messages") and result.messages: + for msg in result.messages: + # Add new assistant messages to history + # Skip messages we already have (user messages we just added) + if msg.get("role") == "assistant" and msg not in self._state.input_list: + self._state.input_list.append(msg) + + # Set span output for tracing - include full state + span.output = self._state.model_dump() + + @workflow.run + async def on_task_create(self, params: CreateTaskParams) -> str: + """ + Workflow entry point - starts the long-running human-in-the-loop agent. + + Handles both automated decisions and human approval workflows durably. + To approve waiting actions: temporal workflow signal --workflow-id="child-workflow-id" --name="fulfill_order_signal" --input=true + """ + logger.info(f"Received task create params: {params}") + + # Initialize the conversation state with an empty history + self._state = StateModel( + input_list=[], + turn_number=0, + ) + + # Send welcome message when task is created + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"Hello! I've received your task. Normally you can do some state initialization here, or just pass and do nothing until you get your first event. For now I'm just acknowledging that I've received a task with the following params:\n\n{json.dumps(params.params, indent=2)}.\n\nYou should only see this message once, when the task is created. All subsequent events will be handled by the `on_task_event_send` handler.", + ), + ) + + # Keep workflow running indefinitely to handle user messages and human approvals + # This survives system failures and can resume exactly where it left off + await workflow.wait_condition( + lambda: self._complete_task, + timeout=None, # No timeout for long-running human-in-the-loop workflows + ) + return "Task completed" + + # TEMPORAL UI (localhost:8080): + # - Main workflow shows agent activities + ChildWorkflow activity when approval needed + # - Child workflow appears as separate "child-workflow-id" that waits for signal + # - Timeline: invoke_model_activity → ChildWorkflow (waiting) → invoke_model_activity (after approval) + # + # To approve: temporal workflow signal --workflow-id="child-workflow-id" --name="fulfill_order_signal" --input=true + # Production: Replace CLI with web dashboards/APIs that send signals programmatically diff --git a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/pyproject.toml b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/pyproject.toml new file mode 100644 index 000000000..5f4c7fbe7 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "at080_open_ai_agents_sdk_human_in_the_loop" +version = "0.1.0" +description = "An AgentEx agent" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk>=0.6.0", + "openai-agents>=0.4.2", + "temporalio>=1.18.2", + "scale-gp", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", + "debugpy>=1.8.15", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/tests/test_agent.py new file mode 100644 index 000000000..3a0386ffb --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/tests/test_agent.py @@ -0,0 +1,183 @@ +""" +Sample tests for AgentEx ACP agent with Human-in-the-Loop workflow. + +This test suite demonstrates how to test human-in-the-loop workflows: +- Non-streaming event sending and polling +- Detecting when workflow is waiting for human approval +- Sending Temporal signals to approve/reject +- Verifying workflow completes after approval + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Make sure Temporal is running (localhost:7233) +3. Set the AGENTEX_API_BASE_URL environment variable if not using default +4. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: example-tutorial) +- TEMPORAL_ADDRESS: Temporal server address (default: localhost:7233) +""" + +import os +import uuid +import asyncio + +import pytest +import pytest_asyncio + +# Temporal imports for signaling child workflows +from temporalio.client import Client as TemporalClient +from test_utils.async_utils import ( + poll_messages, + send_event_and_poll_yielding, +) + +from agentex import AsyncAgentex +from agentex.types.task_message import TaskMessage +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "at080-open-ai-agents-sdk-human-in-the-loop") +TEMPORAL_ADDRESS = os.environ.get("TEMPORAL_ADDRESS", "localhost:7233") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest_asyncio.fixture +async def temporal_client(): + """Create a Temporal client for sending signals to workflows.""" + client = await TemporalClient.connect(TEMPORAL_ADDRESS) + yield client + # Temporal client doesn't need explicit close in recent versions + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling with human-in-the-loop.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll_with_human_approval(self, client: AsyncAgentex, agent_id: str, temporal_client: TemporalClient): + """Test sending an event that triggers human approval workflow.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Poll for the initial task creation message + task_creation_found = False + async for message in poll_messages( + client=client, + task_id=task.id, + timeout=30, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + if message.content and message.content.type == "text" and message.content.author == "agent": + # Check for the initial acknowledgment message + assert "task" in message.content.content.lower() or "received" in message.content.content.lower() + task_creation_found = True + break + + assert task_creation_found, "Task creation message not found" + + # Send an event asking to confirm an order (triggers human-in-the-loop) + user_message = "Please confirm my order" + + # Track what we've seen to ensure human-in-the-loop flow happened + seen_tool_request = False + seen_tool_response = False + found_final_response = False + approval_signal_sent = False + + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message, + timeout=120, # Longer timeout for human-in-the-loop + sleep_interval=1.0, + yield_updates=True, # Get all streaming chunks + ): + assert isinstance(message, TaskMessage) + + # Track tool_request messages (agent calling wait_for_confirmation) + if message.content and message.content.type == "tool_request": + seen_tool_request = True + + if not approval_signal_sent: + # Send signal to child workflow to approve the order + # The child workflow ID is fixed as "child-workflow-id" (see tools.py) + # Give Temporal a brief moment to materialize the child workflow + await asyncio.sleep(1) + try: + handle = temporal_client.get_workflow_handle("child-workflow-id") + await handle.signal("fulfill_order_signal", True) + approval_signal_sent = True + except Exception as e: + # It's okay if the workflow completed before we could signal it. + _ = e + + # Track tool_response messages (child workflow completion) + if message.content and message.content.type == "tool_response": + seen_tool_response = True + # If we already saw DONE but were waiting for tool_response, exit now + if found_final_response: + break + + # Track agent text messages and their streaming updates + if message.content and message.content.type == "text" and message.content.author == "agent": + content_length = len(message.content.content) if message.content.content else 0 + + # Stop when we get DONE with content, but only if tool_response + # is already visible. The DONE text can be persisted before the + # lifecycle activity persists tool_response to the message list. + if message.streaming_status == "DONE" and content_length > 0: + found_final_response = True + if not seen_tool_request or seen_tool_response: + break + + # Verify that we saw the complete flow: tool_request -> human approval -> tool_response -> final answer + assert seen_tool_request, "Expected to see tool_request message (agent calling wait_for_confirmation)" + assert seen_tool_response, "Expected to see tool_response message (child workflow completion after approval)" + assert found_final_response, "Expected to see final text response after human approval" + + +class TestStreamingEvents: + """Test streaming event sending (backend verification via polling).""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_id: str): + """ + Streaming test placeholder. + + NOTE: SSE streaming is tested via the UI (agentex-ui subscribeTaskState). + Backend streaming functionality is verified in test_send_event_and_poll_with_human_approval. + """ + pass + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/.dockerignore b/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/.dockerignore new file mode 100644 index 000000000..c49489471 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/.gitignore b/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/.gitignore new file mode 100644 index 000000000..4d50da2f0 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/.gitignore @@ -0,0 +1,5 @@ +# Local environment variables (contains secrets) +.env.local + +# Workspace directory (created at runtime) +workspace/ diff --git a/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/Dockerfile b/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/Dockerfile new file mode 100644 index 000000000..5428e814a --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/Dockerfile @@ -0,0 +1,62 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +# Install tctl (Temporal CLI) +RUN curl -L https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_arm64.tar.gz -o /tmp/tctl.tar.gz && \ + tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ + chmod +x /usr/local/bin/tctl && \ + rm /tmp/tctl.tar.gz + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 10_async/10_temporal/090_claude_agents_sdk_mvp/pyproject.toml /app/090_claude_agents_sdk_mvp/pyproject.toml +COPY 10_async/10_temporal/090_claude_agents_sdk_mvp/README.md /app/090_claude_agents_sdk_mvp/README.md + +WORKDIR /app/090_claude_agents_sdk_mvp + +# Copy the project code +COPY 10_async/10_temporal/090_claude_agents_sdk_mvp/project /app/090_claude_agents_sdk_mvp/project + +# Copy the test files +COPY 10_async/10_temporal/090_claude_agents_sdk_mvp/tests /app/090_claude_agents_sdk_mvp/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies +RUN uv pip install --system .[dev] + +WORKDIR /app/090_claude_agents_sdk_mvp + +# Set environment variables +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=at090-claude-agents-sdk-mvp +# Run the ACP server using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] diff --git a/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/README.md b/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/README.md new file mode 100644 index 000000000..2f40e53c1 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/README.md @@ -0,0 +1,338 @@ +# Claude Agents SDK Integration with AgentEx + +Integration of Claude Agents SDK with AgentEx's Temporal-based orchestration platform. Claude agents run in durable workflows with real-time streaming to the AgentEx UI. + +> ⚠️ **Note**: This integration is designed for local agent development and single-worker deployments. For distributed multi-worker Kubernetes deployments, additional infrastructure is required (see [Deployment Considerations](#deployment-considerations) below). + +## Features + +- **Durable Execution** - Workflows survive restarts via Temporal's event sourcing (single-worker) +- **Session Resume** - Conversation context maintained across turns via `session_id` +- **Workspace Isolation** - Each task gets dedicated directory for file operations +- **Real-time Streaming** - Text and tool calls stream to UI via Redis +- **Tool Execution** - Read, Write, Edit, Bash, Grep, Glob with visibility in UI +- **Subagents** - Specialized agents (code-reviewer, file-organizer) with nested tracing +- **Cost Tracking** - Token usage and API costs logged per turn +- **Automatic Retries** - Temporal policies handle transient failures + +## How It Works + +### Architecture + +``` +┌─────────────────────────────────┐ +│ Temporal Workflow │ +│ - Stores session_id in state │ +│ - Tracks turn number │ +│ - Sets _task_id, _trace_id │ +└────────────┬────────────────────┘ + │ execute_activity + ↓ +┌─────────────────────────────────┐ +│ run_claude_agent_activity │ +│ - Reads context from ContextVar│ +│ - Configures Claude SDK │ +│ - Processes messages via hooks │ +│ - Returns session_id │ +└────────────┬────────────────────┘ + │ ClaudeSDKClient + ↓ +┌─────────────────────────────────┐ +│ Claude SDK │ +│ - Maintains session │ +│ - Calls Anthropic API │ +│ - Executes tools in workspace │ +│ - Triggers hooks │ +└─────────────────────────────────┘ +``` + +### Context Threading + +The integration reuses AgentEx's `ContextInterceptor` pattern (originally built for OpenAI): + +1. **Workflow** stores `_task_id`, `_trace_id`, `_parent_span_id` as instance variables +2. **ContextInterceptor (outbound)** reads these from workflow instance, injects into activity headers +3. **ContextInterceptor (inbound)** extracts from headers, sets `ContextVar` values +4. **Activity** reads `ContextVar` to get task_id for streaming + +This enables real-time streaming without breaking Temporal's determinism requirements. + +### Session Management + +Claude SDK sessions are preserved across turns: + +1. **First turn**: Claude SDK creates session, returns `session_id` in `SystemMessage` +2. **Message handler** extracts `session_id` from messages +3. **Activity** returns `session_id` to workflow +4. **Workflow** stores in `StateModel.claude_session_id` (Temporal checkpoints this) +5. **Next turn**: Pass `resume=session_id` to `ClaudeAgentOptions` +6. **Claude SDK** resumes session with full conversation history + +### Tool Streaming via Hooks + +Tool lifecycle events are handled by Claude SDK hooks: + +**PreToolUse Hook**: +- Called before tool execution +- Streams `ToolRequestContent` to UI → shows "Using tool: Write" +- Creates nested span for Task tool (subagents) + +**PostToolUse Hook**: +- Called after tool execution +- Streams `ToolResponseContent` to UI → shows "Used tool: Write" +- Closes subagent spans with results + +### Subagent Execution + +Subagents are defined as `AgentDefinition` objects passed to Claude SDK: + +```python +agents={ + 'code-reviewer': AgentDefinition( + description='Expert code review specialist...', + prompt='You are a code reviewer...', + tools=['Read', 'Grep', 'Glob'], # Read-only + model='sonnet', + ) +} +``` + +When Claude uses the Task tool, the SDK routes to the appropriate subagent based on description matching. Subagent execution is tracked via nested tracing spans. + +## Code Structure + +``` +claude_agents/ +├── __init__.py # Public exports +├── activities.py # Temporal activities +│ ├── create_workspace_directory +│ └── run_claude_agent_activity +├── message_handler.py # Message processing +│ └── ClaudeMessageHandler +│ ├── Streams text blocks +│ ├── Extracts session_id +│ └── Extracts usage/cost +└── hooks/ + └── hooks.py # Claude SDK hooks + └── TemporalStreamingHooks + ├── pre_tool_use + └── post_tool_use +``` + +## Deployment Considerations + +This integration works well for local development and single-worker deployments. For distributed multi-worker production deployments, consider the following: + +### ⚠️ Session Persistence (Multi-Worker) + +**Current behavior**: Claude SDK sessions are tied to the worker process. + +- **Local dev**: ✅ Works - session persists within single worker +- **K8s multi-pod**: ⚠️ Session ID stored in Temporal state, but session itself lives in Claude CLI process +- **Impact**: If task moves to different pod, session becomes invalid +- **Infrastructure needed**: Session persistence layer or sticky routing to same pod + +### ⚠️ Workspace Storage (Multi-Worker) + +**Current behavior**: Workspaces are local directories (`./workspace/{task_id}`). + +- **Local dev**: ✅ Works - single worker accesses all files +- **K8s multi-pod**: ⚠️ Each pod has isolated filesystem +- **Impact**: Files created by one pod are invisible to other pods +- **Infrastructure needed**: Shared storage (NFS, EFS, GCS Fuse) via `CLAUDE_WORKSPACE_ROOT` env var + +**Solution for production**: +```bash +# Mount shared filesystem (NFS, EFS, etc.) to all pods +export CLAUDE_WORKSPACE_ROOT=/mnt/shared/workspaces + +# All workers will now share workspace access +``` + +### ℹ️ Filesystem-Based Configuration + +**Current approach**: Agents and configuration are defined programmatically in code. + +- **Not used**: `.claude/agents/`, `.claude/skills/`, `CLAUDE.md` files +- **Why**: Aligns with AgentEx's code-as-configuration philosophy +- **Trade-off**: More explicit and version-controlled, but can't leverage existing Claude configs +- **To enable**: Would need to add `setting_sources=["project"]` to `ClaudeAgentOptions` + +**Current approach** (programmatic config in workflow.py): +```python +subagents = { + 'code-reviewer': AgentDefinition( + description='...', + prompt='...', + tools=['Read', 'Grep', 'Glob'], + model='sonnet', + ), +} +``` + +--- + +**Summary**: The integration is production-ready for **single-worker deployments**. Multi-worker deployments require additional infrastructure for session persistence and workspace sharing. + +## Quick Start + +### Prerequisites + +- Temporal server (localhost:7233) +- Redis (localhost:6379) +- Anthropic API key + +### Run + +```bash +# Install +rye sync --all-features + +# Configure +export ANTHROPIC_API_KEY="your-key" +export REDIS_URL="redis://localhost:6379" +export TEMPORAL_ADDRESS="localhost:7233" + +# Run from repository root +uv run agentex agents run --manifest examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/manifest.yaml +``` + +## Example Interactions + +### Context Preservation + +``` +User: "Your name is Jose" +Claude: "Nice to meet you! I'm Jose..." + +User: "What name did I assign to you?" +Claude: "You asked me to go by Jose!" ← Remembers context +``` + +### Tool Usage + +``` +User: "Create a hello.c file with Hello World" +Claude: *streams response* +[Tool card appears: "Using tool: Write"] +[Tool card updates: "Used tool: Write"] +"Done! I've created hello.c..." +``` + +### Subagents + +``` +User: "Review the code quality in hello.c" +Claude: *delegates to code-reviewer* +[Tool card: "Using tool: Task" with subagent_type: "code-reviewer"] +[Traces view shows: "Subagent: code-reviewer" nested under turn] +``` + +## Behind the Scenes + +### Message Flow + +When a user sends a message: + +1. **Signal received** (`on_task_event_send`) - Workflow increments turn, echoes message +2. **Span created** - Tracing span wraps turn, stores `parent_span_id` for interceptor +3. **Activity called** - Workflow passes prompt, workspace, session_id, subagent defs +4. **Context threaded** - Interceptor injects task_id/trace_id into activity headers +5. **Activity starts** - Reads context from ContextVar, creates hooks +6. **Claude executes** - SDK uses hooks to stream tools, message_handler streams text +7. **Results returned** - Activity returns session_id, usage, cost +8. **State updated** - Workflow stores session_id for next turn + +### Streaming Pipeline + +**Text streaming**: +``` +Claude SDK → TextBlock → ClaudeMessageHandler._handle_text_block() +→ TextDelta → adk.streaming.stream_update() +→ Redis XADD → AgentEx UI +``` + +**Tool streaming**: +``` +Claude SDK → PreToolUse hook → ToolRequestContent +→ adk.streaming (via hook) → Redis → UI ("Using tool...") + +Tool executes... + +Claude SDK → PostToolUse hook → ToolResponseContent +→ adk.streaming (via hook) → Redis → UI ("Used tool...") +``` + +### Subagent Tracing + +When Task tool is detected in PreToolUse hook: + +```python +# Create nested span +span_ctx = adk.tracing.span( + trace_id=trace_id, + parent_id=parent_span_id, + name=f"Subagent: {subagent_type}", + input=tool_input, +) +span = await span_ctx.__aenter__() + +# Store for PostToolUse to close +self.subagent_spans[tool_use_id] = (span_ctx, span) +``` + +In PostToolUse hook, the span is closed with results, creating a complete nested trace. + +## Key Implementation Details + +### Temporal Determinism + +- **File I/O in activities**: `create_workspace_directory` is an activity (not workflow code) +- **Message iteration completes**: Use `receive_response()` (not `receive_messages()`) +- **State is serializable**: `StateModel` uses Pydantic BaseModel + +### AgentDefinition Serialization + +Temporal serializes activity arguments to JSON. AgentDefinition dataclasses become dicts, so the activity reconstructs them: + +```python +agent_defs = { + name: AgentDefinition(**agent_data) + for name, agent_data in agents.items() +} +``` + +### Hook Callback Signatures + +Claude SDK expects specific signatures: + +```python +async def pre_tool_use( + input_data: dict[str, Any], # Contains tool_name, tool_input + tool_use_id: str | None, # Unique ID for this call + context: Any, # HookContext (currently unused) +) -> dict[str, Any]: # Return {} to allow, or modify behavior +``` + +## Comparison with OpenAI Integration + +| Aspect | OpenAI | Claude | +|--------|--------|--------| +| **Plugin** | `OpenAIAgentsPlugin` (official) | Manual activity wrapper | +| **Streaming** | Token-level deltas | Message block-level | +| **Tool Results** | `ToolResultBlock` | `UserMessage` (with acceptEdits) | +| **Hooks** | `RunHooks` class | `HookMatcher` with callbacks | +| **Context Threading** | ContextInterceptor | ContextInterceptor (reused!) | +| **Subagents** | Agent handoffs | AgentDefinition config | + +## Notes + +**Message Block Streaming**: Claude SDK returns complete text blocks, not individual tokens. Text appears instantly rather than animating character-by-character. This is inherent to Claude SDK's API design. + +**In-Process Subagents**: Subagents run within Claude SDK via config-based routing, not as separate Temporal workflows. This is by design - subagents are specializations, not independent agents. + +**Manual Activity Calls**: Unlike OpenAI which has an official Temporal plugin, Claude integration requires explicit `workflow.execute_activity()` calls. A future enhancement could create an automatic plugin. + +## License + +Apache 2.0 (same as AgentEx SDK) diff --git a/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/manifest.yaml b/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/manifest.yaml new file mode 100644 index 000000000..2c1ce21dd --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/manifest.yaml @@ -0,0 +1,74 @@ +kind: Agent + +# Build Configuration +build: + context: + # Root directory for the build context + root: ../../../ # Up to tutorials level to include test_utils + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - 10_async/10_temporal/090_claude_agents_sdk_mvp + - test_utils + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: 10_async/10_temporal/090_claude_agents_sdk_mvp/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: 10_async/10_temporal/090_claude_agents_sdk_mvp/.dockerignore + +# Local Development Configuration +local_development: + agent: + port: 8000 + host_address: host.docker.internal + paths: + acp: project/acp.py + worker: project/run_worker.py + +# Agent Configuration +agent: + acp_type: async + name: claude-mvp-agent + description: Claude Agents SDK MVP - proof of concept integration with AgentEx + + temporal: + enabled: true + workflows: + - name: ClaudeMvpWorkflow + queue_name: claude-mvp-queue + + credentials: + - env_var_name: ANTHROPIC_API_KEY + secret_name: anthropic-api-key + secret_key: api-key + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + +# Deployment Configuration +deployment: + image: + repository: "" + tag: "latest" + imagePullSecrets: + - name: my-registry-secret + global: + agent: + name: "claude-mvp-agent" + description: "Claude Agents SDK MVP" + replicaCount: 1 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/project/acp.py b/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/project/acp.py new file mode 100644 index 000000000..fdb08ded8 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/project/acp.py @@ -0,0 +1,75 @@ +import os +import sys + +from temporalio.contrib.openai_agents import OpenAIAgentsPlugin + +# === DEBUG SETUP (AgentEx CLI Debug Support) === +if os.getenv("AGENTEX_DEBUG_ENABLED") == "true": + print("test me") + try: + import debugpy + + debug_port = int(os.getenv("AGENTEX_DEBUG_PORT", "5679")) + debug_type = os.getenv("AGENTEX_DEBUG_TYPE", "acp") + wait_for_attach = os.getenv("AGENTEX_DEBUG_WAIT_FOR_ATTACH", "false").lower() == "true" + + # Configure debugpy + debugpy.configure(subProcess=False) + debugpy.listen(debug_port) + + print(f"🐛 [{debug_type.upper()}] Debug server listening on port {debug_port}") + + if wait_for_attach: + print(f"⏳ [{debug_type.upper()}] Waiting for debugger to attach...") + debugpy.wait_for_client() + print(f"✅ [{debug_type.upper()}] Debugger attached!") + else: + print(f"📡 [{debug_type.upper()}] Ready for debugger attachment") + + except ImportError: + print("❌ debugpy not available. Install with: pip install debugpy") + sys.exit(1) + except Exception as e: + print(f"❌ Debug setup failed: {e}") + sys.exit(1) +# === END DEBUG SETUP === + +from agentex.lib.types.fastacp import TemporalACPConfig +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model import ( + TemporalStreamingModelProvider, +) +from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ContextInterceptor + +context_interceptor = ContextInterceptor() +temporal_streaming_model_provider = TemporalStreamingModelProvider() + +# Create the ACP server +acp = FastACP.create( + acp_type="async", + config=TemporalACPConfig( + # When deployed to the cluster, the Temporal address will automatically be set to the cluster address + # For local development, we set the address manually to talk to the local Temporal service set up via docker compose + # We are also adding the Open AI Agents SDK plugin to the ACP. + type="temporal", + temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[OpenAIAgentsPlugin(model_provider=temporal_streaming_model_provider)], + interceptors=[context_interceptor], + ), +) + + +# Notice that we don't need to register any handlers when we use type="temporal" +# If you look at the code in agentex.sdk.fastacp.impl.temporal_acp +# You can see that these handlers are automatically registered when the ACP is created + +# @acp.on_task_create +# This will be handled by the method in your workflow that is decorated with @workflow.run + +# @acp.on_task_event_send +# This will be handled by the method in your workflow that is decorated with @workflow.signal(name=SignalName.RECEIVE_MESSAGE) + +# @acp.on_task_cancel +# This does not need to be handled by your workflow. +# It is automatically handled by the temporal client which cancels the workflow directly + diff --git a/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/project/run_worker.py b/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/project/run_worker.py new file mode 100644 index 000000000..a969cd760 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/project/run_worker.py @@ -0,0 +1,85 @@ +"""Claude MVP Worker - Minimal setup + +This worker demonstrates the minimal setup needed to run Claude agents +in AgentEx's Temporal architecture. + +Key components: +- ClaudeSDKClient activity (run_claude_agent_activity) +- ContextInterceptor (reused from OpenAI - threads task_id) +- Standard AgentEx activities (messages, streaming, tracing) +""" + +import os +import asyncio + +# Import workflow +from project.workflow import ClaudeMvpWorkflow + +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.activities import get_all_activities +from agentex.lib.core.temporal.workers.worker import AgentexWorker + +# Import Claude components +from agentex.lib.core.temporal.plugins.claude_agents import ( + ContextInterceptor, # Reuse from OpenAI! + run_claude_agent_activity, + create_workspace_directory, +) + +logger = make_logger(__name__) + + +async def main(): + """Start the Claude MVP worker""" + + environment_variables = EnvironmentVariables.refresh() + + logger.info("=" * 80) + logger.info("CLAUDE MVP WORKER STARTING") + logger.info("=" * 80) + logger.info(f"Workflow: {environment_variables.WORKFLOW_NAME}") + logger.info(f"Task Queue: {environment_variables.WORKFLOW_TASK_QUEUE}") + logger.info(f"Temporal Address: {environment_variables.TEMPORAL_ADDRESS}") + logger.info(f"Redis URL: {environment_variables.REDIS_URL}") + logger.info(f"Workspace Root: {environment_variables.CLAUDE_WORKSPACE_ROOT}") + logger.info(f"ANTHROPIC_API_KEY: {'SET' if os.environ.get('ANTHROPIC_API_KEY') else 'NOT SET (will fail when activity runs)'}") + + # Get all standard AgentEx activities + activities = get_all_activities() + + # Add Claude-specific activities + activities.append(run_claude_agent_activity) + activities.append(create_workspace_directory) + + logger.info(f"Registered {len(activities)} activities (including Claude activity)") + + # Create context interceptor (reuse from OpenAI!) + context_interceptor = ContextInterceptor() + + # Create worker with interceptor + worker = AgentexWorker( + task_queue=environment_variables.WORKFLOW_TASK_QUEUE, + interceptors=[context_interceptor], # Threads task_id to activities! + plugins=[], # No plugin for MVP - manual activity wrapping + ) + + logger.info("=" * 80) + logger.info("🚀 WORKER READY - Listening for tasks...") + logger.info("=" * 80) + + # Run worker + await worker.run( + activities=activities, + workflow=ClaudeMvpWorkflow, + ) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + logger.info("\n🛑 Worker stopped by user") + except Exception as e: + logger.error(f"❌ Worker failed: {e}", exc_info=True) + raise diff --git a/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/project/workflow.py b/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/project/workflow.py new file mode 100644 index 000000000..c22045152 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/project/workflow.py @@ -0,0 +1,240 @@ +"""Claude Agents SDK MVP - Minimal working example + +This workflow demonstrates the basic integration pattern between Claude Agents SDK +and AgentEx's Temporal architecture. + +What this proves: +- ✅ Claude agent runs in Temporal workflow +- ✅ File operations isolated to workspace +- ✅ Basic text streaming to UI +- ✅ Visible in Temporal UI as activities +- ✅ Temporal retry policies work + +What's missing (see NEXT_STEPS.md): +- Tool call streaming +- Proper plugin architecture +- Subagents +- Tracing +""" +from __future__ import annotations + +import os +from datetime import timedelta + +from temporalio import workflow +from temporalio.common import RetryPolicy +from claude_agent_sdk.types import AgentDefinition + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CreateTaskParams +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.types.workflow import SignalName +from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow + +# Import Claude activities +from agentex.lib.core.temporal.plugins.claude_agents import ( + run_claude_agent_activity, + create_workspace_directory, +) + +environment_variables = EnvironmentVariables.refresh() + +if environment_variables.WORKFLOW_NAME is None: + raise ValueError("Environment variable WORKFLOW_NAME is not set") + +if environment_variables.AGENT_NAME is None: + raise ValueError("Environment variable AGENT_NAME is not set") + +logger = make_logger(__name__) + + +class StateModel(BaseModel): + """Workflow state for Claude session tracking + + Stores Claude session ID to maintain conversation context across turns. + This allows Claude to remember previous messages and answer follow-up questions. + """ + claude_session_id: str | None = None + turn_number: int = 0 + + +@workflow.defn(name=environment_variables.WORKFLOW_NAME) +class ClaudeMvpWorkflow(BaseWorkflow): + """Minimal Claude agent workflow - MVP v0 + + This workflow: + 1. Creates isolated workspace for task + 2. Receives user messages via signals + 3. Runs Claude via Temporal activity + 4. Returns responses to user + + Key features: + - Durable execution (survives restarts) + - Workspace isolation + - Automatic retries + - Visible in Temporal UI + """ + + def __init__(self): + super().__init__(display_name=environment_variables.AGENT_NAME) + self._complete_task = False + self._state: StateModel | None = None + self._task_id = None + self._trace_id = None + self._parent_span_id = None + self._workspace_path = None + + @workflow.signal(name=SignalName.RECEIVE_EVENT) + async def on_task_event_send(self, params: SendEventParams): + """Handle user message - run Claude agent""" + + logger.info(f"Received task message: {params.event.content.content[:100]}...") + + if self._state is None: + raise ValueError("State is not initialized") + + self._task_id = params.task.id + self._trace_id = params.task.id + self._state.turn_number += 1 + + # Echo user message to UI + await adk.messages.create( + task_id=params.task.id, + content=params.event.content + ) + + # Wrap in tracing span - THIS IS REQUIRED for ContextInterceptor to work! + async with adk.tracing.span( + trace_id=params.task.id, + name=f"Turn {self._state.turn_number}", + input={ + "prompt": params.event.content.content, + "session_id": self._state.claude_session_id, + }, + ) as span: + self._parent_span_id = span.id if span else None + + try: + # Define subagents for specialized tasks + subagents = { + 'code-reviewer': AgentDefinition( + description='Expert code review specialist. Use when analyzing code quality, security, or best practices.', + prompt='You are a code review expert. Analyze code for bugs, security issues, and best practices. Be thorough but concise.', + tools=['Read', 'Grep', 'Glob'], # Read-only + model='sonnet', + ), + 'file-organizer': AgentDefinition( + description='File organization specialist. Use when creating multiple files or organizing project layout.', + prompt='You are a file organization expert. Create well-structured projects with clear naming.', + tools=['Write', 'Read', 'Bash', 'Glob'], + model='haiku', # Faster model + ), + } + + # Run Claude via activity (manual wrapper for MVP) + # ContextInterceptor reads _task_id, _trace_id, _parent_span_id and threads to activity! + result = await workflow.execute_activity( + run_claude_agent_activity, + args=[ + params.event.content.content, # prompt + self._workspace_path, # workspace + ["Read", "Write", "Edit", "Bash", "Grep", "Glob", "Task"], # allowed tools (Task for subagents!) + "acceptEdits", # permission mode + "You are a helpful coding assistant. Be concise.", # system prompt + self._state.claude_session_id, # resume session for context! + subagents, # subagent definitions! + ], + start_to_close_timeout=timedelta(minutes=5), + retry_policy=RetryPolicy( + maximum_attempts=3, + initial_interval=timedelta(seconds=1), + maximum_interval=timedelta(seconds=10), + backoff_coefficient=2.0, + ), + ) + + # Update session_id for next turn (maintains conversation context) + new_session_id = result.get("session_id") + if new_session_id: + self._state.claude_session_id = new_session_id + logger.info( + f"Turn {self._state.turn_number}: " + f"session_id={'STARTED' if self._state.turn_number == 1 else 'CONTINUED'} " + f"({new_session_id[:16]}...)" + ) + else: + logger.warning(f"No session_id returned - context may not persist") + + # Response already streamed to UI by activity - no need to send again + logger.debug(f"Turn {self._state.turn_number} completed successfully") + + except Exception as e: + logger.error(f"Error running Claude agent: {e}", exc_info=True) + # Send error message to user + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"❌ Error: {str(e)}", + ) + ) + raise + + @workflow.run + async def on_task_create(self, params: CreateTaskParams): + """Initialize workflow - create workspace and send welcome""" + + logger.info(f"Creating Claude MVP workflow for task: {params.task.id}") + + # Initialize state with session tracking + self._state = StateModel( + claude_session_id=None, + turn_number=0, + ) + + # Create workspace via activity (avoids determinism issues with file I/O) + workspace_root = os.environ.get("CLAUDE_WORKSPACE_ROOT") + self._workspace_path = await workflow.execute_activity( + create_workspace_directory, + args=[params.task.id, workspace_root], + start_to_close_timeout=timedelta(seconds=10), + ) + + logger.info(f"Workspace ready: {self._workspace_path}") + + # Send welcome message + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=( + "🚀 **Claude MVP Agent Ready!**\n\n" + f"Workspace: `{self._workspace_path}`\n\n" + "I'm powered by Claude Agents SDK + Temporal. Try asking me to:\n" + "- Create files: *'Create a hello.py file'*\n" + "- Read files: *'What's in hello.py?'*\n" + "- Run commands: *'List files in the workspace'*\n\n" + "Send me a message to get started! 💬" + ), + format="markdown", + ) + ) + + # Wait for completion signal + logger.info("Waiting for task completion...") + await workflow.wait_condition( + lambda: self._complete_task, + timeout=None, # Long-running workflow + ) + + logger.info("Claude MVP workflow completed") + return "Task completed successfully" + + @workflow.signal + async def complete_task_signal(self): + """Signal to gracefully complete the workflow""" + logger.info("Received complete_task signal") + self._complete_task = True diff --git a/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/pyproject.toml b/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/pyproject.toml new file mode 100644 index 000000000..23213dfd0 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "at090_claude_agents_sdk_mvp" +version = "0.1.0" +description = "Claude Agents SDK integration with AgentEx Temporal workflows - MVP proof of concept" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk>=0.6.0", + "claude-agent-sdk>=0.1.0", + "temporalio>=1.18.2", + "anthropic>=0.40.0", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", + "debugpy>=1.8.15", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/tests/test_agent.py new file mode 100644 index 000000000..9b93b1b76 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/tests/test_agent.py @@ -0,0 +1,67 @@ +import os + +# import uuid +# import asyncio +import pytest +import pytest_asyncio + +# from test_utils.async_utils import ( +# poll_messages, +# stream_agent_response, +# send_event_and_poll_yielding, +# ) +from agentex import AsyncAgentex + +# from agentex.types import TaskMessage +# from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +# from agentex.types.text_content_param import TextContentParam + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "claude-mvp-agent") + + +@pytest_asyncio.fixture +async def client(): + """Create an AgentEx client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client: AsyncAgentex, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_id: str): + """Test sending an event and polling for the response.""" + pass + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_id: str): + """Test sending an event and streaming the response.""" + pass + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/workspace/.gitignore b/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/workspace/.gitignore new file mode 100644 index 000000000..3b65a4661 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/workspace/.gitignore @@ -0,0 +1,4 @@ +# Ignore all files in workspace directory +# Each task gets its own subdirectory here +* +!.gitignore diff --git a/examples/tutorials/10_async/10_temporal/100_gemini_litellm/.dockerignore b/examples/tutorials/10_async/10_temporal/100_gemini_litellm/.dockerignore new file mode 100644 index 000000000..c49489471 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/100_gemini_litellm/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/10_temporal/100_gemini_litellm/Dockerfile b/examples/tutorials/10_async/10_temporal/100_gemini_litellm/Dockerfile new file mode 100644 index 000000000..b1b52a9a7 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/100_gemini_litellm/Dockerfile @@ -0,0 +1,54 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +# Install tctl (Temporal CLI) +RUN curl -L https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_arm64.tar.gz -o /tmp/tctl.tar.gz && \ + tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ + chmod +x /usr/local/bin/tctl && \ + rm /tmp/tctl.tar.gz + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 10_async/10_temporal/100_gemini_litellm/pyproject.toml /app/100_gemini_litellm/pyproject.toml +COPY 10_async/10_temporal/100_gemini_litellm/README.md /app/100_gemini_litellm/README.md + +WORKDIR /app/100_gemini_litellm + +# Copy the project code +COPY 10_async/10_temporal/100_gemini_litellm/project /app/100_gemini_litellm/project + +# Install the required Python packages +RUN uv pip install --system . + +WORKDIR /app/100_gemini_litellm + +ENV PYTHONPATH=/app +ENV AGENT_NAME=at100-gemini-litellm + +# Run the ACP server using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] diff --git a/examples/tutorials/10_async/10_temporal/100_gemini_litellm/README.md b/examples/tutorials/10_async/10_temporal/100_gemini_litellm/README.md new file mode 100644 index 000000000..b566fe2bd --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/100_gemini_litellm/README.md @@ -0,0 +1,130 @@ +# [Temporal] Using Alternative Models with LiteLLM (Gemini) + +**Part of the [OpenAI SDK + Temporal integration series](../README.md)** + +## What You'll Learn + +This tutorial demonstrates how to use Google's Gemini models (or any other LLM provider) with the OpenAI Agents SDK through LiteLLM. The key insight is that LiteLLM provides a unified interface, allowing you to swap models without changing your agent code structure. + +**Key insight:** You can use the same OpenAI Agents SDK patterns with any LLM provider supported by LiteLLM - Gemini, Anthropic Claude, Mistral, and many more. + +## Prerequisites +- Development environment set up (see [main repo README](https://github.com/scaleapi/scale-agentex)) +- Backend services running: `make dev` from repository root (includes Temporal) +- Temporal UI available at http://localhost:8233 +- **Google Gemini API key** (see setup below) +- Understanding of OpenAI Agents SDK basics (see [060_open_ai_agents_sdk_hello_world](../060_open_ai_agents_sdk_hello_world/)) + +## Setup + +### 1. Get a Gemini API Key + +1. Go to [Google AI Studio](https://aistudio.google.com/apikey) +2. Create a new API key +3. Copy the key for the next step + +### 2. Configure the API Key + +Add to your environment or `manifest.yaml`: + +**Option A: Environment variable** +```bash +export GEMINI_API_KEY="your-gemini-api-key-here" +``` + +**Option B: In manifest.yaml** +```yaml +agent: + env: + GEMINI_API_KEY: "your-gemini-api-key-here" +``` + +### 3. Install LiteLLM Dependency + +The `pyproject.toml` already includes `litellm>=1.52.0`. When you run the agent, dependencies are installed automatically. + +## Quick Start + +```bash +cd examples/tutorials/10_async/10_temporal/100_gemini_litellm +uv run agentex agents run --manifest manifest.yaml +``` + +**Monitor:** Open Temporal UI at http://localhost:8233 to see workflow execution. + +## Key Code Changes + +The main difference from OpenAI examples is using `LitellmModel`: + +```python +from agents.extensions.models.litellm_model import LitellmModel + +# Create a LiteLLM model pointing to Gemini +gemini_model = LitellmModel(model="gemini/gemini-2.0-flash") + +agent = Agent( + name="Gemini Assistant", + instructions="You are a helpful assistant powered by Gemini.", + model=gemini_model, # Use the LiteLLM model instead of default +) + +# Run works exactly the same way +result = await Runner.run(agent, user_messages) +``` + +## Supported Models + +LiteLLM supports many providers. Just change the model string: + +| Provider | Model String Example | +|----------|---------------------| +| Google Gemini | `gemini/gemini-2.0-flash`, `gemini/gemini-1.5-pro` | +| Anthropic | `anthropic/claude-3-sonnet-20240229` | +| Mistral | `mistral/mistral-large-latest` | +| Cohere | `cohere/command-r-plus` | +| AWS Bedrock | `bedrock/anthropic.claude-3-sonnet` | + +See [LiteLLM Providers](https://docs.litellm.ai/docs/providers) for the full list. + +## Why LiteLLM? + +**Model Flexibility:** Switch between providers without code changes - just update the model string. + +**Unified Interface:** Same OpenAI Agents SDK patterns work with any provider. + +**Cost Optimization:** Easily compare costs across providers by switching models. + +**Fallback Support:** LiteLLM supports automatic fallbacks if a provider is unavailable. + +## Architecture Notes + +The Temporal integration remains identical: +- Workflows are durable and survive restarts +- LLM calls are wrapped as activities automatically +- Full observability in Temporal UI +- Automatic retries on failures + +The only change is the model provider - everything else works the same. + +## When to Use + +- Want to use non-OpenAI models with OpenAI Agents SDK +- Need to compare model performance across providers +- Building multi-model systems with fallbacks +- Cost optimization across different providers +- Regulatory requirements for specific model providers + +## Troubleshooting + +**"GEMINI_API_KEY environment variable is not set"** +- Ensure you've exported the API key or added it to manifest.yaml + +**"Model not found" errors** +- Check the model string format matches LiteLLM's expected format +- See [LiteLLM Providers](https://docs.litellm.ai/docs/providers) for correct model names + +**Rate limiting errors** +- Gemini has different rate limits than OpenAI +- Consider adding retry logic or using LiteLLM's built-in retry support + +**Previous:** [090_claude_agents_sdk_mvp](../090_claude_agents_sdk_mvp/) - Claude SDK integration diff --git a/examples/tutorials/10_async/10_temporal/100_gemini_litellm/project/__init__.py b/examples/tutorials/10_async/10_temporal/100_gemini_litellm/project/__init__.py new file mode 100644 index 000000000..8fca5e6e6 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/100_gemini_litellm/project/__init__.py @@ -0,0 +1 @@ +# Gemini LiteLLM Tutorial diff --git a/examples/tutorials/10_async/10_temporal/100_gemini_litellm/project/acp.py b/examples/tutorials/10_async/10_temporal/100_gemini_litellm/project/acp.py new file mode 100644 index 000000000..9d2afdc37 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/100_gemini_litellm/project/acp.py @@ -0,0 +1,60 @@ +import os +from datetime import timedelta + +from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters +from agents.extensions.models.litellm_provider import LitellmProvider + +# === DEBUG SETUP (AgentEx CLI Debug Support) === +if os.getenv("AGENTEX_DEBUG_ENABLED") == "true": + import debugpy + debug_port = int(os.getenv("AGENTEX_DEBUG_PORT", "5679")) + debugpy.configure(subProcess=False) + debugpy.listen(debug_port) + if os.getenv("AGENTEX_DEBUG_WAIT_FOR_ATTACH", "false").lower() == "true": + debugpy.wait_for_client() +# === END DEBUG SETUP === + +from agentex.lib.types.fastacp import TemporalACPConfig +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ContextInterceptor + +context_interceptor = ContextInterceptor() + +# Create the ACP server +# We use LitellmProvider instead of TemporalStreamingModelProvider +# to enable using Gemini and other models through LiteLLM +acp = FastACP.create( + acp_type="async", + config=TemporalACPConfig( + # When deployed to the cluster, the Temporal address will automatically be set to the cluster address + # For local development, we set the address manually to talk to the local Temporal service set up via docker compose + # + # We use the OpenAI Agents SDK plugin because Temporal has built-in support for it, + # handling serialization and activity wrapping automatically. LitellmProvider lets us + # route to different model providers (like Gemini) while keeping all that infrastructure. + type="temporal", + temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[OpenAIAgentsPlugin( + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(days=1) + ), + model_provider=LitellmProvider(), + )], + interceptors=[context_interceptor] + ) +) + + +# Notice that we don't need to register any handlers when we use type="temporal" +# If you look at the code in agentex.sdk.fastacp.impl.temporal_acp +# You can see that these handlers are automatically registered when the ACP is created + +# @acp.on_task_create +# This will be handled by the method in your workflow that is decorated with @workflow.run + +# @acp.on_task_event_send +# This will be handled by the method in your workflow that is decorated with @workflow.signal(name=SignalName.RECEIVE_MESSAGE) + +# @acp.on_task_cancel +# This does not need to be handled by your workflow. +# It is automatically handled by the temporal client which cancels the workflow directly diff --git a/examples/tutorials/10_async/10_temporal/100_gemini_litellm/project/run_worker.py b/examples/tutorials/10_async/10_temporal/100_gemini_litellm/project/run_worker.py new file mode 100644 index 000000000..7d9ac6516 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/100_gemini_litellm/project/run_worker.py @@ -0,0 +1,62 @@ +import asyncio +from datetime import timedelta + +from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters +from agents.extensions.models.litellm_provider import LitellmProvider + +from project.workflow import At100GeminiLitellmWorkflow +from agentex.lib.utils.debug import setup_debug_if_enabled +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.activities import get_all_activities +from agentex.lib.core.temporal.workers.worker import AgentexWorker +from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ContextInterceptor + +environment_variables = EnvironmentVariables.refresh() + +logger = make_logger(__name__) + + +async def main(): + # Setup debug mode if enabled + setup_debug_if_enabled() + + task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE + if task_queue_name is None: + raise ValueError("WORKFLOW_TASK_QUEUE is not set") + + # Add activities to the worker + all_activities = get_all_activities() + [] # add your own activities here + + # ============================================================================ + # LITELLM SETUP: Interceptor + LitellmProvider + # ============================================================================ + # The ContextInterceptor threads task_id through activity headers using + # Temporal's interceptor pattern. This enables runtime context without + # forking the Temporal plugin. + # + # We use LitellmProvider instead of TemporalStreamingModelProvider to + # enable routing to Gemini and other models through LiteLLM. + context_interceptor = ContextInterceptor() + + # Create a worker with automatic tracing + # IMPORTANT: We use the STANDARD temporalio.contrib.openai_agents.OpenAIAgentsPlugin + # but with LitellmProvider to handle model routing to Gemini. + worker = AgentexWorker( + task_queue=task_queue_name, + plugins=[OpenAIAgentsPlugin( + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(days=1) + ), + model_provider=LitellmProvider(), + )], + interceptors=[context_interceptor] + ) + + await worker.run( + activities=all_activities, + workflow=At100GeminiLitellmWorkflow, + ) + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/tutorials/10_async/10_temporal/100_gemini_litellm/project/workflow.py b/examples/tutorials/10_async/10_temporal/100_gemini_litellm/project/workflow.py new file mode 100644 index 000000000..249bdaa50 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/100_gemini_litellm/project/workflow.py @@ -0,0 +1,234 @@ +""" +Gemini + LiteLLM + Temporal Integration Tutorial + +This tutorial demonstrates how to use Google's Gemini models through LiteLLM +with the OpenAI Agents SDK and Temporal workflows. It shows how to: + +1. Use LiteLLM to route requests to Gemini instead of OpenAI +2. Maintain the same durable workflow patterns with a different model provider +3. Leverage the OpenAI Agents SDK interface while using non-OpenAI models + +KEY CONCEPTS DEMONSTRATED: +- LiteLLM model provider for multi-model support +- Gemini model integration with OpenAI-compatible interface +- Temporal workflow durability with alternative LLM providers +- Model-agnostic agent patterns + +This builds on the OpenAI Agents SDK tutorials, showing how to swap models easily. +""" + +import os +import json +from typing import Any, Dict, List + +from agents import Agent, Runner +from temporalio import workflow + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CreateTaskParams +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.types.workflow import SignalName +from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow +from agentex.lib.core.tracing.tracing_processor_manager import ( + add_tracing_processor_config, +) + +# Configure tracing processor (optional - only if you have SGP credentials) +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + ) +) + +environment_variables = EnvironmentVariables.refresh() + +if environment_variables.WORKFLOW_NAME is None: + raise ValueError("Environment variable WORKFLOW_NAME is not set") + +if environment_variables.AGENT_NAME is None: + raise ValueError("Environment variable AGENT_NAME is not set") + +# Note: GEMINI_API_KEY should be set in your environment +# LiteLLM will use this automatically when routing to Gemini models + +logger = make_logger(__name__) + + +class StateModel(BaseModel): + """ + State model for preserving conversation history across turns. + + This allows the agent to maintain context throughout the conversation, + making it possible to reference previous messages and build on the discussion. + """ + + input_list: List[Dict[str, Any]] + turn_number: int + + +@workflow.defn(name=environment_variables.WORKFLOW_NAME) +class At100GeminiLitellmWorkflow(BaseWorkflow): + """ + Gemini + LiteLLM Temporal Workflow + + This workflow demonstrates using Google's Gemini models through LiteLLM + with the OpenAI Agents SDK. The key insight is that LiteLLM provides a + unified interface, allowing you to swap models without changing your + agent code structure. + + KEY FEATURES: + - Use Gemini models with OpenAI Agents SDK interface + - Same durable workflow patterns as OpenAI tutorials + - Model-agnostic agent development + - Full observability through Temporal dashboard + """ + + def __init__(self): + super().__init__(display_name=environment_variables.AGENT_NAME) + self._complete_task = False + self._state: StateModel | None = None + self._task_id = None + self._trace_id = None + self._parent_span_id = None + + @workflow.signal(name=SignalName.RECEIVE_EVENT) + async def on_task_event_send(self, params: SendEventParams) -> None: + """ + Handle incoming user messages and respond using Gemini via LiteLLM + + This signal handler demonstrates using alternative model providers: + 1. Receive user message through Temporal signal + 2. Echo message back to UI for visibility + 3. Create agent with LitellmModel pointing to Gemini + 4. Return agent's response to user + + LITELLM INTEGRATION: + - LitellmModel wraps the model selection, routing to Gemini + - The agent interface remains identical to OpenAI examples + - Temporal durability works the same way regardless of model provider + """ + logger.info(f"Received task message instruction: {params}") + + if self._state is None: + raise ValueError("State is not initialized") + + # Increment turn number for tracing + self._state.turn_number += 1 + + self._task_id = params.task.id + self._trace_id = params.task.id + + # Add the user message to conversation history + self._state.input_list.append({"role": "user", "content": params.event.content.content}) + + # ============================================================================ + # STEP 1: Echo User Message + # ============================================================================ + await adk.messages.create(task_id=params.task.id, content=params.event.content) + + # ============================================================================ + # STEP 2: Wrap execution in tracing span + # ============================================================================ + async with adk.tracing.span( + trace_id=params.task.id, + name=f"Turn {self._state.turn_number}", + input=self._state.model_dump(), + ) as span: + self._parent_span_id = span.id if span else None + + # ============================================================================ + # STEP 3: Create Agent with Gemini via LiteLLM + # ============================================================================ + # The key difference from OpenAI examples is specifying the model. + # LiteLLM uses a "provider/model" format: + # - "gemini/gemini-2.0-flash" for Gemini 2.0 Flash + # - "gemini/gemini-1.5-pro" for Gemini 1.5 Pro + # - See https://docs.litellm.ai/docs/providers/gemini for more options + # + # You can also use other providers: + # - "anthropic/claude-3-sonnet-20240229" for Claude + # - "mistral/mistral-large-latest" for Mistral + # - And many more! + # + # The LitellmProvider configured in acp.py and run_worker.py handles + # routing the model string to the appropriate provider. + + agent = Agent( + name="Gemini Assistant", + instructions="You are a helpful assistant powered by Google's Gemini model. " + "You respond concisely and clearly to user questions. " + "When appropriate, mention that you're powered by Gemini via LiteLLM.", + model="gemini/gemini-2.0-flash", + ) + + # ============================================================================ + # STEP 4: Run Agent with Temporal Durability + # ============================================================================ + # The Runner.run() call works exactly the same as with OpenAI. + # LiteLLM handles routing the request to Gemini transparently. + # Temporal still provides durability and automatic retries. + + result = await Runner.run(agent, self._state.input_list) + + # Update the state with the assistant's response for the next turn + if hasattr(result, "messages") and result.messages: + for msg in result.messages: + if msg.get("role") == "assistant" and msg not in self._state.input_list: + self._state.input_list.append(msg) + + # Set span output for tracing + span.output = self._state.model_dump() + + # Send the response to the user + await adk.messages.create( + task_id=params.task.id, + content=TextContent(author="agent", content=result.final_output) + ) + + @workflow.run + async def on_task_create(self, params: CreateTaskParams) -> str: + """ + Temporal Workflow Entry Point - Long-Running Agent Conversation + + This method runs when the workflow starts and keeps the agent conversation alive. + The pattern is identical to other tutorials - only the model provider changes. + """ + logger.info(f"Received task create params: {params}") + + # Initialize the conversation state + self._state = StateModel( + input_list=[], + turn_number=0, + ) + + # Send welcome message + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"Hello! I'm your assistant powered by Google's Gemini model via LiteLLM!\n\n" + f"This demonstrates how to use alternative model providers with the OpenAI Agents SDK " + f"and Temporal workflows. The code structure is nearly identical to OpenAI examples - " + f"only the model specification changes.\n\n" + f"Task created with params:\n{json.dumps(params.params, indent=2)}\n\n" + f"Send me a message and I'll respond using Gemini!", + ), + ) + + # Wait for completion signal + await workflow.wait_condition( + lambda: self._complete_task, + timeout=None, + ) + return "Agent conversation completed" + + @workflow.signal + async def complete_task_signal(self) -> None: + """Signal to gracefully complete the agent conversation workflow""" + logger.info("Received signal to complete the agent conversation") + self._complete_task = True diff --git a/examples/tutorials/10_async/10_temporal/100_gemini_litellm/pyproject.toml b/examples/tutorials/10_async/10_temporal/100_gemini_litellm/pyproject.toml new file mode 100644 index 000000000..9f0098e0b --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/100_gemini_litellm/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "at100_gemini_litellm" +version = "0.1.0" +description = "An AgentEx agent using Gemini via LiteLLM" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk>=0.6.0", + "openai-agents>=0.4.2", + "temporalio>=1.18.2", + "scale-gp", + "litellm>=1.52.0", +] + +[project.optional-dependencies] +dev = [ + "debugpy>=1.8.15", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/.dockerignore b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/.dockerignore new file mode 100644 index 000000000..c49489471 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/Dockerfile b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/Dockerfile new file mode 100644 index 000000000..17b0db8a0 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/Dockerfile @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +COPY 10_async/10_temporal/110_pydantic_ai/pyproject.toml /app/110_pydantic_ai/pyproject.toml +COPY 10_async/10_temporal/110_pydantic_ai/README.md /app/110_pydantic_ai/README.md + +WORKDIR /app/110_pydantic_ai + +COPY 10_async/10_temporal/110_pydantic_ai/project /app/110_pydantic_ai/project +COPY 10_async/10_temporal/110_pydantic_ai/tests /app/110_pydantic_ai/tests +COPY test_utils /app/test_utils + +RUN uv pip install --system .[dev] + +ENV PYTHONPATH=/app + +ENV AGENT_NAME=at110-pydantic-ai + +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] diff --git a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/README.md b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/README.md new file mode 100644 index 000000000..66466693b --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/README.md @@ -0,0 +1,59 @@ +# Temporal Pydantic AI Agent + +A minimal **Temporal-backed** Pydantic AI agent that drives the **unified +harness surface** (`UnifiedEmitter.auto_send_turn` + `PydanticAITurn`) from +inside the model activity's `event_stream_handler`. + +## Why this agent exists + +This agent calls `emitter.auto_send_turn(...)` **explicitly** inside +the `event_stream_handler`, making the unified-surface wiring visible and giving +the temporal channel direct coverage. + +## How it wires the unified surface + +In `project/agent.py`, the `event_stream_handler` runs inside the model activity +and constructs a `UnifiedEmitter` from `RunContext.deps`: + +```python +async def event_handler(run_context, events): + emitter = UnifiedEmitter( + task_id=run_context.deps.task_id, + trace_id=run_context.deps.task_id, + parent_span_id=run_context.deps.parent_span_id, + ) + turn = PydanticAITurn(events, model=MODEL_NAME, coalesce_tool_requests=True) + await emitter.auto_send_turn(turn) +``` + +- The handler runs inside a Temporal activity, so it can freely make + non-deterministic Redis + tracing writes. +- `coalesce_tool_requests=True` is required on the auto_send path until + AGX1-377 lands. +- `deps` (set by `project/workflow.py`) threads the `task_id` and the per-turn + `parent_span_id` into the handler so tool spans nest under the workflow's turn + span. + +## Structure + +- `project/acp.py` — thin ACP server; FastACP auto-wires HTTP routes to the + workflow when `TemporalACPConfig` is used. +- `project/agent.py` — base `Agent` + `TemporalAgent` + the unified-surface + `event_stream_handler`. +- `project/workflow.py` — durable workflow; each turn delegates to + `temporal_agent.run(...)`. +- `project/run_worker.py` — Temporal worker entry point. +- `project/tools.py` — async `get_weather(city)` returning a constant. +- `tests/test_agent.py` — live integration test (requires Temporal + Redis + + ACP server + worker). + +## Tools + +- `get_weather(city: str) -> str` (async): returns a fixed "sunny and 72°F" + string. Each tool call becomes its own Temporal activity. + +## Offline coverage + +Offline integration tests for the same wiring (pydantic-ai `TestModel` + fake +streaming/tracing, no Temporal server) live in the SDK repo under +`tests/lib/core/harness/` (the pydantic-ai temporal suite). diff --git a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/manifest.yaml b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/manifest.yaml new file mode 100644 index 000000000..7ca454b05 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/manifest.yaml @@ -0,0 +1,62 @@ +build: + context: + root: ../../../ + include_paths: + - 10_async/10_temporal/110_pydantic_ai + - test_utils + dockerfile: 10_async/10_temporal/110_pydantic_ai/Dockerfile + dockerignore: 10_async/10_temporal/110_pydantic_ai/.dockerignore + +local_development: + agent: + port: 8000 + host_address: host.docker.internal + paths: + acp: project/acp.py + worker: project/run_worker.py + +agent: + acp_type: async + name: at110-pydantic-ai + description: A Temporal-backed Pydantic AI harness test agent using the unified emitter surface + + temporal: + enabled: true + workflows: + - name: at110-pydantic-ai + queue_name: at110_pydantic_ai_queue + + credentials: + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + - env_var_name: OPENAI_API_KEY + secret_name: openai-api-key + secret_key: api-key + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + - env_var_name: SGP_ACCOUNT_ID + secret_name: sgp-account-id + secret_key: account-id + - env_var_name: SGP_CLIENT_BASE_URL + secret_name: sgp-client-base-url + secret_key: url + +deployment: + image: + repository: "" + tag: "latest" + + global: + agent: + name: "at110-pydantic-ai" + description: "A Temporal-backed Pydantic AI harness test agent using the unified emitter surface" + replicaCount: 1 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/__init__.py b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/acp.py b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/acp.py new file mode 100644 index 000000000..c142dcf70 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/acp.py @@ -0,0 +1,35 @@ +"""ACP server for the Temporal harness Pydantic AI test agent. + +This file is intentionally thin. When ``acp_type="async"`` is combined with +``TemporalACPConfig(type="temporal", ...)``, FastACP auto-wires: + + HTTP task/create → @workflow.run on the workflow class + HTTP task/event/send → @workflow.signal(SignalName.RECEIVE_EVENT) + HTTP task/cancel → workflow cancellation via the Temporal client + +so we don't define any handlers here. The actual agent code lives in +``project/workflow.py`` and is executed by the Temporal worker +(``project/run_worker.py``), not by this HTTP process. +""" + +from __future__ import annotations + +import os + +from dotenv import load_dotenv + +load_dotenv() + +from pydantic_ai.durable_exec.temporal import PydanticAIPlugin + +from agentex.lib.types.fastacp import TemporalACPConfig +from agentex.lib.sdk.fastacp.fastacp import FastACP + +acp = FastACP.create( + acp_type="async", + config=TemporalACPConfig( + type="temporal", + temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[PydanticAIPlugin()], + ), +) diff --git a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/agent.py b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/agent.py new file mode 100644 index 000000000..4e59688ce --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/agent.py @@ -0,0 +1,111 @@ +"""Pydantic AI agent definition for the Temporal harness test agent. + +This module constructs the base ``pydantic_ai.Agent`` once at import time, +registers tools on it, and wraps it in ``TemporalAgent`` from +``pydantic_ai.durable_exec.temporal``. + +The ``TemporalAgent`` wrapper makes every model call and every tool call run as +a Temporal activity automatically. The workflow stays deterministic; the +non-deterministic work (LLM HTTP calls, tool execution) moves into recorded +activities. + +Streaming back to Agentex happens via ``event_stream_handler``, which receives +Pydantic AI ``AgentStreamEvent``s from inside the model activity and forwards +them through the UNIFIED HARNESS SURFACE (``UnifiedEmitter.auto_send_turn`` + +``PydanticAITurn``) — called directly rather than via ``stream_pydantic_ai_events``. +The ``task_id`` and per-turn ``parent_span_id`` are threaded into the handler +via ``deps``. +""" + +from __future__ import annotations + +from datetime import datetime +from collections.abc import AsyncIterable + +from pydantic import BaseModel +from pydantic_ai import Agent, RunContext +from pydantic_ai.messages import AgentStreamEvent +from pydantic_ai.durable_exec.temporal import TemporalAgent + +from project.tools import get_weather +from agentex.lib.core.harness import UnifiedEmitter +from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn + +__all__ = ["TaskDeps", "temporal_agent", "base_agent", "MODEL_NAME"] + +MODEL_NAME = "openai:gpt-4o-mini" +SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. + +Current date and time: {timestamp} + +Guidelines: +- Be concise and helpful +- Use tools when they would help answer the user's question +- If you're unsure, ask clarifying questions +- Always provide accurate information +""" + + +class TaskDeps(BaseModel): + """Per-run dependencies passed into the agent via ``deps=``. + + Pydantic AI's ``RunContext.deps`` is the canonical place to thread + request-scoped data (like the Agentex task_id) into tools and event + handlers — including code that runs inside Temporal activities. + """ + + task_id: str + # When set, the event handler nests per-tool-call spans under this span. + # Typically the ID of the per-turn span opened by the workflow. + parent_span_id: str | None = None + + +def _build_base_agent() -> Agent[TaskDeps, str]: + """Build the underlying Pydantic AI agent with tools registered. + + Tools must be registered BEFORE the agent is wrapped in TemporalAgent; + changes to tool registration after wrapping are not reflected. + """ + agent: Agent[TaskDeps, str] = Agent( + MODEL_NAME, + deps_type=TaskDeps, + system_prompt=SYSTEM_PROMPT.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + ) + agent.tool_plain(get_weather) + return agent + + +async def event_handler( + run_context: RunContext[TaskDeps], + events: AsyncIterable[AgentStreamEvent], +) -> None: + """Stream Pydantic AI events to Agentex via the unified surface. + + Pydantic AI calls this with the live event stream as soon as the model + activity begins emitting parts. Because the handler runs inside the activity + (not the workflow), it can freely make non-deterministic Redis + tracing + writes. + + The UnifiedEmitter is constructed from ``deps`` (task_id + parent_span_id), + so tool spans nest under the workflow's per-turn span and messages auto-send + to the task stream. The auto_send path delivers streamed tool requests + natively, so no coalescing workaround is needed. + """ + emitter = UnifiedEmitter( + task_id=run_context.deps.task_id, + trace_id=run_context.deps.task_id, + parent_span_id=run_context.deps.parent_span_id, + ) + turn = PydanticAITurn(events, model=MODEL_NAME) + await emitter.auto_send_turn(turn) + + +# Construct the durable agent at module load time so that the PydanticAIPlugin +# can auto-discover its activities via the workflow's ``__pydantic_ai_agents__`` +# attribute. +base_agent = _build_base_agent() +temporal_agent: TemporalAgent[TaskDeps, str] = TemporalAgent( + base_agent, + name="pydantic_ai_agent", + event_stream_handler=event_handler, +) diff --git a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/run_worker.py b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/run_worker.py new file mode 100644 index 000000000..4b4d43d19 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/run_worker.py @@ -0,0 +1,48 @@ +"""Temporal worker for the harness Pydantic AI test agent. + +Run as a separate long-lived process alongside the ACP HTTP server. The worker +polls Temporal for workflow + activity tasks and executes them. + +The ``PydanticAIPlugin`` reads ``__pydantic_ai_agents__`` off the workflow class +and registers every model/tool activity the TemporalAgent needs — so we don't +have to enumerate activities by hand here. +""" + +import asyncio + +from pydantic_ai.durable_exec.temporal import PydanticAIPlugin + +from project.workflow import HarnessPydanticAiWorkflow +from agentex.lib.utils.debug import setup_debug_if_enabled +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.activities import get_all_activities +from agentex.lib.core.temporal.workers.worker import AgentexWorker + +environment_variables = EnvironmentVariables.refresh() +logger = make_logger(__name__) + + +async def main(): + setup_debug_if_enabled() + + task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE + if task_queue_name is None: + raise ValueError("WORKFLOW_TASK_QUEUE is not set") + + # get_all_activities() returns the built-in Agentex activities (state, + # messages, streaming, tracing). Pydantic AI's TemporalAgent activities are + # auto-registered by PydanticAIPlugin via __pydantic_ai_agents__. + worker = AgentexWorker( + task_queue=task_queue_name, + plugins=[PydanticAIPlugin()], + ) + + await worker.run( + activities=get_all_activities(), + workflow=HarnessPydanticAiWorkflow, + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/tools.py b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/tools.py new file mode 100644 index 000000000..bbd6c5200 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/tools.py @@ -0,0 +1,24 @@ +"""Tool definitions for the Temporal harness Pydantic AI agent. + +These functions are registered on the base Pydantic AI agent. When the agent +is wrapped in ``TemporalAgent``, each tool call becomes its own Temporal +activity automatically — independently retryable and observable. + +Tools must be ``async`` because Pydantic AI's Temporal integration requires +it: non-async tools would run in threads, which is non-deterministic and +unsafe for Temporal replay. +""" + +from __future__ import annotations + + +async def get_weather(city: str) -> str: + """Get the current weather for a city. + + Args: + city: The name of the city to get weather for. + + Returns: + A string describing the weather conditions. + """ + return f"The weather in {city} is sunny and 72°F" diff --git a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/workflow.py b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/workflow.py new file mode 100644 index 000000000..9a01be7de --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/workflow.py @@ -0,0 +1,137 @@ +"""Temporal workflow for the harness Pydantic AI test agent. + +The workflow holds task state durably across crashes. Its signal handler +delegates the actual agent run to ``temporal_agent.run(...)`` — which internally +schedules model and tool activities, each independently durable. The +``event_stream_handler`` registered on ``temporal_agent`` (see project.agent) +pushes streaming deltas through the unified harness surface while the model +activity runs. + +Multi-turn memory is kept on the workflow instance itself +(``self._message_history``). Temporal's workflow state is already durable and +replay-safe, so unlike the async-base agent we don't need an external +``adk.state`` round-trip. +""" + +from __future__ import annotations + +import os +import json +from typing import TYPE_CHECKING + +from temporalio import workflow + +from agentex.lib import adk +from project.agent import TaskDeps, temporal_agent +from agentex.lib.types.acp import SendEventParams, CreateTaskParams +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.types.workflow import SignalName +from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow +from agentex.lib.core.tracing.tracing_processor_manager import ( + add_tracing_processor_config, +) + +if TYPE_CHECKING: + from pydantic_ai.messages import ModelMessage + +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) +) + +environment_variables = EnvironmentVariables.refresh() + +if environment_variables.WORKFLOW_NAME is None: + raise ValueError("Environment variable WORKFLOW_NAME is not set") +if environment_variables.AGENT_NAME is None: + raise ValueError("Environment variable AGENT_NAME is not set") + +logger = make_logger(__name__) + + +@workflow.defn(name=environment_variables.WORKFLOW_NAME) +class HarnessPydanticAiWorkflow(BaseWorkflow): + """Long-running Temporal workflow that delegates each turn to a Pydantic AI TemporalAgent. + + The ``__pydantic_ai_agents__`` attribute is the marker the + ``PydanticAIPlugin`` looks for at worker startup: it pulls + ``temporal_agent.temporal_activities`` off this list and registers them on + the worker automatically — so we don't have to list activities by hand in + ``run_worker.py``. + """ + + __pydantic_ai_agents__ = [temporal_agent] + + def __init__(self): + super().__init__(display_name=environment_variables.AGENT_NAME) + self._complete_task = False + self._turn_number = 0 + # Conversation history accumulated across turns. Each entry is a + # pydantic-ai ``ModelMessage``. Temporal replays the activity that + # produced these messages, so the list is rebuilt deterministically if + # the workflow ever recovers from a crash. + self._message_history: list["ModelMessage"] = [] + + @workflow.signal(name=SignalName.RECEIVE_EVENT) + async def on_task_event_send(self, params: SendEventParams) -> None: + """Handle a new user message: echo it, then run the agent durably.""" + logger.info(f"Received task event: {params.task.id}") + self._turn_number += 1 + + # Echo the user's message so it shows up in the UI as a chat bubble. + await adk.messages.create(task_id=params.task.id, content=params.event.content) + + async with adk.tracing.span( + trace_id=params.task.id, + task_id=params.task.id, + name=f"Turn {self._turn_number}", + input={"message": params.event.content.content}, + ) as span: + # temporal_agent.run() schedules a model activity, per-tool + # activities, and the event_stream_handler activity (which pushes + # deltas through the unified surface). Passing ``message_history`` + # makes the run remember prior turns. + result = await temporal_agent.run( + params.event.content.content, + message_history=self._message_history, + deps=TaskDeps( + task_id=params.task.id, + parent_span_id=span.id if span else None, + ), + ) + # Persist the new full history (user + assistant + any tool rounds) + # so the next turn picks up from here. + self._message_history = list(result.all_messages()) + if span: + span.output = {"final_output": result.output} + + @workflow.run + async def on_task_create(self, params: CreateTaskParams) -> str: + """Workflow entry point — keep the conversation alive for incoming signals.""" + logger.info(f"Task created: {params.task.id}") + + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=( + f"Task initialized with params:\n{json.dumps(params.params, indent=2)}\n" + f"Send me a message and I'll respond using a Pydantic AI agent backed by Temporal." + ), + ), + ) + + await workflow.wait_condition(lambda: self._complete_task, timeout=None) + return "Task completed" + + @workflow.signal + async def complete_task_signal(self) -> None: + """Graceful workflow shutdown signal.""" + logger.info("Received complete_task signal") + self._complete_task = True diff --git a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/pyproject.toml b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/pyproject.toml new file mode 100644 index 000000000..2f308f2a1 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "at110-pydantic-ai" +version = "0.1.0" +description = "A Temporal-backed Pydantic AI harness test agent using the unified emitter surface" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "temporalio>=1.18.2", + "pydantic-ai-slim[openai]>=1.0,<2", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", + "debugpy>=1.8.15", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/tests/test_agent.py new file mode 100644 index 000000000..974cddcc0 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/tests/test_agent.py @@ -0,0 +1,113 @@ +"""Live tests for the Temporal Pydantic AI agent. + +These tests require a running agent (Temporal + Redis + ACP server + worker) and +exercise the unified-surface event_stream_handler end-to-end over the wire. + +Offline coverage of the same wiring (TestModel + fake streaming/tracing) lives +in the SDK repo under ``tests/lib/core/harness/`` (the pydantic-ai temporal suite). + +To run these tests: +1. Make sure the agent is running (worker + ACP server) +2. Set AGENTEX_API_BASE_URL if not using the default +3. Run: pytest tests/test_agent.py -v +""" + +import os +import uuid + +import pytest +import pytest_asyncio +from test_utils.async_utils import poll_messages, send_event_and_poll_yielding + +from agentex import AsyncAgentex +from agentex.types.task_message import TaskMessage +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest + +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "at110-pydantic-ai") + + +@pytest_asyncio.fixture +async def client(): + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test that the Temporal-backed harness agent responds and uses tools.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_id: str): + """Drive a full turn: create task, send a weather question, verify tool round-trip.""" + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Wait for the welcome message from on_task_create + task_creation_found = False + async for message in poll_messages( + client=client, + task_id=task.id, + timeout=30, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + if message.content and message.content.type == "text" and message.content.author == "agent": + task_creation_found = True + break + assert task_creation_found, "Task creation welcome message not found" + + # Ask about weather — the agent should call get_weather + seen_tool_request = False + seen_tool_response = False + final_message = None + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message="What is the weather in San Francisco?", + timeout=60, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + + if message.content and message.content.type == "tool_request": + seen_tool_request = True + if message.content and message.content.type == "tool_response": + seen_tool_response = True + if final_message and getattr(final_message, "streaming_status", None) == "DONE": + break + + if message.content and message.content.type == "text" and message.content.author == "agent": + final_message = message + content_length = len(getattr(message.content, "content", "") or "") + if message.streaming_status == "DONE" and content_length > 0: + if not seen_tool_request or seen_tool_response: + break + + assert seen_tool_request, "Expected a tool_request (agent calling get_weather)" + assert seen_tool_response, "Expected a tool_response (get_weather result)" + assert final_message is not None, "Expected a final agent text message" + final_text = getattr(final_message.content, "content", None) if final_message.content else None + assert isinstance(final_text, str) and len(final_text) > 0 + # The get_weather tool always returns "72°F" — the response should mention it. + assert "72" in final_text, "Expected weather response to mention 72°F" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/.dockerignore b/examples/tutorials/10_async/10_temporal/120_openai_agents/.dockerignore new file mode 100644 index 000000000..c49489471 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/Dockerfile b/examples/tutorials/10_async/10_temporal/120_openai_agents/Dockerfile new file mode 100644 index 000000000..700f56cea --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents/Dockerfile @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +COPY 10_async/10_temporal/120_openai_agents/pyproject.toml /app/120_openai_agents/pyproject.toml +COPY 10_async/10_temporal/120_openai_agents/README.md /app/120_openai_agents/README.md + +WORKDIR /app/120_openai_agents + +COPY 10_async/10_temporal/120_openai_agents/project /app/120_openai_agents/project +COPY 10_async/10_temporal/120_openai_agents/tests /app/120_openai_agents/tests +COPY test_utils /app/test_utils + +RUN uv pip install --system .[dev] + +ENV PYTHONPATH=/app + +ENV AGENT_NAME=at120-openai-agents + +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/README.md b/examples/tutorials/10_async/10_temporal/120_openai_agents/README.md new file mode 100644 index 000000000..4db26d0a1 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents/README.md @@ -0,0 +1,41 @@ +# Temporal OpenAI Agents on the unified harness surface + +A Temporal-backed Agentex agent that runs the OpenAI Agents SDK and delivers its +output through the **unified harness surface**. + +## What this demonstrates + +LLM calls are non-deterministic, so they can't run directly in a Temporal +workflow. This tutorial keeps the workflow (`project/workflow.py`) +deterministic and delegates each turn to a custom activity +(`project/activities.py`). The activity uses the SAME `OpenAITurn` adapter as +the sync (`050_openai_agents`) and async (`120_openai_agents`) variants, and +delivers via `UnifiedEmitter.auto_send_turn` — which is designed to run inside +an activity (it writes streaming side effects to Redis and returns the final +text + usage). + +```python +# inside the activity: +result = Runner.run_streamed(starting_agent=agent, input=user_message) +turn = OpenAITurn(result=result, model="gpt-4o") +emitter = UnifiedEmitter(task_id=task_id, trace_id=trace_id, parent_span_id=parent_span_id) +turn_result = await emitter.auto_send_turn(turn) +return turn_result.final_text +``` + +## Run it + +```bash +agentex agents run --manifest manifest.yaml +``` + +This starts both the ACP HTTP server and the Temporal worker. + +## Test it + +The offline test exercises the activity's delivery path with an injected fake +streaming backend (no server, Temporal, Redis, or API key required): + +```bash +pytest tests/test_agent.py -v +``` diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/environments.yaml b/examples/tutorials/10_async/10_temporal/120_openai_agents/environments.yaml new file mode 100644 index 000000000..f90511911 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents/environments.yaml @@ -0,0 +1,64 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-example-tutorial" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + # This is used to override the global helm values.yaml file in the agentex-agent helm charts + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + temporal-worker: + enabled: true + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/manifest.yaml b/examples/tutorials/10_async/10_temporal/120_openai_agents/manifest.yaml new file mode 100644 index 000000000..4b59db442 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents/manifest.yaml @@ -0,0 +1,62 @@ +build: + context: + root: ../../../ + include_paths: + - 10_async/10_temporal/120_openai_agents + - test_utils + dockerfile: 10_async/10_temporal/120_openai_agents/Dockerfile + dockerignore: 10_async/10_temporal/120_openai_agents/.dockerignore + +local_development: + agent: + port: 8000 + host_address: host.docker.internal + paths: + acp: project/acp.py + worker: project/run_worker.py + +agent: + acp_type: async + name: at120-openai-agents + description: A Temporal-backed OpenAI Agents SDK agent on the unified harness surface + + temporal: + enabled: true + workflows: + - name: at120-openai-agents + queue_name: at120_openai_agents_queue + + credentials: + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + - env_var_name: OPENAI_API_KEY + secret_name: openai-api-key + secret_key: api-key + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + - env_var_name: SGP_ACCOUNT_ID + secret_name: sgp-account-id + secret_key: account-id + - env_var_name: SGP_CLIENT_BASE_URL + secret_name: sgp-client-base-url + secret_key: url + +deployment: + image: + repository: "" + tag: "latest" + + global: + agent: + name: "at120-openai-agents" + description: "A Temporal-backed OpenAI Agents SDK agent on the unified harness surface" + replicaCount: 1 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/project/__init__.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/project/acp.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/acp.py new file mode 100644 index 000000000..6076835ba --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/acp.py @@ -0,0 +1,33 @@ +"""ACP server for the Temporal OpenAI Agents harness tutorial. + +Thin by design: with ``acp_type="async"`` + ``TemporalACPConfig``, FastACP +auto-wires task/create, task/event/send, and task/cancel onto the workflow. +The agent logic lives in ``project/workflow.py`` (deterministic) and +``project/activities.py`` (the harness-backed LLM run), executed by the worker +in ``project/run_worker.py``. +""" + +from __future__ import annotations + +import os + +from dotenv import load_dotenv + +load_dotenv() + +from agentex.lib.types.fastacp import TemporalACPConfig +from agentex.lib.sdk.fastacp.fastacp import FastACP + +# LiteLLM proxy auth: copy LITELLM_API_KEY to OPENAI_API_KEY for OpenAI client +# compatibility, so the same example works behind the Scale LiteLLM gateway. +_litellm_key = os.environ.get("LITELLM_API_KEY") +if _litellm_key and not os.environ.get("OPENAI_API_KEY"): + os.environ["OPENAI_API_KEY"] = _litellm_key + +acp = FastACP.create( + acp_type="async", + config=TemporalACPConfig( + type="temporal", + temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), + ), +) diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/project/activities.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/activities.py new file mode 100644 index 000000000..72c92d617 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/activities.py @@ -0,0 +1,80 @@ +"""Custom Temporal activity that runs the OpenAI agent on the harness surface. + +LLM calls are non-deterministic, so they must run inside a Temporal activity +rather than directly in the workflow. This activity runs the OpenAI Agents SDK +via ``Runner.run_streamed``, wraps the result in an ``OpenAITurn``, and pushes +the canonical stream to the task stream via ``UnifiedEmitter.auto_send_turn``. + +``auto_send`` (which backs ``auto_send_turn``) is explicitly designed to be +called from inside an activity: it writes streaming side effects to Redis and +returns the accumulated final text + normalized usage. +""" + +from __future__ import annotations + +from typing import Any +from datetime import datetime + +from agents import Runner +from pydantic import BaseModel +from temporalio import activity + +from project.agent import MODEL_NAME, create_agent +from agentex.lib.utils.logging import make_logger +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.lib.adk.providers._modules.openai_turn import OpenAITurn + +logger = make_logger(__name__) + +RUN_AGENT_ACTIVITY = "run_openai_agent" + + +class RunHarnessAgentParams(BaseModel): + """Parameters for the harness agent activity.""" + + task_id: str + user_message: str + # Prior conversation as OpenAI Agents SDK input items, so the agent sees the + # full history (not just the latest message) on every turn. + input_list: list[Any] = [] + trace_id: str | None = None + parent_span_id: str | None = None + # Deterministic turn timestamp from workflow.now(); forwarded to + # auto_send_turn so retried activities re-emit messages with stable + # timestamps instead of new server-side ones (which could reorder turns). + created_at: datetime | None = None + + +class RunHarnessAgentResult(BaseModel): + """Result of one harness turn.""" + + final_text: str + # Updated conversation (prior history + this turn) to carry into the next turn. + input_list: list[Any] + + +class HarnessActivities: + """Hosts the harness-backed OpenAI agent activity.""" + + @activity.defn(name=RUN_AGENT_ACTIVITY) + async def run_openai_agent(self, params: RunHarnessAgentParams) -> RunHarnessAgentResult: + """Run the agent for one turn and auto-send its output. + + Threads the running conversation through ``input_list`` so multi-turn + chats retain memory: prior history + the new user message go in, and the + updated conversation comes back out via ``result.to_input_list()``. + """ + logger.info(f"Running harness OpenAI agent for task {params.task_id}") + + agent = create_agent() + input_list: list[Any] = [*params.input_list, {"role": "user", "content": params.user_message}] + result = Runner.run_streamed(starting_agent=agent, input=input_list) + turn = OpenAITurn(result=result, model=MODEL_NAME) + emitter = UnifiedEmitter( + task_id=params.task_id, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) + turn_result = await emitter.auto_send_turn(turn, created_at=params.created_at) + # to_input_list() is valid now: auto_send_turn has exhausted the stream. + return RunHarnessAgentResult(final_text=turn_result.final_text, input_list=result.to_input_list()) diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/project/agent.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/agent.py new file mode 100644 index 000000000..385a80b69 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/agent.py @@ -0,0 +1,44 @@ +"""OpenAI Agents SDK agent definition for the Temporal harness tutorial. + +Same agent shape as the sync (060) and async (130) variants. Here the agent is +built and run inside a Temporal activity (see ``project.activities``); the +workflow stays deterministic and delegates the non-deterministic LLM run to that +activity, which delivers the turn via the unified harness surface. +""" + +from __future__ import annotations + +from datetime import datetime + +from agents import Agent, function_tool, set_tracing_disabled + +from project.tools import get_weather + +set_tracing_disabled(True) + +MODEL_NAME = "gpt-4o" +INSTRUCTIONS = """You are a helpful AI assistant with access to tools. + +Current date and time: {timestamp} + +Guidelines: +- Be concise and helpful +- Use the weather tool when the user asks about the weather +- Always report the real tool output back to the user +""" + + +@function_tool +def weather(city: str) -> str: + """Get the current weather for a city.""" + return get_weather(city) + + +def create_agent() -> Agent: + """Build and return the OpenAI Agents SDK agent with the weather tool.""" + return Agent( + name="Harness OpenAI Assistant", + model=MODEL_NAME, + instructions=INSTRUCTIONS.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + tools=[weather], + ) diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/project/run_worker.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/run_worker.py new file mode 100644 index 000000000..b82ee0f50 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/run_worker.py @@ -0,0 +1,44 @@ +"""Temporal worker for the OpenAI Agents harness tutorial. + +Runs as a separate long-lived process alongside the ACP HTTP server. Registers +the built-in Agentex activities plus the custom harness agent activity +(``HarnessActivities.run_openai_agent``), and the workflow. +""" + +import asyncio + +from project.workflow import At140HarnessOpenaiWorkflow +from project.activities import HarnessActivities +from agentex.lib.utils.debug import setup_debug_if_enabled +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.activities import get_all_activities +from agentex.lib.core.temporal.workers.worker import AgentexWorker + +environment_variables = EnvironmentVariables.refresh() +logger = make_logger(__name__) + + +async def main(): + setup_debug_if_enabled() + + task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE + if task_queue_name is None: + raise ValueError("WORKFLOW_TASK_QUEUE is not set") + + harness_activities = HarnessActivities() + all_activities = [ + harness_activities.run_openai_agent, + *get_all_activities(), + ] + + worker = AgentexWorker(task_queue=task_queue_name) + + await worker.run( + activities=all_activities, + workflow=At140HarnessOpenaiWorkflow, + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/project/tools.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/tools.py new file mode 100644 index 000000000..d26f9b097 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/tools.py @@ -0,0 +1,15 @@ +"""Tool definitions for the Temporal OpenAI Agents harness tutorial.""" + +from __future__ import annotations + + +def get_weather(city: str) -> str: + """Get the current weather for a city. + + Args: + city: The name of the city to get weather for. + + Returns: + A string describing the weather conditions. + """ + return f"The weather in {city} is sunny and 72°F" diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/project/workflow.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/workflow.py new file mode 100644 index 000000000..5cb8fb38b --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/workflow.py @@ -0,0 +1,124 @@ +"""Temporal workflow for the OpenAI Agents harness tutorial. + +The workflow stays deterministic: it echoes the user message and delegates the +non-deterministic LLM run to ``run_openai_agent`` (see +``project.activities``). That activity runs the OpenAI Agents SDK and delivers +the turn through the unified harness surface (``OpenAITurn`` + +``UnifiedEmitter.auto_send_turn``). +""" + +from __future__ import annotations + +import os +import json +from datetime import timedelta + +from temporalio import workflow +from temporalio.common import RetryPolicy + +from agentex.lib import adk +from project.activities import ( + RUN_AGENT_ACTIVITY, + RunHarnessAgentParams, + RunHarnessAgentResult, +) +from agentex.lib.types.acp import SendEventParams, CreateTaskParams +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.types.workflow import SignalName +from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config + +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) +) + +environment_variables = EnvironmentVariables.refresh() + +if environment_variables.WORKFLOW_NAME is None: + raise ValueError("Environment variable WORKFLOW_NAME is not set") +if environment_variables.AGENT_NAME is None: + raise ValueError("Environment variable AGENT_NAME is not set") + +logger = make_logger(__name__) + + +@workflow.defn(name=environment_variables.WORKFLOW_NAME) +class At140HarnessOpenaiWorkflow(BaseWorkflow): + """Long-running workflow that runs each turn through the harness activity.""" + + def __init__(self): + super().__init__(display_name=environment_variables.AGENT_NAME) + self._complete_task = False + self._turn_number = 0 + # Running conversation (OpenAI Agents SDK input items) so each turn sees + # the full history, not just the latest user message. + self._messages: list = [] + + @workflow.signal(name=SignalName.RECEIVE_EVENT) + async def on_task_event_send(self, params: SendEventParams) -> None: + """Handle a user message: echo it, then run the harness activity durably.""" + logger.info(f"Received task event: {params.task.id}") + self._turn_number += 1 + + # Echo the user's message so it shows up in the UI as a chat bubble. + await adk.messages.create(task_id=params.task.id, content=params.event.content) + + async with adk.tracing.span( + trace_id=params.task.id, + task_id=params.task.id, + name=f"Turn {self._turn_number}", + input={"message": params.event.content.content}, + ) as span: + turn_result = await workflow.execute_activity( + RUN_AGENT_ACTIVITY, + RunHarnessAgentParams( + task_id=params.task.id, + user_message=params.event.content.content, + input_list=self._messages, + trace_id=params.task.id, + parent_span_id=span.id if span else None, + # Deterministic timestamp under replay so a retried activity + # re-emits this turn's messages with stable ordering. + created_at=workflow.now(), + ), + start_to_close_timeout=timedelta(minutes=5), + retry_policy=RetryPolicy(maximum_attempts=3), + result_type=RunHarnessAgentResult, + ) + # Carry the updated conversation into the next turn. + self._messages = turn_result.input_list + if span: + span.output = {"final_output": turn_result.final_text} + + @workflow.run + async def on_task_create(self, params: CreateTaskParams) -> str: + """Workflow entry point — keep the conversation alive for incoming signals.""" + logger.info(f"Task created: {params.task.id}") + + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=( + f"Task initialized with params:\n{json.dumps(params.params, indent=2)}\n" + f"Send me a message and I'll respond using an OpenAI Agents SDK agent " + f"delivered through the unified harness surface." + ), + ), + ) + + await workflow.wait_condition(lambda: self._complete_task, timeout=None) + return "Task completed" + + @workflow.signal + async def complete_task_signal(self) -> None: + """Graceful workflow shutdown signal.""" + logger.info("Received complete_task signal") + self._complete_task = True diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/pyproject.toml b/examples/tutorials/10_async/10_temporal/120_openai_agents/pyproject.toml new file mode 100644 index 000000000..e6c77fae3 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "at120-openai-agents" +version = "0.1.0" +description = "A Temporal-backed OpenAI Agents SDK agent on the unified harness surface" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "temporalio>=1.18.2", + "openai-agents", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", + "debugpy>=1.8.15", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/tests/test_agent.py new file mode 100644 index 000000000..dd043c44c --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents/tests/test_agent.py @@ -0,0 +1,77 @@ +"""Offline test for the Temporal OpenAI Agents harness tutorial. + +This test does NOT require a running Agentex server, Temporal, Redis, or an +OpenAI API key. It verifies the delivery path the harness activity uses: an +``OpenAITurn`` built from an injected canonical stream, pushed through +``UnifiedEmitter.auto_send_turn`` with an injected fake streaming backend, +returns the accumulated final text (which the activity returns to the workflow). + +To run: ``pytest tests/test_agent.py -v`` +""" + +from __future__ import annotations + +import pytest + +from agentex.types.task_message import TaskMessage +from agentex.types.text_content import TextContent +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.types.task_message_delta import TextDelta +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.lib.adk.providers._modules.openai_turn import OpenAITurn + + +class _FakeCtx: + def __init__(self, initial_content): + self.task_message = TaskMessage(id="m-1", task_id="task-1", content=initial_content) + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + await self.close() + return False + + async def close(self): + pass + + async def stream_update(self, update): + return update + + +class _FakeStreaming: + def streaming_task_message_context(self, task_id, initial_content, **_kwargs): # noqa: ARG002 + return _FakeCtx(initial_content) + + +async def _canonical_stream(events): + for e in events: + yield e + + +@pytest.mark.asyncio +async def test_activity_delivery_returns_final_text(): + events = [ + StreamTaskMessageStart(type="start", index=0, content=TextContent(type="text", author="agent", content="")), + StreamTaskMessageDelta(type="delta", index=0, delta=TextDelta(type="text", text_delta="72")), + StreamTaskMessageDelta(type="delta", index=0, delta=TextDelta(type="text", text_delta="F")), + StreamTaskMessageDone(type="done", index=0), + ] + turn = OpenAITurn(stream=_canonical_stream(events), model="gpt-4o") + emitter = UnifiedEmitter( + task_id="task-1", + trace_id=None, + parent_span_id=None, + streaming=_FakeStreaming(), + ) + + result = await emitter.auto_send_turn(turn) + assert result.final_text == "72F" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/.dockerignore b/examples/tutorials/10_async/10_temporal/130_langgraph/.dockerignore new file mode 100644 index 000000000..c49489471 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/130_langgraph/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/Dockerfile b/examples/tutorials/10_async/10_temporal/130_langgraph/Dockerfile new file mode 100644 index 000000000..8a125ac72 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/130_langgraph/Dockerfile @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +COPY 10_async/10_temporal/130_langgraph/pyproject.toml /app/130_langgraph/pyproject.toml +COPY 10_async/10_temporal/130_langgraph/README.md /app/130_langgraph/README.md + +WORKDIR /app/130_langgraph + +COPY 10_async/10_temporal/130_langgraph/project /app/130_langgraph/project +COPY 10_async/10_temporal/130_langgraph/tests /app/130_langgraph/tests +COPY test_utils /app/test_utils + +RUN uv pip install --system .[dev] + +ENV PYTHONPATH=/app + +ENV AGENT_NAME=at130-langgraph + +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/README.md b/examples/tutorials/10_async/10_temporal/130_langgraph/README.md new file mode 100644 index 000000000..0820f56ab --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/130_langgraph/README.md @@ -0,0 +1,49 @@ +# Tutorial: Temporal LangGraph Agent + +This tutorial demonstrates how to build a **Temporal-backed** LangGraph agent on +AgentEx using the **unified harness surface**. The agent's LLM node runs as a +durable Temporal activity; the tools node runs inline in the workflow. + +## Key Concepts + +### Temporal + LangGraph + +The ``LangGraphPlugin`` from ``temporalio.contrib.langgraph`` turns annotated graph +nodes into Temporal activities or inline workflow callables: + +- `agent` node: `execute_in="activity"` (durable, retryable LLM call) +- `tools` node: `execute_in="workflow"` (inline, fast tool execution) + +### Message surfacing + +After each turn, ``emit_langgraph_messages`` converts the new LangGraph messages +(tool requests, tool responses, final text) into AgentEx ``TaskMessage`` objects +and posts them to the task's message stream. + +This is the Temporal-specific path. The non-Temporal async/sync channels use +``UnifiedEmitter.auto_send_turn`` / ``UnifiedEmitter.yield_turn`` with +``LangGraphTurn`` instead. + +## Files + +| File | Description | +|------|-------------| +| `project/acp.py` | ACP server (Temporal config, LangGraphPlugin) | +| `project/graph.py` | LangGraph graph (agent + tools nodes) | +| `project/workflow.py` | Temporal workflow (signal handlers, emit_langgraph_messages) | +| `project/run_worker.py` | Temporal worker runner | +| `project/tools.py` | Tool definitions (weather example) | +| `tests/test_agent.py` | Integration tests | +| `manifest.yaml` | Agent configuration (name: at130-langgraph) | + +## Running Locally + +```bash +agentex agents run +``` + +## Running Tests + +```bash +pytest tests/test_agent.py -v +``` diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/manifest.yaml b/examples/tutorials/10_async/10_temporal/130_langgraph/manifest.yaml new file mode 100644 index 000000000..534c8dd58 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/130_langgraph/manifest.yaml @@ -0,0 +1,59 @@ +build: + context: + root: ../../../ + include_paths: + - 10_async/10_temporal/130_langgraph + - test_utils + dockerfile: 10_async/10_temporal/130_langgraph/Dockerfile + dockerignore: 10_async/10_temporal/130_langgraph/.dockerignore + +local_development: + agent: + port: 8000 + host_address: host.docker.internal + paths: + acp: project/acp.py + worker: project/run_worker.py + +agent: + acp_type: async + name: at130-langgraph + description: "A Temporal-backed LangGraph agent (harness variant) whose nodes run as Temporal activities" + + temporal: + enabled: true + workflows: + - name: at130-langgraph + queue_name: at130_langgraph_queue + + credentials: + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + # graph.py builds ChatOpenAI(model=MODEL_NAME); a deployed worker needs the + # model credential or the first activity call fails. + - env_var_name: OPENAI_API_KEY + secret_name: openai-api-key + secret_key: api-key + + env: {} + +deployment: + image: + repository: "" + tag: "latest" + + imagePullSecrets: [] + + global: + agent: + name: "at130-langgraph" + description: "A Temporal-backed LangGraph agent (harness variant) whose nodes run as Temporal activities" + replicaCount: 1 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/project/__init__.py b/examples/tutorials/10_async/10_temporal/130_langgraph/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/project/acp.py b/examples/tutorials/10_async/10_temporal/130_langgraph/project/acp.py new file mode 100644 index 000000000..7af9c5e68 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/130_langgraph/project/acp.py @@ -0,0 +1,34 @@ +"""ACP server for the Temporal harness LangGraph agent. + +Follows the ``130_langgraph`` pattern: the Temporal ``LangGraphPlugin`` runs +graph nodes as Temporal activities. The agent logic lives in ``workflow.py`` +(the runtime) and ``graph.py`` (the LangGraph graph), executed by the Temporal +worker (``run_worker.py``), not by this HTTP process. + +The workflow uses ``emit_langgraph_messages`` to surface turn messages to +AgentEx. That helper is Temporal-specific and is not replaced by the unified +harness here (``UnifiedEmitter`` targets the non-Temporal async/sync channels). +""" + +from __future__ import annotations + +import os + +from dotenv import load_dotenv + +load_dotenv() + +from temporalio.contrib.langgraph import LangGraphPlugin + +from project.graph import GRAPH_NAME, build_graph +from agentex.lib.types.fastacp import TemporalACPConfig +from agentex.lib.sdk.fastacp.fastacp import FastACP + +acp = FastACP.create( + acp_type="async", + config=TemporalACPConfig( + type="temporal", + temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[LangGraphPlugin(graphs={GRAPH_NAME: build_graph()})], + ), +) diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/project/graph.py b/examples/tutorials/10_async/10_temporal/130_langgraph/project/graph.py new file mode 100644 index 000000000..7adba3ae4 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/130_langgraph/project/graph.py @@ -0,0 +1,85 @@ +"""LangGraph graph for at130-langgraph — nodes run as Temporal activities. + +Identical in structure to ``130_langgraph/project/graph.py``. The graph +definition is not affected by the harness migration; only the agent naming +changes. The LLM ``agent`` node runs as a durable Temporal activity; +the ``tools`` node runs inline in the workflow. +""" + +from __future__ import annotations + +import os +from typing import Any, Annotated +from datetime import datetime, timedelta + +_litellm_key = os.environ.get("LITELLM_API_KEY") +if _litellm_key: + os.environ.setdefault("OPENAI_API_KEY", _litellm_key) + +from typing_extensions import TypedDict + +from langgraph.graph import END, START, StateGraph +from langchain_openai import ChatOpenAI +from langchain_core.messages import ToolMessage, SystemMessage +from langgraph.graph.message import add_messages + +from project.tools import TOOLS + +_TOOLS_BY_NAME = {tool.name: tool for tool in TOOLS} + +GRAPH_NAME = "at130-langgraph" +MODEL_NAME = "gpt-4o" +SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. + +Current date and time: {timestamp} + +Be concise and use tools when they help answer the question.""" + + +class AgentState(TypedDict): + messages: Annotated[list[Any], add_messages] + + +async def agent_node(state: AgentState) -> dict[str, Any]: + """The 'agent' node — one LLM call. Runs as a durable Temporal activity.""" + llm = ChatOpenAI(model=MODEL_NAME).bind_tools(TOOLS) + messages = state["messages"] + if not messages or not isinstance(messages[0], SystemMessage): + system = SystemMessage(content=SYSTEM_PROMPT.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))) + messages = [system, *messages] + return {"messages": [await llm.ainvoke(messages)]} + + +async def tools_node(state: AgentState) -> dict[str, Any]: + """Run the tool calls the model requested. Runs inline in the workflow.""" + last = state["messages"][-1] + results: list[Any] = [] + for call in getattr(last, "tool_calls", None) or []: + tool = _TOOLS_BY_NAME.get(call["name"]) + if tool is None: + output = f"Error: unknown tool {call['name']!r}. Available: {list(_TOOLS_BY_NAME)}" + else: + output = await tool.ainvoke(call["args"]) + results.append(ToolMessage(content=str(output), tool_call_id=call["id"], name=call["name"])) + return {"messages": results} + + +async def route_after_agent(state: AgentState) -> str: + """Go to the tools node if the model requested tools, else finish.""" + last = state["messages"][-1] + return "tools" if getattr(last, "tool_calls", None) else END + + +def build_graph() -> StateGraph: + """Build the agent graph; the LLM node runs as an activity, tools in the workflow.""" + builder = StateGraph(AgentState) + builder.add_node( + "agent", + agent_node, + metadata={"execute_in": "activity", "start_to_close_timeout": timedelta(minutes=5)}, + ) + builder.add_node("tools", tools_node, metadata={"execute_in": "workflow"}) + builder.add_edge(START, "agent") + builder.add_conditional_edges("agent", route_after_agent, {"tools": "tools", END: END}) + builder.add_edge("tools", "agent") + return builder diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/project/run_worker.py b/examples/tutorials/10_async/10_temporal/130_langgraph/project/run_worker.py new file mode 100644 index 000000000..4b31bf396 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/130_langgraph/project/run_worker.py @@ -0,0 +1,46 @@ +"""Temporal worker for at130-langgraph. + +Run as a separate long-lived process alongside the ACP HTTP server. The +worker polls Temporal for workflow + activity tasks and executes them. + +The ``LangGraphPlugin`` is given the graph registry (``{ GRAPH_NAME: graph }``). +At runtime it turns the graph's ``execute_in="activity"`` nodes into Temporal +activities and registers them on the worker automatically. +""" + +import asyncio + +from temporalio.contrib.langgraph import LangGraphPlugin + +from project.graph import GRAPH_NAME, build_graph +from project.workflow import AtHarnessLanggraphWorkflow +from agentex.lib.utils.debug import setup_debug_if_enabled +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.activities import get_all_activities +from agentex.lib.core.temporal.workers.worker import AgentexWorker + +environment_variables = EnvironmentVariables.refresh() +logger = make_logger(__name__) + + +async def main(): + setup_debug_if_enabled() + + task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE + if task_queue_name is None: + raise ValueError("WORKFLOW_TASK_QUEUE is not set") + + worker = AgentexWorker( + task_queue=task_queue_name, + plugins=[LangGraphPlugin(graphs={GRAPH_NAME: build_graph()})], + ) + + await worker.run( + activities=get_all_activities(), + workflow=AtHarnessLanggraphWorkflow, + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/project/tools.py b/examples/tutorials/10_async/10_temporal/130_langgraph/project/tools.py new file mode 100644 index 000000000..e7220016e --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/130_langgraph/project/tools.py @@ -0,0 +1,37 @@ +"""Tool definitions for the 130_langgraph temporal agent.""" + +from langchain_core.tools import Tool + + +def get_weather(city: str) -> str: + """Get the current weather for a city. + + Args: + city: The name of the city to get weather for. + + Returns: + A string describing the weather conditions. + """ + return f"The weather in {city} is sunny and 72°F" + + +async def aget_weather(city: str) -> str: + """Native async tool entrypoint. + + ``tools_node`` runs inline in the Temporal workflow and invokes tools via + ``tool.ainvoke``. A sync-only tool forces LangChain to bridge through + ``run_in_executor`` (a thread pool), which the deterministic Temporal + workflow event loop forbids (``NotImplementedError``). Providing a real + coroutine keeps tool execution on the workflow loop. + """ + return get_weather(city) + + +weather_tool = Tool( + name="get_weather", + func=get_weather, + coroutine=aget_weather, + description="Get the current weather for a city. Input should be a city name.", +) + +TOOLS = [weather_tool] diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/project/workflow.py b/examples/tutorials/10_async/10_temporal/130_langgraph/project/workflow.py new file mode 100644 index 000000000..b9224ca00 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/130_langgraph/project/workflow.py @@ -0,0 +1,80 @@ +"""Temporal workflow for at130-langgraph. + +Each turn the workflow runs the LangGraph graph (``project/graph.py``) via the +``temporalio.contrib.langgraph`` plugin. The plugin runs the LLM ``agent`` node +as a durable Temporal activity and the ``tools`` node inline in the workflow. + +Multi-turn memory is kept on the workflow instance (``self._messages``) — it's +durable and replay-safe for free, so no checkpoint database is needed. +""" + +from __future__ import annotations + +import json +from typing import Any + +from temporalio import workflow +from temporalio.contrib.langgraph import graph as lg_graph + +from agentex.lib import adk +from project.graph import GRAPH_NAME +from agentex.lib.adk import emit_langgraph_messages +from agentex.protocol.acp import SendEventParams, CreateTaskParams +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 +from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow + +environment_variables = EnvironmentVariables.refresh() + +if environment_variables.WORKFLOW_NAME is None: + raise ValueError("Environment variable WORKFLOW_NAME is not set") +if environment_variables.AGENT_NAME is None: + raise ValueError("Environment variable AGENT_NAME is not set") + +logger = make_logger(__name__) + + +@workflow.defn(name=environment_variables.WORKFLOW_NAME) +class AtHarnessLanggraphWorkflow(BaseWorkflow): + """Runs the LangGraph agent each turn; its nodes run as Temporal activities.""" + + def __init__(self) -> None: + super().__init__(display_name=environment_variables.AGENT_NAME) + self._complete_task = False + self._messages: list[Any] = [] + self._emitted = 0 + + @workflow.signal(name=SignalName.RECEIVE_EVENT) + async def on_task_event_send(self, params: SendEventParams) -> None: + """Echo the user's message, run the graph, surface the new messages.""" + await adk.messages.create(task_id=params.task.id, content=params.event.content) + self._messages.append({"role": "user", "content": params.event.content.content}) + + compiled = lg_graph(GRAPH_NAME).compile() + result = await compiled.ainvoke({"messages": self._messages}) + self._messages = result["messages"] + + await emit_langgraph_messages(self._messages[self._emitted :], params.task.id) + self._emitted = len(self._messages) + + @workflow.signal + async def complete_task_signal(self) -> None: + self._complete_task = True + + @workflow.run + async def on_task_create(self, params: CreateTaskParams) -> str: + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=( + f"Task initialized with params:\n{json.dumps(params.params, indent=2)}\n\n" + "Send me a message and I'll respond using a LangGraph agent whose nodes " + "run as durable Temporal activities." + ), + ), + ) + await workflow.wait_condition(lambda: self._complete_task, timeout=None) + return "Task completed" diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/pyproject.toml b/examples/tutorials/10_async/10_temporal/130_langgraph/pyproject.toml new file mode 100644 index 000000000..6d2262761 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/130_langgraph/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "at130-langgraph" +version = "0.1.0" +description = "A Temporal-backed LangGraph agent (harness variant) whose nodes run as Temporal activities" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "temporalio[langgraph]>=1.27.0", + "langchain-openai", + "langchain-core", + "grandalf", + "python-dotenv", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", + "debugpy>=1.8.15", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/130_langgraph/tests/test_agent.py new file mode 100644 index 000000000..f2292389f --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/130_langgraph/tests/test_agent.py @@ -0,0 +1,106 @@ +"""Integration tests for the Temporal harness LangGraph agent (live agent required). + +These drive a *running* agent over the AgentEx API and verify that: +- the agent sends a welcome message on task creation, +- a weather question triggers a tool_request / tool_response round-trip + (proving the LLM node ran as a Temporal activity and the tool node ran), +- the final answer reflects the tool output. + +To run: +1. Start the agent (worker + ACP server): ``agentex agents run --manifest manifest.yaml`` +2. Set AGENTEX_API_BASE_URL if not using the default +3. ``pytest tests/test_agent.py -v`` +""" + +import os +import uuid + +import pytest +import pytest_asyncio +from test_utils.async_utils import ( + poll_messages, + send_event_and_poll_yielding, +) + +from agentex import AsyncAgentex +from agentex.types.task_message import TaskMessage +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest + +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "at130-langgraph") + + +@pytest_asyncio.fixture +async def client(): + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """The Temporal-backed LangGraph agent responds and uses tools.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_id: str): + """Create a task, ask about weather, verify the tool round-trip.""" + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + task_creation_found = False + async for message in poll_messages(client=client, task_id=task.id, timeout=30, sleep_interval=1.0): + assert isinstance(message, TaskMessage) + if message.content and message.content.type == "text" and message.content.author == "agent": + task_creation_found = True + break + assert task_creation_found, "Task creation welcome message not found" + + seen_tool_request = False + seen_tool_response = False + final_message = None + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message="What is the weather in San Francisco? Use your tool.", + timeout=60, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + + if message.content and message.content.type == "tool_request": + seen_tool_request = True + if message.content and message.content.type == "tool_response": + seen_tool_response = True + + if message.content and message.content.type == "text" and message.content.author == "agent": + final_message = message + content_length = len(getattr(message.content, "content", "") or "") + if getattr(message, "streaming_status", None) in (None, "DONE") and content_length > 0: + if seen_tool_response: + break + + assert seen_tool_request, "Expected a tool_request (agent calling get_weather)" + assert seen_tool_response, "Expected a tool_response (get_weather result)" + assert final_message is not None, "Expected a final agent text message" + final_text = getattr(final_message.content, "content", None) if final_message.content else None + assert isinstance(final_text, str) and len(final_text) > 0 + assert "72" in final_text, "Expected weather response to mention 72°F" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/.dockerignore b/examples/tutorials/10_async/10_temporal/140_claude_code/.dockerignore new file mode 100644 index 000000000..c49489471 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/140_claude_code/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/Dockerfile b/examples/tutorials/10_async/10_temporal/140_claude_code/Dockerfile new file mode 100644 index 000000000..c909ee6c7 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/140_claude_code/Dockerfile @@ -0,0 +1,46 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +RUN npm install -g @anthropic-ai/claude-code || true + +ENV UV_HTTP_TIMEOUT=1000 + +COPY 10_async/10_temporal/140_claude_code/pyproject.toml /app/140_claude_code/pyproject.toml +COPY 10_async/10_temporal/140_claude_code/README.md /app/140_claude_code/README.md + +WORKDIR /app/140_claude_code + +COPY 10_async/10_temporal/140_claude_code/project /app/140_claude_code/project +COPY 10_async/10_temporal/140_claude_code/tests /app/140_claude_code/tests +COPY test_utils /app/test_utils + +RUN uv pip install --system .[dev] + +ENV PYTHONPATH=/app + +ENV AGENT_NAME=at140-claude-code + +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When deploying the worker, replace the CMD with: +# CMD ["python", "project/run_worker.py"] diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/README.md b/examples/tutorials/10_async/10_temporal/140_claude_code/README.md new file mode 100644 index 000000000..61cc94183 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/140_claude_code/README.md @@ -0,0 +1,76 @@ +# Tutorial 140 (async/temporal): Temporal Claude Code Agent + +This tutorial demonstrates how to build a **Temporal-backed** agent that +spawns the Claude Code CLI as a local subprocess and delivers its output +through the Agentex unified harness surface via ``ClaudeCodeTurn`` and +``UnifiedEmitter.auto_send_turn``, with Temporal providing durable execution +and crash recovery. + +## Key Concepts + +### Temporal + ClaudeCodeTurn + +The Temporal workflow (``project/workflow.py``) holds state durably. Each user +message arrives as a signal (``on_task_event_send``), spawns the Claude Code +CLI locally, wraps the stdout line stream in ``ClaudeCodeTurn``, and pushes +events to the task's Redis stream via ``UnifiedEmitter.auto_send_turn``. + +``workflow.now()`` is passed as ``created_at`` so message timestamps are +deterministic under Temporal replay. + +### Multi-turn session resume + +The workflow persists the Claude Code ``session_id`` from the ``result`` +envelope. On the next turn, ``-r `` is passed to the CLI to +resume the conversation. Temporal's durable state ensures the session_id +survives worker crashes. + +### Note on subprocess in workflow code + +For simplicity, this tutorial spawns the subprocess directly inside the +workflow signal handler. For production use, move the spawn into a custom +Temporal activity so each subprocess invocation gets independent retry and +timeout guarantees. See +``examples/tutorials/10_async/10_temporal/030_custom_activities/`` for +that pattern. + +### Injectable spawn seam + +``_spawn_claude`` in ``project/workflow.py`` is a top-level async generator. +Tests monkeypatch it to inject pre-recorded stream-json lines so offline +unit tests run without the CLI. + +## Files + +| File | Description | +|------|-------------| +| ``project/acp.py`` | Thin ACP server; wires Temporal (no handlers) | +| ``project/workflow.py`` | Temporal workflow + ``_spawn_claude`` seam | +| ``project/run_worker.py`` | Temporal worker entry point | +| ``tests/test_agent.py`` | Live integration tests (needs CLI + Temporal + API key) | +| ``tests/test_agent_offline.py`` | Offline unit tests with injected fake subprocess | +| ``manifest.yaml`` | Agent configuration | + +## Running Locally (live) + +Requires Temporal server, the ``claude`` CLI, and ``ANTHROPIC_API_KEY``: + +```bash +npm install -g @anthropic-ai/claude-code +export ANTHROPIC_API_KEY=sk-ant-... +agentex agents run +``` + +## Running Offline Tests + +No CLI, Temporal, or API key needed: + +```bash +uv run pytest tests/test_agent_offline.py -v +``` + +## Notes + +- Production isolation (sandbox, secrets, MCP) is the golden agent's concern. +- The subprocess spawn should be moved to a custom activity in production. +- The ``--verbose`` flag is included to match the golden agent's invocation. diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/manifest.yaml b/examples/tutorials/10_async/10_temporal/140_claude_code/manifest.yaml new file mode 100644 index 000000000..9328b1713 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/140_claude_code/manifest.yaml @@ -0,0 +1,62 @@ +build: + context: + root: ../../../ + include_paths: + - 10_async/10_temporal/140_claude_code + - test_utils + dockerfile: 10_async/10_temporal/140_claude_code/Dockerfile + dockerignore: 10_async/10_temporal/140_claude_code/.dockerignore + +local_development: + agent: + port: 8000 + host_address: host.docker.internal + paths: + acp: project/acp.py + worker: project/run_worker.py + +agent: + acp_type: async + name: at140-claude-code + description: A Temporal-backed Claude Code agent streaming the unified harness surface via a local CLI subprocess + + temporal: + enabled: true + workflows: + - name: at140-claude-code + queue_name: at140_claude_code_queue + + credentials: + - env_var_name: ANTHROPIC_API_KEY + secret_name: anthropic-api-key + secret_key: api-key + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + - env_var_name: SGP_ACCOUNT_ID + secret_name: sgp-account-id + secret_key: account-id + - env_var_name: SGP_CLIENT_BASE_URL + secret_name: sgp-client-base-url + secret_key: url + +deployment: + image: + repository: "" + tag: "latest" + + global: + agent: + name: "at140-claude-code" + description: "A Temporal-backed Claude Code agent streaming via local CLI subprocess" + replicaCount: 1 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/project/__init__.py b/examples/tutorials/10_async/10_temporal/140_claude_code/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/project/acp.py b/examples/tutorials/10_async/10_temporal/140_claude_code/project/acp.py new file mode 100644 index 000000000..07258f6d8 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/140_claude_code/project/acp.py @@ -0,0 +1,31 @@ +"""ACP server for the Temporal Claude Code tutorial. + +This file is intentionally thin. When ``acp_type="async"`` is combined +with ``TemporalACPConfig``, FastACP auto-wires: + + HTTP task/create -> @workflow.run on the workflow class + HTTP task/event/send -> @workflow.signal(SignalName.RECEIVE_EVENT) + HTTP task/cancel -> workflow cancellation via the Temporal client + +The actual agent code lives in ``project/workflow.py`` and is executed by +the Temporal worker (``project/run_worker.py``), not by this HTTP process. +""" + +from __future__ import annotations + +import os + +from dotenv import load_dotenv + +load_dotenv() + +from agentex.lib.types.fastacp import TemporalACPConfig +from agentex.lib.sdk.fastacp.fastacp import FastACP + +acp = FastACP.create( + acp_type="async", + config=TemporalACPConfig( + type="temporal", + temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), + ), +) diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/project/activities.py b/examples/tutorials/10_async/10_temporal/140_claude_code/project/activities.py new file mode 100644 index 000000000..dcba0f9a7 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/140_claude_code/project/activities.py @@ -0,0 +1,139 @@ +"""Temporal activity for the Claude Code tutorial. + +Subprocess spawning (and any other I/O) must run inside a Temporal *activity*, +not in workflow code. Temporal runs workflow + signal-handler bodies on a +deterministic sandbox event loop that does not implement ``subprocess_exec`` +(or threads / sockets), so spawning the CLI directly in the signal handler +raises ``NotImplementedError``. This activity runs the Claude Code CLI, drives +the ``ClaudeCodeTurn`` through ``UnifiedEmitter.auto_send_turn`` (the async +Redis push path), and returns the turn result to the workflow. + +The ``_spawn_claude`` async generator is an injectable seam: offline tests +provide a fake that yields pre-recorded stdout lines so no real CLI runs. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, AsyncIterator +from datetime import datetime + +from temporalio import activity + +from agentex.lib.adk import ClaudeCodeTurn +from agentex.lib.core.harness import UnifiedEmitter +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.model_utils import BaseModel + +logger = make_logger(__name__) + +RUN_CLAUDE_CODE_TURN_ACTIVITY = "run_claude_code_turn" + + +class RunClaudeCodeTurnParams(BaseModel): + """Arguments for one Claude Code turn run inside an activity.""" + + task_id: str + prompt: str + trace_id: str | None = None + parent_span_id: str | None = None + session_id: str | None = None + created_at: datetime | None = None + + +class RunClaudeCodeTurnResult(BaseModel): + """Result returned from the activity to the workflow.""" + + final_text: str + session_id: str | None = None + + +async def _spawn_claude(prompt: str, session_id: str | None = None) -> AsyncIterator[str]: + """Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines. + + Pass ``session_id`` to resume a previous Claude Code session (multi-turn + memory via ``-r ``). + + Injectable seam: tests monkeypatch this with a fake async iterator so no + real CLI invocation is needed offline. + """ + cmd = [ + "claude", + "-p", + "--output-format", + "stream-json", + "--verbose", + ] + if session_id: + cmd.extend(["-r", session_id]) + + proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + assert proc.stdout is not None + assert proc.stdin is not None + + proc.stdin.write(prompt.encode()) + proc.stdin.close() + + # Drain stderr concurrently. With --verbose, Claude Code can write enough to + # stderr to fill the OS pipe buffer; if we only read stdout, the CLI blocks + # on its stderr write while we block reading stdout — a deadlock. A + # background task keeps stderr flowing so stdout never stalls. + async def _drain_stderr() -> None: + assert proc.stderr is not None + async for _ in proc.stderr: + pass + + stderr_task = asyncio.create_task(_drain_stderr()) + + try: + buffer = "" + async for chunk in proc.stdout: + buffer += chunk.decode("utf-8", errors="replace") + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if line: + yield line + + if buffer.strip(): + yield buffer.strip() + + await proc.wait() + finally: + # Release the subprocess and stderr drain task even if the consumer + # abandons the generator early (task cancellation / client disconnect): + # cancel the drain task and terminate+reap the process if it is still + # running, so neither is leaked. + stderr_task.cancel() + try: + await stderr_task + except asyncio.CancelledError: + pass + if proc.returncode is None: + try: + proc.terminate() + except ProcessLookupError: + pass + await proc.wait() + + +@activity.defn(name=RUN_CLAUDE_CODE_TURN_ACTIVITY) +async def run_claude_code_turn(params: RunClaudeCodeTurnParams) -> dict[str, Any]: + """Run one Claude Code turn end-to-end and stream events to the task. + + Runs in an activity (real asyncio loop) so subprocess I/O is permitted. + """ + emitter = UnifiedEmitter( + task_id=params.task_id, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) + turn = ClaudeCodeTurn(_spawn_claude(params.prompt, session_id=params.session_id)) + result = await emitter.auto_send_turn(turn, created_at=params.created_at) + + return RunClaudeCodeTurnResult(final_text=result.final_text, session_id=turn.session_id).model_dump() diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/project/run_worker.py b/examples/tutorials/10_async/10_temporal/140_claude_code/project/run_worker.py new file mode 100644 index 000000000..58802737e --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/140_claude_code/project/run_worker.py @@ -0,0 +1,41 @@ +"""Temporal worker for the Claude Code tutorial. + +Run as a separate long-lived process alongside the ACP HTTP server. The +worker polls Temporal for workflow + activity tasks and executes them. + +The Claude Code CLI subprocess runs in the ``run_claude_code_turn`` activity +(registered below alongside the built-in Agentex activities), because +subprocess I/O is not permitted on the Temporal workflow event loop. +""" + +import asyncio + +from project.workflow import At140ClaudeCodeWorkflow +from project.activities import run_claude_code_turn +from agentex.lib.utils.debug import setup_debug_if_enabled +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.activities import get_all_activities +from agentex.lib.core.temporal.workers.worker import AgentexWorker + +environment_variables = EnvironmentVariables.refresh() +logger = make_logger(__name__) + + +async def main(): + setup_debug_if_enabled() + + task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE + if task_queue_name is None: + raise ValueError("WORKFLOW_TASK_QUEUE is not set") + + worker = AgentexWorker(task_queue=task_queue_name) + + await worker.run( + activities=[run_claude_code_turn, *get_all_activities()], + workflow=At140ClaudeCodeWorkflow, + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/project/workflow.py b/examples/tutorials/10_async/10_temporal/140_claude_code/project/workflow.py new file mode 100644 index 000000000..7f50ba8d5 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/140_claude_code/project/workflow.py @@ -0,0 +1,137 @@ +"""Temporal workflow for the Claude Code tutorial. + +Holds conversation state (session_id for multi-turn resume) durably across +crashes. Each user message triggers ``on_task_event_send``, which delegates the +turn to the ``run_claude_code_turn`` activity. The activity spawns the Claude +Code CLI, wraps its stdout in ``ClaudeCodeTurn``, and delivers the turn via +``UnifiedEmitter.auto_send_turn`` (the async Redis push path). + +Note on subprocess inside Temporal +------------------------------------ +Subprocess (and all other) I/O must run in a Temporal *activity*, never in +workflow code. Temporal runs workflow + signal-handler bodies on a +deterministic sandbox event loop that does not implement ``subprocess_exec`` +(spawning the CLI there raises ``NotImplementedError``). The activity also gets +Temporal's retry + timeout guarantees. See +``examples/tutorials/10_async/10_temporal/030_custom_activities/`` for the +activity pattern. +""" + +from __future__ import annotations + +import os +import json +from datetime import timedelta + +from temporalio import workflow + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CreateTaskParams +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.types.workflow import SignalName +from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config + +with workflow.unsafe.imports_passed_through(): + from project.activities import RunClaudeCodeTurnParams, run_claude_code_turn + +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) +) + +environment_variables = EnvironmentVariables.refresh() + +if environment_variables.WORKFLOW_NAME is None: + raise ValueError("Environment variable WORKFLOW_NAME is not set") +if environment_variables.AGENT_NAME is None: + raise ValueError("Environment variable AGENT_NAME is not set") + +logger = make_logger(__name__) + + +@workflow.defn(name=environment_variables.WORKFLOW_NAME) +class At140ClaudeCodeWorkflow(BaseWorkflow): + """Temporal workflow that runs Claude Code locally for each user message. + + Persists the Claude Code session_id across turns so the CLI can resume + the conversation (``-r ``). Temporal's durable state ensures + the session_id survives worker crashes. + """ + + def __init__(self): + super().__init__(display_name=environment_variables.AGENT_NAME) + self._complete_task = False + self._turn_number = 0 + # Claude Code session_id for multi-turn resume. + self._session_id: str | None = None + + @workflow.signal(name=SignalName.RECEIVE_EVENT) + async def on_task_event_send(self, params: SendEventParams) -> None: + """Handle a user message: spawn Claude Code and push events to the task stream.""" + self._turn_number += 1 + task_id = params.task.id + prompt = params.event.content.content + logger.info("Turn %d for task %s", self._turn_number, task_id) + + await adk.messages.create(task_id=task_id, content=params.event.content) + + async with adk.tracing.span( + trace_id=task_id, + task_id=task_id, + name=f"Turn {self._turn_number}", + input={"message": prompt}, + ) as span: + # Delegate the subprocess turn to an activity: subprocess I/O is not + # permitted on the Temporal workflow event loop. The activity streams + # events to the task and returns the final text + session_id. + # workflow.now() gives a deterministic timestamp under replay. + result = await workflow.execute_activity( + run_claude_code_turn, + RunClaudeCodeTurnParams( + task_id=task_id, + prompt=prompt, + trace_id=task_id, + parent_span_id=span.id if span else None, + session_id=self._session_id, + created_at=workflow.now(), + ), + start_to_close_timeout=timedelta(minutes=5), + ) + + # Capture session_id to enable Claude Code resume on the next turn. + sid = result.get("session_id") + if sid: + self._session_id = sid + + if span: + span.output = {"final_text": result.get("final_text")} + + @workflow.run + async def on_task_create(self, params: CreateTaskParams) -> str: + logger.info("Task created: %s", params.task.id) + + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=( + f"Task initialized with params:\n{json.dumps(params.params, indent=2)}\n" + "Send me a message and I'll run it through Claude Code locally." + ), + ), + ) + + await workflow.wait_condition(lambda: self._complete_task, timeout=None) + return "Task completed" + + @workflow.signal + async def complete_task_signal(self) -> None: + logger.info("Received complete_task signal") + self._complete_task = True diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/pyproject.toml b/examples/tutorials/10_async/10_temporal/140_claude_code/pyproject.toml new file mode 100644 index 000000000..b9d517267 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/140_claude_code/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "at140-claude-code" +version = "0.1.0" +description = "A Temporal-backed Claude Code agent streaming the unified harness surface via a local CLI subprocess" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "temporalio>=1.18.2", + "python-dotenv>=1.0,<2", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "debugpy>=1.8.15", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/140_claude_code/tests/test_agent.py new file mode 100644 index 000000000..767c707b9 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/140_claude_code/tests/test_agent.py @@ -0,0 +1,249 @@ +"""Tests for the Temporal Claude Code tutorial agent. + +LIVE tests (``TestClaudeCodeLive``): + - Require Temporal server, the ACP server, the Temporal worker, the ``claude`` + CLI on PATH, and ``ANTHROPIC_API_KEY`` set. + - Run the full agent end-to-end against a live Agentex server. + - Skipped automatically when ``CLAUDE_LIVE_TESTS`` is not set to ``1``. + +OFFLINE unit tests (``TestClaudeCodeOffline``): + - Inject a fake async iterator of pre-recorded stream-json lines. + - Assert the ``ClaudeCodeTurn`` + ``UnifiedEmitter`` pipeline drives + ``auto_send_turn``, populates usage, and satisfies the ``HarnessTurn`` + protocol. + - Always run -- no CLI or API key needed. +""" + +from __future__ import annotations + +import os +import json +from typing import AsyncIterator + +import pytest + +from agentex.types.task_message import TaskMessage + +# --------------------------------------------------------------------------- +# Recorded stream-json fixtures +# --------------------------------------------------------------------------- + +_TEXT_ONLY_LINES: list[str] = [ + json.dumps({"type": "system", "subtype": "init", "session_id": "sess-temporal-offline-1"}), + json.dumps( + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "Hello from Temporal Claude Code!"}]}, + } + ), + json.dumps( + { + "type": "result", + "session_id": "sess-temporal-offline-1", + "usage": {"input_tokens": 15, "output_tokens": 7}, + "cost_usd": 0.00015, + "duration_ms": 350, + "num_turns": 1, + } + ), +] + + +async def _fake_lines(lines: list[str]) -> AsyncIterator[str]: + """Async iterator of pre-recorded stream-json lines (no subprocess).""" + for line in lines: + yield line + + +# --------------------------------------------------------------------------- +# Fake streaming backend +# --------------------------------------------------------------------------- + + +class _FakeCtx: + def __init__(self, sink, content_type, initial_content): + self.sink = sink + self.content_type = content_type + self.task_message = TaskMessage(id="msg-t1", task_id="task-temporal-offline", content=initial_content) + + async def __aenter__(self): + self.sink.append(("open", self.content_type)) + return self + + async def __aexit__(self, *a): + await self.close() + return False + + async def close(self): + self.sink.append(("close", self.content_type)) + + async def stream_update(self, update): + self.sink.append(("update", update)) + return update + + +class _FakeStreaming: + def __init__(self): + self.sink: list = [] + + def streaming_task_message_context(self, task_id, initial_content, streaming_mode="coalesced", created_at=None): # noqa: ARG002 + ctype = getattr(initial_content, "type", None) + self.sink.append(("ctx", ctype)) + return _FakeCtx(self.sink, ctype, initial_content) + + +# --------------------------------------------------------------------------- +# Offline tests (always run -- no CLI or API key needed) +# --------------------------------------------------------------------------- + + +class TestClaudeCodeOffline: + """Unit tests that run without a real claude CLI, Temporal, or network.""" + + @pytest.mark.asyncio + async def test_auto_send_text_only_produces_output(self): + """auto_send_turn result carries the agent's reply text.""" + from agentex.lib.adk import ClaudeCodeTurn + from agentex.lib.core.harness import UnifiedEmitter + + fake_streaming = _FakeStreaming() + turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) + emitter = UnifiedEmitter( + task_id="offline-temporal", + trace_id=None, + parent_span_id=None, + tracer=False, + streaming=fake_streaming, + ) + result = await emitter.auto_send_turn(turn) + assert "Hello from Temporal Claude Code" in result.final_text + + @pytest.mark.asyncio + async def test_usage_populated_after_stream_exhausted(self): + """Usage is populated after the events stream is exhausted.""" + from agentex.lib.adk import ClaudeCodeTurn + from agentex.lib.core.harness import UnifiedEmitter + + fake_streaming = _FakeStreaming() + turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) + emitter = UnifiedEmitter( + task_id="t", + trace_id=None, + parent_span_id=None, + tracer=False, + streaming=fake_streaming, + ) + await emitter.auto_send_turn(turn) + usage = turn.usage() + assert usage.input_tokens == 15 + assert usage.output_tokens == 7 + assert usage.num_llm_calls == 1 + + @pytest.mark.asyncio + async def test_stream_task_message_done_present(self): + """StreamTaskMessageDone must appear via yield_turn on a ClaudeCodeTurn.""" + from agentex.lib.adk import ClaudeCodeTurn + from agentex.lib.core.harness import UnifiedEmitter + from agentex.types.task_message_update import StreamTaskMessageDone + + turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) + emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) + events = [e async for e in emitter.yield_turn(turn)] + assert any(isinstance(e, StreamTaskMessageDone) for e in events), ( + "Expected at least one StreamTaskMessageDone event" + ) + + @pytest.mark.asyncio + async def test_session_id_captured_in_result_envelope(self): + """The result envelope carries session_id (multi-turn resume support).""" + from agentex.lib.adk import ClaudeCodeTurn + from agentex.lib.core.harness import UnifiedEmitter + + fake_streaming = _FakeStreaming() + turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) + emitter = UnifiedEmitter( + task_id="t", + trace_id=None, + parent_span_id=None, + tracer=False, + streaming=fake_streaming, + ) + await emitter.auto_send_turn(turn) + assert turn._result_envelope is not None + assert turn._result_envelope.get("session_id") == "sess-temporal-offline-1" + + +# --------------------------------------------------------------------------- +# Live tests (skipped unless CLAUDE_LIVE_TESTS=1) +# --------------------------------------------------------------------------- + +pytestmark_live = pytest.mark.skipif( + not os.environ.get("CLAUDE_LIVE_TESTS"), + reason="Set CLAUDE_LIVE_TESTS=1 and ensure the `claude` CLI + ANTHROPIC_API_KEY are available", +) + +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "at140-claude-code") + + +@pytestmark_live +class TestClaudeCodeLive: + """Live Temporal tests -- needs Temporal server + the claude CLI + ANTHROPIC_API_KEY.""" + + @pytest.fixture + def client(self): + from agentex import Agentex + + return Agentex(base_url=AGENTEX_API_BASE_URL) + + @pytest.fixture + def agent_name(self): + return AGENT_NAME + + @pytest.fixture + def agent_id(self, client, agent_name): + agents = client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent {agent_name!r} not found.") + + def test_send_simple_message(self, client, agent_id: str): + """Create a task, send a message, and poll until a response appears.""" + import time + import uuid + + from agentex.types import TextContentParam + from agentex.types.agent_rpc_params import ParamsSendEventRequest, ParamsCreateTaskRequest + + task = client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)).result + assert task is not None + task_id = task.id + + client.agents.send_event( + agent_id=agent_id, + params=ParamsSendEventRequest( + task_id=task_id, + content=TextContentParam( + author="user", + content="Reply with exactly three words: hello from claude", + type="text", + ), + ), + ) + + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + msgs = client.messages.list(task_id=task_id) + agent_msgs = [m for m in msgs if getattr(m.content, "author", None) == "agent"] + response_msgs = [m for m in agent_msgs if "Task initialized" not in str(getattr(m.content, "content", ""))] + if response_msgs: + assert len(response_msgs) >= 1 + return + time.sleep(3) + + raise AssertionError("No agent response received within 90 s") + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/tests/test_agent_offline.py b/examples/tutorials/10_async/10_temporal/140_claude_code/tests/test_agent_offline.py new file mode 100644 index 000000000..1adc553f1 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/140_claude_code/tests/test_agent_offline.py @@ -0,0 +1,230 @@ +"""Offline unit tests for the Temporal Claude Code tutorial agent. + +These tests do NOT require the ``claude`` CLI, Temporal, or ANTHROPIC_API_KEY. +They inject a fake async iterator of pre-recorded stream-json lines in place of +the real subprocess spawn and a fake streaming backend, then assert that the +workflow's turn logic correctly drives ``UnifiedEmitter.auto_send_turn``. + +The injection seam is the ``_spawn_claude`` function in ``project/workflow.py``. +Tests monkeypatch it with a coroutine returning a pre-recorded async iterator. +""" + +from __future__ import annotations + +import json +from typing import AsyncIterator + +import pytest + +from agentex.lib.adk import ClaudeCodeTurn +from agentex.lib.core.harness import UnifiedEmitter +from agentex.types.task_message import TaskMessage + +# --------------------------------------------------------------------------- +# Recorded fixtures +# --------------------------------------------------------------------------- + +_TEXT_ONLY_LINES: list[str] = [ + json.dumps({"type": "system", "subtype": "init", "session_id": "sess-temporal-1"}), + json.dumps( + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "Hello from Temporal Claude Code!"}]}, + } + ), + json.dumps( + { + "type": "result", + "session_id": "sess-temporal-1", + "usage": {"input_tokens": 15, "output_tokens": 7}, + "cost_usd": 0.00015, + "duration_ms": 350, + "num_turns": 1, + } + ), +] + +_TOOL_CALL_LINES: list[str] = [ + json.dumps({"type": "system", "subtype": "init", "session_id": "sess-temporal-2"}), + json.dumps( + { + "type": "assistant", + "message": { + "content": [ + { + "type": "tool_use", + "id": "tool_temporal", + "name": "Bash", + "input": {"command": "ls /tmp"}, + } + ] + }, + } + ), + json.dumps( + { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "tool_temporal", + "content": "file1\nfile2\n", + "is_error": False, + } + ] + }, + } + ), + json.dumps( + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "Listed files."}]}, + } + ), + json.dumps( + { + "type": "result", + "session_id": "sess-temporal-2", + "usage": {"input_tokens": 30, "output_tokens": 12}, + "cost_usd": 0.0004, + "duration_ms": 600, + "num_turns": 1, + } + ), +] + + +# --------------------------------------------------------------------------- +# Fake streaming backend +# --------------------------------------------------------------------------- + + +class _FakeCtx: + def __init__(self, sink, content_type, initial_content): + self.sink = sink + self.content_type = content_type + self.task_message = TaskMessage(id="msg-t1", task_id="task-temporal-offline", content=initial_content) + + async def __aenter__(self): + self.sink.append(("open", self.content_type)) + return self + + async def __aexit__(self, *a): + await self.close() + return False + + async def close(self): + self.sink.append(("close", self.content_type)) + + async def stream_update(self, update): + self.sink.append(("update", update)) + return update + + +class _FakeStreaming: + def __init__(self): + self.sink: list = [] + + def streaming_task_message_context(self, task_id, initial_content, streaming_mode="coalesced", created_at=None): # noqa: ARG002 + ctype = getattr(initial_content, "type", None) + self.sink.append(("ctx", ctype)) + return _FakeCtx(self.sink, ctype, initial_content) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _fake_lines(lines: list[str]) -> AsyncIterator[str]: + for line in lines: + yield line + + +async def _run_turn(lines: list[str]): + fake_streaming = _FakeStreaming() + turn = ClaudeCodeTurn(_fake_lines(lines)) + emitter = UnifiedEmitter( + task_id="offline-temporal", + trace_id=None, + parent_span_id=None, + tracer=False, + streaming=fake_streaming, + ) + result = await emitter.auto_send_turn(turn) + return result, fake_streaming.sink, turn + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_text_only_produces_agent_output(): + result, sink, _ = await _run_turn(_TEXT_ONLY_LINES) + assert "Hello from Temporal Claude Code" in result.final_text + + +@pytest.mark.asyncio +async def test_usage_from_result_envelope(): + """Usage is available from turn.usage() after the events are exhausted. + + UnifiedEmitter.auto_send_turn evaluates turn.usage() eagerly before the + async generator is consumed, so result.usage is a pre-exhaust snapshot. + Read usage directly from the turn after _run_turn completes instead. + """ + result, _, turn = await _run_turn(_TEXT_ONLY_LINES) + usage = turn.usage() + assert usage.input_tokens == 15 + assert usage.output_tokens == 7 + assert usage.num_llm_calls == 1 + + +@pytest.mark.asyncio +async def test_session_id_captured_in_result_envelope(): + """Verify the result envelope carries session_id (multi-turn resume support).""" + _, _, turn = await _run_turn(_TEXT_ONLY_LINES) + assert turn._result_envelope is not None + assert turn._result_envelope.get("session_id") == "sess-temporal-1" + + +@pytest.mark.asyncio +async def test_tool_call_context_types(): + result, sink, _ = await _run_turn(_TOOL_CALL_LINES) + opened = [s for s in sink if s[0] == "open"] + content_types = [s[1] for s in opened] + assert "tool_request" in content_types + assert "text" in content_types + + +@pytest.mark.asyncio +async def test_spawn_seam_concept(): + """Demonstrate the injectable spawn seam pattern used in project/workflow.py. + + ``_spawn_claude(prompt, session_id=None)`` is a top-level async generator. + A drop-in replacement (e.g. via monkeypatch) supplies pre-recorded lines + and captures call arguments. The session_id parameter enables multi-turn + resume (``claude -r ``). + """ + called: list[tuple] = [] + + async def _fake_spawn(prompt: str, session_id=None) -> AsyncIterator[str]: + called.append((prompt, session_id)) + for line in _TEXT_ONLY_LINES: + yield line + + fake_streaming = _FakeStreaming() + turn = ClaudeCodeTurn(_fake_spawn("temporal prompt", session_id="old-sid")) + emitter = UnifiedEmitter( + task_id="t", + trace_id=None, + parent_span_id=None, + tracer=False, + streaming=fake_streaming, + ) + result = await emitter.auto_send_turn(turn) + + assert called == [("temporal prompt", "old-sid")] + assert "Hello from Temporal Claude Code" in result.final_text diff --git a/examples/tutorials/10_async/10_temporal/150_codex/.dockerignore b/examples/tutorials/10_async/10_temporal/150_codex/.dockerignore new file mode 100644 index 000000000..c49489471 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/150_codex/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/10_temporal/150_codex/Dockerfile b/examples/tutorials/10_async/10_temporal/150_codex/Dockerfile new file mode 100644 index 000000000..e861c7f33 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/150_codex/Dockerfile @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install the codex CLI: the worker spawns `codex exec --json`, so the binary +# must be present on PATH in the image. +RUN npm install -g @openai/codex + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +COPY 10_async/10_temporal/150_codex/pyproject.toml /app/150_codex/pyproject.toml +COPY 10_async/10_temporal/150_codex/README.md /app/150_codex/README.md + +WORKDIR /app/150_codex + +COPY 10_async/10_temporal/150_codex/project /app/150_codex/project +COPY 10_async/10_temporal/150_codex/tests /app/150_codex/tests +COPY test_utils /app/test_utils + +RUN uv pip install --system .[dev] + +ENV PYTHONPATH=/app +ENV AGENT_NAME=at150-codex + +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When deploying the worker, replace CMD with: +# CMD ["python", "-m", "project.run_worker"] diff --git a/examples/tutorials/10_async/10_temporal/150_codex/README.md b/examples/tutorials/10_async/10_temporal/150_codex/README.md new file mode 100644 index 000000000..498b81374 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/150_codex/README.md @@ -0,0 +1,48 @@ +# 150_codex (Temporal) + +Tutorial agent demonstrating the `convert_codex_to_agentex_events` tap, +`CodexTurn`, and `UnifiedEmitter` for a **Temporal-durable** async ACP agent. + +## What this tutorial shows + +- Spawning `codex exec --json` as a **local asyncio subprocess** (no Scale sandbox) + inside a Temporal workflow signal handler. +- Wrapping the stdout line stream in a `CodexTurn`. +- Delivering every canonical `StreamTaskMessage*` event to Redis via + `UnifiedEmitter.auto_send_turn`, passing `created_at=workflow.now()` for + deterministic Temporal replay timestamps. +- Keeping the codex thread ID on the workflow instance (durable across crashes + without an external `adk.state` round-trip). + +> **Production isolation note:** A tutorial agent runs the Codex CLI locally. +> Production-grade isolation (Scale sandbox, secret injection, MCP configuration) +> is handled by the golden agent at +> `teams/sgp/agents/golden_agent/project/harness/providers/codex.py`. + +> **Temporal determinism note:** Subprocess spawning happens inside +> `@workflow.signal` handler bodies. Temporal does NOT replay signal handler +> bodies (only `@workflow.run` is subject to replay constraints), so this is +> safe. A production agent would wrap the subprocess in a Temporal activity for +> full durability and retry semantics. + +## Live runs + +Live runs require: +1. The `codex` CLI on PATH: `npm install -g @openai/codex` +2. `OPENAI_API_KEY` set in the environment. +3. A running Temporal server. + +## Running offline unit tests + +```bash +cd /path/to/scale-agentex-python +uv run --all-packages --all-extras pytest examples/tutorials/10_async/10_temporal/150_codex/tests/test_agent.py -q +``` + +## Running live integration tests + +```bash +export CODEX_LIVE_TESTS=1 +export OPENAI_API_KEY=sk-... +pytest tests/test_agent.py -v +``` diff --git a/examples/tutorials/10_async/10_temporal/150_codex/conftest.py b/examples/tutorials/10_async/10_temporal/150_codex/conftest.py new file mode 100644 index 000000000..6370f278d --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/150_codex/conftest.py @@ -0,0 +1,17 @@ +"""Add the agent's project root to sys.path so ``import project`` works. + +Also sets minimal environment variables so FastACP, tracing, and the +Temporal workflow module can be imported without a running server. +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) + +# AGENT_NAME must match the manifest's agent name: the live test queries the +# server by this name, and project.workflow reads it at import time. +os.environ.setdefault("AGENT_NAME", "at150-codex") +os.environ.setdefault("ACP_URL", "http://localhost:8000") +os.environ.setdefault("WORKFLOW_NAME", "at150-codex") +os.environ.setdefault("WORKFLOW_TASK_QUEUE", "at150_codex_queue") diff --git a/examples/tutorials/10_async/10_temporal/150_codex/manifest.yaml b/examples/tutorials/10_async/10_temporal/150_codex/manifest.yaml new file mode 100644 index 000000000..d64bdfad0 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/150_codex/manifest.yaml @@ -0,0 +1,62 @@ +build: + context: + root: ../../../ + include_paths: + - 10_async/10_temporal/150_codex + - test_utils + dockerfile: 10_async/10_temporal/150_codex/Dockerfile + dockerignore: 10_async/10_temporal/150_codex/.dockerignore + +local_development: + agent: + port: 8000 + host_address: host.docker.internal + paths: + acp: project/acp.py + worker: project/run_worker.py + +agent: + acp_type: async + name: at150-codex + description: Temporal tutorial agent driving the unified harness surface via local codex CLI subprocess + + temporal: + enabled: true + workflows: + - name: at150-codex + queue_name: at150_codex_queue + + credentials: + - env_var_name: OPENAI_API_KEY + secret_name: openai-api-key + secret_key: api-key + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + - env_var_name: SGP_ACCOUNT_ID + secret_name: sgp-account-id + secret_key: account-id + - env_var_name: SGP_CLIENT_BASE_URL + secret_name: sgp-client-base-url + secret_key: url + +deployment: + image: + repository: "" + tag: "latest" + + global: + agent: + name: "at150-codex" + description: "Temporal tutorial agent driving the unified harness surface via local codex CLI subprocess" + replicaCount: 1 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/10_async/10_temporal/150_codex/project/__init__.py b/examples/tutorials/10_async/10_temporal/150_codex/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/10_temporal/150_codex/project/acp.py b/examples/tutorials/10_async/10_temporal/150_codex/project/acp.py new file mode 100644 index 000000000..39a81dde9 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/150_codex/project/acp.py @@ -0,0 +1,32 @@ +"""ACP server for the Temporal Codex harness tutorial. + +This file is intentionally thin. When ``acp_type="async"`` is combined with +``TemporalACPConfig(type="temporal", ...)``, FastACP auto-wires: + + HTTP task/create -> @workflow.run on the workflow class + HTTP task/event/send -> @workflow.signal(SignalName.RECEIVE_EVENT) + HTTP task/cancel -> workflow cancellation via the Temporal client + +so we don't define any handlers here. The actual agent code lives in +``project/workflow.py`` and is executed by the Temporal worker +(``project/run_worker.py``), not by this HTTP process. +""" + +from __future__ import annotations + +import os + +from dotenv import load_dotenv + +load_dotenv() + +from agentex.lib.types.fastacp import TemporalACPConfig +from agentex.lib.sdk.fastacp.fastacp import FastACP + +acp = FastACP.create( + acp_type="async", + config=TemporalACPConfig( + type="temporal", + temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), + ), +) diff --git a/examples/tutorials/10_async/10_temporal/150_codex/project/activities.py b/examples/tutorials/10_async/10_temporal/150_codex/project/activities.py new file mode 100644 index 000000000..363347635 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/150_codex/project/activities.py @@ -0,0 +1,145 @@ +"""Temporal activity for the Codex harness tutorial. + +Subprocess spawning (and any other I/O) must run inside a Temporal *activity*, +not in workflow code. Temporal runs workflow + signal-handler bodies on a +deterministic sandbox event loop that does not implement ``subprocess_exec`` +(or threads / sockets), so spawning ``codex exec`` directly in the signal +handler raises ``NotImplementedError``. This activity runs codex, drives the +``CodexTurn`` through ``UnifiedEmitter.auto_send_turn`` (the async Redis push +path), and returns the turn result to the workflow. + +The ``_spawn_codex`` / ``_process_stdout`` seams are injectable: offline tests +replace them with fakes that yield pre-recorded event lines so no real CLI +runs. +""" + +from __future__ import annotations + +import os +import codecs +import asyncio +from typing import Any +from datetime import datetime +from collections.abc import AsyncIterator + +from temporalio import activity + +from agentex.lib.adk import CodexTurn +from agentex.lib.core.harness import UnifiedEmitter +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.model_utils import BaseModel + +logger = make_logger(__name__) + +RUN_CODEX_TURN_ACTIVITY = "run_codex_turn" + + +class RunCodexTurnParams(BaseModel): + """Arguments for one codex turn run inside an activity.""" + + task_id: str + prompt: str + model: str + trace_id: str | None = None + parent_span_id: str | None = None + thread_id: str | None = None + created_at: datetime | None = None + + +class RunCodexTurnResult(BaseModel): + """Result returned from the activity to the workflow.""" + + final_text: str + session_id: str | None = None + model: str | None = None + + +async def _spawn_codex( + model: str, + thread_id: str | None = None, +) -> asyncio.subprocess.Process: + """Spawn ``codex exec --json`` locally and return the live process. + + Injection seam: tests replace this function with a fake that returns a + mock process whose stdout yields pre-recorded event lines. + + The caller writes the prompt to stdin after the process starts, then + closes stdin so codex knows input is complete. + """ + base_flags = [ + "--json", + "--skip-git-repo-check", + "--dangerously-bypass-approvals-and-sandbox", + "--model", + model, + ] + + if thread_id: + cmd = ["codex", "exec", *base_flags, "resume", thread_id, "-"] + else: + cmd = ["codex", "exec", *base_flags, "-"] + + return await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + # Discard stderr: codex --json writes events to stdout; its stderr is + # progress/debug noise. Capturing it with PIPE but never reading it + # would deadlock once codex fills the OS pipe buffer (~64 KB). + stderr=asyncio.subprocess.DEVNULL, + env={**os.environ}, + ) + + +async def _process_stdout(process: asyncio.subprocess.Process) -> AsyncIterator[str]: + """Yield newline-delimited JSON lines from the process stdout. + + Uses an incremental UTF-8 decoder so a multibyte character split across two + 4 KB reads is decoded correctly instead of being corrupted at the boundary. + """ + assert process.stdout is not None + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + buffer = "" + while True: + chunk = await process.stdout.read(4096) + if not chunk: + break + buffer += decoder.decode(chunk) + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if line: + yield line + buffer += decoder.decode(b"", final=True) + if buffer.strip(): + yield buffer.strip() + + +@activity.defn(name=RUN_CODEX_TURN_ACTIVITY) +async def run_codex_turn(params: RunCodexTurnParams) -> dict[str, Any]: + """Run one codex turn end-to-end and stream events to the task. + + Runs in an activity (real asyncio loop) so subprocess I/O is permitted. + """ + process = await _spawn_codex(params.model, thread_id=params.thread_id) + + assert process.stdin is not None + process.stdin.write(params.prompt.encode("utf-8")) + await process.stdin.drain() + process.stdin.close() + + turn = CodexTurn(events=_process_stdout(process), model=params.model) + emitter = UnifiedEmitter( + task_id=params.task_id, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) + result = await emitter.auto_send_turn(turn, created_at=params.created_at) + + await process.wait() + + return RunCodexTurnResult( + final_text=result.final_text, + session_id=turn.session_id, + model=turn.usage().model, + ).model_dump() diff --git a/examples/tutorials/10_async/10_temporal/150_codex/project/run_worker.py b/examples/tutorials/10_async/10_temporal/150_codex/project/run_worker.py new file mode 100644 index 000000000..b8972806b --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/150_codex/project/run_worker.py @@ -0,0 +1,41 @@ +"""Temporal worker for the Codex harness tutorial. + +Run as a separate long-lived process alongside the ACP HTTP server. The +worker polls Temporal for workflow + activity tasks and executes them. + +The codex CLI subprocess runs in the ``run_codex_turn`` activity (registered +below alongside the built-in Agentex activities), because subprocess I/O is not +permitted on the Temporal workflow event loop. +""" + +import asyncio + +from project.workflow import AtHarnessCodexWorkflow +from project.activities import run_codex_turn +from agentex.lib.utils.debug import setup_debug_if_enabled +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.activities import get_all_activities +from agentex.lib.core.temporal.workers.worker import AgentexWorker + +environment_variables = EnvironmentVariables.refresh() +logger = make_logger(__name__) + + +async def main(): + setup_debug_if_enabled() + + task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE + if task_queue_name is None: + raise ValueError("WORKFLOW_TASK_QUEUE is not set") + + worker = AgentexWorker(task_queue=task_queue_name) + + await worker.run( + activities=[run_codex_turn, *get_all_activities()], + workflow=AtHarnessCodexWorkflow, + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/tutorials/10_async/10_temporal/150_codex/project/workflow.py b/examples/tutorials/10_async/10_temporal/150_codex/project/workflow.py new file mode 100644 index 000000000..1970b478f --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/150_codex/project/workflow.py @@ -0,0 +1,145 @@ +"""Temporal workflow for the Codex harness tutorial. + +Demonstrates the ``convert_codex_to_agentex_events`` tap + ``CodexTurn`` + +``UnifiedEmitter`` for a Temporal-durable ACP agent. + +KEY CONCEPTS DEMONSTRATED: +- Running ``codex exec --json`` in the ``run_codex_turn`` activity. Subprocess + I/O is not permitted on the Temporal workflow event loop (the deterministic + sandbox loop does not implement ``subprocess_exec``), so the signal handler + delegates the turn to an activity, which also gets Temporal's retry + timeout + guarantees. +- Wrapping the stdout line stream in a ``CodexTurn`` (inside the activity). +- Delivering events via ``UnifiedEmitter.auto_send_turn``, which pushes + ``StreamTaskMessage*`` events to Redis so the UI sees tokens in real time. +- Passing ``created_at=workflow.now()`` for deterministic timestamps under + Temporal replay (required for Temporal-safe delivery). +- Persisting the codex thread ID on the workflow instance itself — Temporal's + workflow state is durable, so no external ``adk.state`` round-trip is needed. +""" + +from __future__ import annotations + +import os +from datetime import timedelta + +from temporalio import workflow + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CreateTaskParams +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.types.workflow import SignalName +from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config + +with workflow.unsafe.imports_passed_through(): + from project.activities import RunCodexTurnParams, run_codex_turn + +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) +) + +environment_variables = EnvironmentVariables.refresh() + +if environment_variables.WORKFLOW_NAME is None: + raise ValueError("Environment variable WORKFLOW_NAME is not set") +if environment_variables.AGENT_NAME is None: + raise ValueError("Environment variable AGENT_NAME is not set") + +logger = make_logger(__name__) + +MODEL = os.environ.get("CODEX_MODEL", "o4-mini") + + +@workflow.defn(name=environment_variables.WORKFLOW_NAME) +class AtHarnessCodexWorkflow(BaseWorkflow): + """Long-running Temporal workflow that runs codex exec for each turn. + + Conversation state (codex thread ID + turn counter) is kept on the + workflow instance. Temporal's durable replay reconstructs this state if + the worker crashes, so no external ``adk.state`` round-trip is needed. + """ + + def __init__(self): + super().__init__(display_name=environment_variables.AGENT_NAME) + self._complete_task = False + self._turn_number = 0 + self._codex_thread_id: str | None = None + + @workflow.signal(name=SignalName.RECEIVE_EVENT) + async def on_task_event_send(self, params: SendEventParams) -> None: + """Handle a new user message: spawn codex, stream events via UnifiedEmitter.""" + logger.info("Received task event: %s", params.task.id) + self._turn_number += 1 + + await adk.messages.create(task_id=params.task.id, content=params.event.content) + + user_message = params.event.content.content + + async with adk.tracing.span( + trace_id=params.task.id, + task_id=params.task.id, + name=f"Turn {self._turn_number}", + input={"message": user_message}, + ) as span: + # Delegate the subprocess turn to an activity: subprocess I/O is not + # permitted on the Temporal workflow event loop. The activity streams + # events to the task and returns the final text + codex thread id. + # workflow.now() gives a deterministic timestamp under replay. + result = await workflow.execute_activity( + run_codex_turn, + RunCodexTurnParams( + task_id=params.task.id, + prompt=user_message, + model=MODEL, + trace_id=params.task.id, + parent_span_id=span.id if span else None, + thread_id=self._codex_thread_id, + created_at=workflow.now(), + ), + start_to_close_timeout=timedelta(minutes=5), + ) + + # Persist the codex thread id so the next turn resumes the session. + session_id = result.get("session_id") + if session_id: + self._codex_thread_id = session_id + + if span: + span.output = { + "final_text": result.get("final_text"), + "model": result.get("model"), + } + + @workflow.run + async def on_task_create(self, params: CreateTaskParams) -> str: + """Workflow entry point — keep the conversation alive for incoming signals.""" + logger.info("Task created: %s", params.task.id) + + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=( + f"Task initialized.\n" + f"Send me a message and I'll run codex (local subprocess) " + f"to answer, streaming events via the unified harness surface." + ), + ), + ) + + await workflow.wait_condition(lambda: self._complete_task, timeout=None) + return "Task completed" + + @workflow.signal + async def complete_task_signal(self) -> None: + """Graceful workflow shutdown signal.""" + logger.info("Received complete_task signal") + self._complete_task = True diff --git a/examples/tutorials/10_async/10_temporal/150_codex/pyproject.toml b/examples/tutorials/10_async/10_temporal/150_codex/pyproject.toml new file mode 100644 index 000000000..7e1d6250f --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/150_codex/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "at150-codex" +version = "0.1.0" +description = "Temporal tutorial agent driving the unified harness surface via local codex CLI subprocess" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "temporalio>=1.18.2", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", + "debugpy>=1.8.15", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/examples/tutorials/10_async/10_temporal/150_codex/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/150_codex/tests/test_agent.py new file mode 100644 index 000000000..fa6c66083 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/150_codex/tests/test_agent.py @@ -0,0 +1,275 @@ +"""Tests for the Temporal Codex harness tutorial agent. + +LIVE tests (``TestLiveCodexAgent``): + - Require the ``codex`` CLI on PATH, ``OPENAI_API_KEY``, and a running + Temporal + Agentex server. + - Skipped automatically when ``CODEX_LIVE_TESTS`` is not set to ``1``. + +OFFLINE unit tests (``TestOfflineCodexWorkflow``): + - Inject a fake async iterator of pre-recorded codex event lines. + - Assert the signal handler drives ``UnifiedEmitter.auto_send_turn`` and + captures the codex thread ID on the workflow instance. + - Always run. +""" + +from __future__ import annotations + +import os +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +SAMPLE_EVENTS: list[dict[str, Any]] = [ + {"type": "thread.started", "thread_id": "thread-temporal-1"}, + {"type": "turn.started"}, + { + "type": "item.started", + "item": {"id": "msg-t1", "type": "agent_message", "text": "Hello"}, + }, + { + "type": "item.completed", + "item": {"id": "msg-t1", "type": "agent_message", "text": "Hello from Temporal!"}, + }, + { + "type": "turn.completed", + "usage": {"input_tokens": 6, "output_tokens": 3, "total_tokens": 9}, + }, +] + + +async def _fake_event_stream(): + """Async iterator of pre-recorded codex event JSON lines (no subprocess).""" + for evt in SAMPLE_EVENTS: + yield json.dumps(evt) + + +class _FakeSpan: + id = "span-temporal-1" + output: Any = None + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + pass + + +class TestOfflineCodexWorkflow: + """Unit tests that run without a real codex CLI, Temporal, or network.""" + + @pytest.mark.asyncio + async def test_codex_turn_usage_with_temporal_events(self): + """CodexTurn.usage() is correct after exhausting the temporal sample events.""" + from agentex.lib.adk import CodexTurn + + turn = CodexTurn(events=_fake_event_stream(), model="o4-mini") + + _ = [e async for e in turn.events] + + usage = turn.usage() + assert usage.input_tokens == 6 + assert usage.output_tokens == 3 + assert usage.model == "o4-mini" + + @pytest.mark.asyncio + async def test_unified_emitter_auto_send_with_created_at(self): + """UnifiedEmitter.auto_send_turn accepts created_at=None without error.""" + from agentex.lib.adk import CodexTurn + from agentex.lib.core.harness import UnifiedEmitter + from agentex.types.task_message import TaskMessage + from agentex.types.text_content import TextContent + + turn = CodexTurn(events=_fake_event_stream(), model="o4-mini") + + real_task_msg = TaskMessage( + id="msg-fake", + task_id="t", + content=TextContent(type="text", author="agent", content=""), + ) + + fake_streaming = MagicMock() + fake_ctx = AsyncMock() + fake_ctx.__aenter__ = AsyncMock(return_value=fake_ctx) + fake_ctx.__aexit__ = AsyncMock(return_value=False) + fake_ctx.stream_update = AsyncMock(return_value=MagicMock()) + fake_ctx.close = AsyncMock() + fake_ctx.task_message = real_task_msg + fake_streaming.streaming_task_message_context = MagicMock(return_value=fake_ctx) + + emitter = UnifiedEmitter( + task_id="t", + trace_id=None, + parent_span_id=None, + streaming=fake_streaming, + ) + + result = await emitter.auto_send_turn(turn, created_at=None) + assert result is not None + + @pytest.mark.asyncio + async def test_thread_id_captured_after_exhausted_stream(self): + """CodexTurn._result captures the thread_id from thread.started.""" + from agentex.lib.adk import CodexTurn + + turn = CodexTurn(events=_fake_event_stream(), model="o4-mini") + _ = [e async for e in turn.events] + + assert turn._result is not None + assert turn._result["session_id"] == "thread-temporal-1" + + @pytest.mark.asyncio + async def test_signal_handler_delegates_to_activity_and_captures_thread_id(self): + """Signal handler runs the turn via execute_activity, increments the turn + counter, and captures the codex thread ID returned by the activity.""" + captured: dict[str, Any] = {} + + async def _fake_execute_activity(_activity, params, **_kw): + captured["params"] = params + return { + "session_id": "thread-temporal-1", + "final_text": "Hello from Temporal!", + "model": "o4-mini", + } + + with patch("project.workflow.adk.messages.create", new=AsyncMock()), patch( + "project.workflow.adk.tracing.span" + ) as mock_span, patch( + "project.workflow.workflow.execute_activity", new=_fake_execute_activity + ), patch("project.workflow.workflow.now", return_value=None): + mock_span.return_value = _FakeSpan() + + from project.workflow import AtHarnessCodexWorkflow + + wf = AtHarnessCodexWorkflow.__new__(AtHarnessCodexWorkflow) + wf._turn_number = 0 + wf._codex_thread_id = None + wf._complete_task = False + wf._display_name = "test" + + params = MagicMock() + params.task.id = "task-temporal-offline-1" + params.event.content.content = "say hello temporal" + + await wf.on_task_event_send(params) + + assert wf._turn_number == 1 + assert wf._codex_thread_id == "thread-temporal-1" + assert captured["params"].prompt == "say hello temporal" + assert captured["params"].thread_id is None + + @pytest.mark.asyncio + async def test_run_codex_turn_activity_streams_and_returns_thread_id(self): + """The run_codex_turn activity drives the turn and returns the thread id.""" + from agentex.lib.core.harness import UnifiedEmitter + + async def _fake_spawn(model, thread_id=None): # noqa: ARG001 + fake_stdin = MagicMock() + fake_stdin.write = MagicMock() + fake_stdin.drain = AsyncMock() + fake_stdin.close = MagicMock() + proc = MagicMock() + proc.stdin = fake_stdin + proc.wait = AsyncMock(return_value=0) + return proc + + async def _fake_process_stdout(_process): # noqa: ARG001 + for evt in SAMPLE_EVENTS: + yield json.dumps(evt) + + class _FakeTurnResult: + final_text = "Hello from Temporal!" + + async def _auto_send(_self, turn, *_a, **_kw): + async for _ in turn.events: + pass + return _FakeTurnResult() + + with patch("project.activities._spawn_codex", new=_fake_spawn), patch( + "project.activities._process_stdout", new=_fake_process_stdout + ), patch.object(UnifiedEmitter, "auto_send_turn", new=_auto_send): + from project.activities import RunCodexTurnParams, run_codex_turn + + result = await run_codex_turn( + RunCodexTurnParams( + task_id="task-temporal-offline-1", + prompt="say hello temporal", + model="o4-mini", + ) + ) + + assert result["session_id"] == "thread-temporal-1" + assert result["final_text"] == "Hello from Temporal!" + + +# --------------------------------------------------------------------------- +# Live tests +# --------------------------------------------------------------------------- + +LIVE = os.environ.get("CODEX_LIVE_TESTS", "") == "1" +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "at150-codex") + + +@pytest.mark.skipif( + not LIVE, + reason="Set CODEX_LIVE_TESTS=1 and ensure codex CLI + OPENAI_API_KEY + Temporal are available", +) +class TestLiveCodexAgent: + """End-to-end tests that require the real codex CLI, Temporal, and Agentex server.""" + + @pytest.fixture + def client(self): + from agentex import Agentex + + return Agentex(base_url=AGENTEX_API_BASE_URL) + + @pytest.fixture + def agent_id(self, client): + for agent in client.agents.list(): + if agent.name == AGENT_NAME: + return agent.id + raise ValueError(f"Agent {AGENT_NAME!r} not found.") + + def test_send_simple_message(self, client, agent_id: str): + """Temporal agents process events out of band, so create a task, send an + event, and poll the task's messages for the agent's response.""" + import time + import uuid + + from agentex.types import TextContentParam + from agentex.types.agent_rpc_params import ParamsSendEventRequest, ParamsCreateTaskRequest + + task = client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)).result + assert task is not None + + client.agents.send_event( + agent_id=agent_id, + params=ParamsSendEventRequest( + task_id=task.id, + content=TextContentParam( + author="user", + content="What is 5+5? Reply with just the number.", + type="text", + ), + ), + ) + + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + msgs = client.messages.list(task_id=task.id) + agent_msgs = [m for m in msgs if getattr(m.content, "author", None) == "agent"] + response_msgs = [ + m for m in agent_msgs if "Task initialized" not in str(getattr(m.content, "content", "")) + ] + if response_msgs: + assert len(response_msgs) >= 1 + return + time.sleep(3) + + raise AssertionError("No agent response received within 90 s") diff --git a/examples/tutorials/README.md b/examples/tutorials/README.md new file mode 100644 index 000000000..c92316858 --- /dev/null +++ b/examples/tutorials/README.md @@ -0,0 +1,155 @@ +# AgentEx Tutorials + +Progressive tutorials for learning AgentEx from basics to production-ready patterns. + +## Prerequisites + +**Before starting any tutorial:** +1. Set up your development environment following the [main repo README](https://github.com/scaleapi/scale-agentex#setup) +2. Start backend services from repository root: + ```bash + cd /path/to/agentex-python + make dev + ``` +3. Verify Temporal UI is accessible at http://localhost:8233 + +For troubleshooting, see the [AgentEx debugging guide](https://github.com/scaleapi/scale-agentex#troubleshooting). + +## Learning Path + +```mermaid +graph TD + A[👋 Start Here] --> B[00_sync/000_hello_acp] + B --> C[00_sync/010_multiturn] + C --> D[00_sync/020_streaming] + + D --> E{Need Task
Management?} + E -->|Yes| F[10_async/00_base/
000_hello_acp] + E -->|No| G[Continue with
sync patterns] + + F --> H[00_base/010_multiturn] + H --> I[00_base/020_streaming] + I --> J[00_base/030_tracing] + J --> K[00_base/040_other_sdks] + K --> L[00_base/080_batch_events] + + L --> M{Building for
Production?} + M -->|Yes| N[10_temporal/
000_hello_acp] + M -->|No| O[00_base/090_multi_agent] + + N --> P[10_temporal/010_agent_chat] + P --> Q[10_temporal/020_state_machine] + Q --> R[10_temporal/030_custom_activities] + R --> S[10_temporal/050_guardrails] + + S --> T{Using
OpenAI SDK?} + T -->|Yes| U[10_temporal/060_openai_hello] + U --> V[10_temporal/070_openai_tools] + V --> W[10_temporal/080_openai_hitl] + T -->|No| X[🎉 Production Ready!] + W --> X + + style A fill:#e1f5e1 + style X fill:#fff3cd + style E fill:#e3f2fd + style M fill:#e3f2fd + style T fill:#e3f2fd +``` + +## Tutorial Structure + +### 00_sync/ - Synchronous Agents +Simple request-response patterns without task management. Start here if you're new to AgentEx. + +- **[000_hello_acp](00_sync/000_hello_acp/)** - Your first agent +- **[010_multiturn](00_sync/010_multiturn/)** - Maintaining conversation context +- **[020_streaming](00_sync/020_streaming/)** - Real-time response streaming + +**When to use:** Simple chatbots, stateless Q&A, quick prototypes + +--- + +### 10_async/ - Task-Based Agents + +#### 00_base/ - Non-Temporal Patterns +Task-based architecture without workflow orchestration. Adds task management on top of sync patterns. + +- **[000_hello_acp](10_async/00_base/000_hello_acp/)** - Task-based hello world +- **[010_multiturn](10_async/00_base/010_multiturn/)** - Multiturn with task management +- **[020_streaming](10_async/00_base/020_streaming/)** - Streaming with tasks +- **[030_tracing](10_async/00_base/030_tracing/)** - Observability with Scale Groundplane +- **[040_other_sdks](10_async/00_base/040_other_sdks/)** - Integrating OpenAI, Anthropic, etc. +- **[080_batch_events](10_async/00_base/080_batch_events/)** - Event batching (shows limitations → Temporal) +- **[090_multi_agent_non_temporal](10_async/00_base/090_multi_agent_non_temporal/)** - Complex multi-agent coordination + +**When to use:** Task tracking needed but workflows are simple, no durability requirements + +--- + +#### 10_temporal/ - Production Workflows +Durable, fault-tolerant agents with Temporal workflow orchestration. + +**Core Patterns:** +- **[000_hello_acp](10_async/10_temporal/000_hello_acp/)** - Temporal basics +- **[010_agent_chat](10_async/10_temporal/010_agent_chat/)** - Stateful conversations +- **[020_state_machine](10_async/10_temporal/020_state_machine/)** - Structured state management +- **[030_custom_activities](10_async/10_temporal/030_custom_activities/)** - Custom Temporal activities +- **[050_agent_chat_guardrails](10_async/10_temporal/050_agent_chat_guardrails/)** - Safety & validation + +**OpenAI Agents SDK Series:** +- **[060_openai_hello_world](10_async/10_temporal/060_open_ai_agents_sdk_hello_world/)** - Plugin-based agents +- **[070_openai_tools](10_async/10_temporal/070_open_ai_agents_sdk_tools/)** - Tool integration patterns +- **[080_openai_hitl](10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/)** - Human oversight workflows + +**When to use:** Production systems requiring durability, fault tolerance, long-running workflows, or complex state management + +--- + +## Quick Start + +```bash +# 1. Start backend services (from repo root) +make dev + +# 2. Navigate to a tutorial +cd examples/tutorials/00_sync/000_hello_acp + +# 3. Run it +uv run python hello_acp.py +``` + +## Common Commands + +```bash +# Format tutorial code (always scope to specific files you're modifying) +rye run format examples/tutorials/00_sync/000_hello_acp/ + +# Run all async tutorial tests +cd examples/tutorials +./run_all_async_tests.sh + +# Run specific tutorial test +cd examples/tutorials +uv run pytest 00_sync/000_hello_acp/ -v + +# Check Temporal UI (when running temporal tutorials) +open http://localhost:8233 +``` + +## Tutorial Categories at a Glance + +| Category | Tutorials | Focus | Use When | +|----------|-----------|-------|----------| +| **Sync** | 3 | Request-response basics | Learning fundamentals, simple chatbots | +| **Async Base** | 7 | Task management without workflows | Need task tracking, simple coordination | +| **Temporal** | 8 | Production-grade workflows | Need durability, fault tolerance, complex state | + +## Getting Help + +- **Each tutorial includes:** README explaining concepts, annotated source code, and tests +- **Common issues?** See [AgentEx troubleshooting guide](https://github.com/scaleapi/scale-agentex#troubleshooting) +- **Need more context?** Check the [main AgentEx documentation](https://github.com/scaleapi/scale-agentex) + +--- + +**Ready to start?** → Begin with [00_sync/000_hello_acp](00_sync/000_hello_acp/) diff --git a/examples/tutorials/TEST_RUNNER_README.md b/examples/tutorials/TEST_RUNNER_README.md new file mode 100644 index 000000000..de8fcf66b --- /dev/null +++ b/examples/tutorials/TEST_RUNNER_README.md @@ -0,0 +1,142 @@ +# Tutorial Test Runner + +This directory contains a test runner script that automates the process of starting an agent and running its tests. + +## Prerequisites + +- Python 3.12+ +- `uv` installed and available in PATH +- `httpx` Python package (for health checks) + +## Usage + +From the `tutorials/` directory, run: + +```bash +python run_tutorial_test.py +``` + +### Examples + +```bash +# Test a sync tutorial +python run_tutorial_test.py 00_sync/000_hello_acp + +# Test an async tutorial +python run_tutorial_test.py 10_async/00_base/000_hello_acp +python run_tutorial_test.py 10_async/00_base/010_multiturn +python run_tutorial_test.py 10_async/00_base/020_streaming + +# Test with custom base URL +python run_tutorial_test.py 10_async/00_base/000_hello_acp --base-url http://localhost:5003 +``` + +## What the Script Does + +1. **Validates Paths**: Checks that the tutorial directory, manifest.yaml, and tests directory exist +2. **Starts Agent**: Runs `uv run agentex agents run --manifest manifest.yaml` in the tutorial directory +3. **Health Check**: Polls the agent's health endpoint (default: http://localhost:5003/health) until it's live +4. **Runs Tests**: Executes `uv run pytest tests/ -v --tb=short` in the tutorial directory +5. **Cleanup**: Gracefully stops the agent process (or kills it if necessary) + +## Options + +``` +positional arguments: + tutorial_dir Path to the tutorial directory (relative to current directory) + +optional arguments: + -h, --help Show help message and exit + --base-url BASE_URL Base URL for the AgentEx server (default: http://localhost:5003) +``` + +## Exit Codes + +- `0`: All tests passed successfully +- `1`: Tests failed or error occurred +- `130`: Interrupted by user (Ctrl+C) + +## Example Output + +``` +================================================================================ +AgentEx Tutorial Test Runner +================================================================================ + +🚀 Starting agent from: 10_async/00_base/000_hello_acp +📄 Manifest: 10_async/00_base/000_hello_acp/manifest.yaml +💻 Running command: uv run agentex agents run --manifest manifest.yaml +📁 Working directory: 10_async/00_base/000_hello_acp +✅ Agent process started (PID: 12345) + +🔍 Checking agent health at http://localhost:5003/health... +⏳ Waiting for agent... (attempt 1/30) +⏳ Waiting for agent... (attempt 2/30) +✅ Agent is live! (attempt 3/30) + +⏳ Waiting 2 seconds for agent to fully initialize... + +🧪 Running tests from: 10_async/00_base/000_hello_acp/tests +💻 Running command: uv run pytest tests/ -v --tb=short +📁 Working directory: 10_async/00_base/000_hello_acp + +============================= test session starts ============================== +... +============================= X passed in Y.YYs ================================ + +✅ All tests passed! + +🛑 Stopping agent (PID: 12345)... +✅ Agent stopped gracefully + +================================================================================ +✅ Test run completed successfully! +================================================================================ +``` + +## Troubleshooting + +### Agent doesn't become live + +If the health check times out: +- Check that port 5003 is not already in use +- Look at the agent logs to see if there are startup errors +- Try increasing the timeout by modifying the `max_attempts` parameter in the script + +### Tests fail + +- Ensure the agent is properly configured in manifest.yaml +- Check that all dependencies are installed in the tutorial's virtual environment +- Review test output for specific failure reasons + +### "uv: command not found" + +Install `uv`: +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +### Missing httpx package + +The script requires `httpx` for health checks. It should be installed automatically via the tutorial's dependencies, but if needed: +```bash +pip install httpx +``` + +## Integration with CI/CD + +This script is designed to be CI/CD friendly: + +```bash +# Run all async tutorials +for tutorial in 10_async/00_base/*/; do + python run_tutorial_test.py "$tutorial" || exit 1 +done +``` + +## Notes + +- The script automatically sets `AGENTEX_API_BASE_URL` environment variable when running tests +- Agent processes are always cleaned up, even if tests fail or the script is interrupted +- The script uses line-buffered output for real-time feedback +- Health checks poll every 1 second for up to 30 seconds (configurable in the code) diff --git a/examples/tutorials/pytest.ini b/examples/tutorials/pytest.ini new file mode 100644 index 000000000..7be1a0764 --- /dev/null +++ b/examples/tutorials/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +pythonpath = . +testpaths = . +addopts = --import-mode=importlib diff --git a/examples/tutorials/run_agent_test.sh b/examples/tutorials/run_agent_test.sh new file mode 100755 index 000000000..ead49363e --- /dev/null +++ b/examples/tutorials/run_agent_test.sh @@ -0,0 +1,469 @@ +#!/bin/bash +# +# Run a single agent tutorial test +# +# This script runs the test for a single agent tutorial. +# It starts the agent, runs tests against it, then stops the agent. +# +# Usage: +# ./run_agent_test.sh # Run single tutorial test +# ./run_agent_test.sh --build-cli # Build CLI from source and run test +# ./run_agent_test.sh --view-logs # View logs for specific tutorial +# ./run_agent_test.sh --view-logs # View most recent agent logs +# + +set -e # Exit on error + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Parse arguments +TUTORIAL_PATH="" +VIEW_LOGS=false +BUILD_CLI=false + +for arg in "$@"; do + if [[ "$arg" == "--view-logs" ]]; then + VIEW_LOGS=true + elif [[ "$arg" == "--build-cli" ]]; then + BUILD_CLI=true + else + TUTORIAL_PATH="$arg" + fi +done + +# Function to check prerequisites for running this test suite +check_prerequisites() { + # Check that we are in the examples/tutorials directory + if [[ "$PWD" != */examples/tutorials ]]; then + echo -e "${RED}❌ Please run this script from the examples/tutorials directory${NC}" + exit 1 + fi + + # Check if uv is available + if ! command -v uv &> /dev/null; then + echo -e "${RED}❌ uv is required but not installed${NC}" + echo "Please install uv: curl -LsSf https://astral.sh/uv/install.sh | sh" + exit 1 + fi + + echo -e "${GREEN}✅ Prerequisites check passed${NC}" +} + +# Function to wait for agent to be ready +wait_for_agent_ready() { + local name=$1 + local logfile="/tmp/agentex-${name}.log" + local timeout=45 # seconds - increased to account for package installation time + local elapsed=0 + + echo -e "${YELLOW}⏳ Waiting for ${name} agent to be ready...${NC}" + + while [ $elapsed -lt $timeout ]; do + # Check if agent is successfully registered + if grep -q "Successfully registered agent" "$logfile" 2>/dev/null; then + + # For temporal agents, also wait for workers to be ready + if [[ "$tutorial_path" == *"temporal"* ]]; then + # This is a temporal agent - wait for workers too + if grep -q "Running workers for task queue" "$logfile" 2>/dev/null; then + return 0 + fi + else + return 0 + fi + fi + sleep 1 + ((elapsed++)) + done + + echo -e "${RED}❌ Timeout waiting for ${name} agent to be ready${NC}" + echo -e "${YELLOW}📋 Agent logs:${NC}" + if [[ -f "$logfile" ]]; then + echo "----------------------------------------" + tail -50 "$logfile" + echo "----------------------------------------" + else + echo "❌ Log file not found: $logfile" + fi + return 1 +} + +# Function to start agent in background +start_agent() { + local tutorial_path=$1 + local name=$(basename "$tutorial_path") + local logfile="/tmp/agentex-${name}.log" + + echo -e "${YELLOW}🚀 Starting ${name} agent...${NC}" + + # Check if tutorial directory exists + if [[ ! -d "$tutorial_path" ]]; then + echo -e "${RED}❌ Tutorial directory not found: $tutorial_path${NC}" + return 1 + fi + + # Check if manifest exists + if [[ ! -f "$tutorial_path/manifest.yaml" ]]; then + echo -e "${RED}❌ Manifest not found: $tutorial_path/manifest.yaml${NC}" + return 1 + fi + + # Save current directory + local original_dir="$PWD" + + # Change to tutorial directory + cd "$tutorial_path" || return 1 + + # Start the agent in background and capture PID + local manifest_path="$PWD/manifest.yaml" # Always use full path + + if [ "$BUILD_CLI" = true ]; then + + # uv workspace builds both wheels into the root dist/ (slim + heavy ADK). + # We need both: heavy pins agentex-client which isn't on PyPI yet, + # so uv must resolve both from local wheels rather than the registry. + local heavy_wheel=$(ls /home/runner/work/*/*/dist/agentex_sdk-*.whl 2>/dev/null | head -n1) + local slim_wheel=$(ls /home/runner/work/*/*/dist/agentex_client-*.whl 2>/dev/null | head -n1) + if [[ -z "$heavy_wheel" ]]; then + echo -e "${RED}❌ No built heavy wheel found in dist/agentex_sdk-*.whl${NC}" + echo -e "${YELLOW}💡 Build it first: uv build --all-packages --wheel${NC}" + cd "$original_dir" + return 1 + fi + if [[ -z "$slim_wheel" ]]; then + echo -e "${RED}❌ No built slim wheel found in dist/agentex_client-*.whl${NC}" + echo -e "${YELLOW}💡 Build it first: uv build --wheel${NC}" + cd "$original_dir" + return 1 + fi + + # Pass both wheels so the local heavy resolves its slim dep locally + uv run --with "$heavy_wheel" --with "$slim_wheel" agentex agents run --manifest "$manifest_path" > "$logfile" 2>&1 & + else + uv run agentex agents run --manifest manifest.yaml > "$logfile" 2>&1 & + fi + local pid=$! + + # Return to original directory + cd "$original_dir" + + echo "$pid" > "/tmp/agentex-${name}.pid" + echo -e "${GREEN}✅ ${name} agent started (PID: $pid, logs: $logfile)${NC}" + + # Wait for agent to be ready + if ! wait_for_agent_ready "$name"; then + kill -9 $pid 2>/dev/null + return 1 + fi + + return 0 +} + +# Helper function to view agent container logs +view_agent_logs() { + local tutorial_path=$1 + + # If tutorial path is provided, view logs for that specific tutorial + if [[ -n "$tutorial_path" ]]; then + local name=$(basename "$tutorial_path") + local logfile="/tmp/agentex-${name}.log" + + echo -e "${YELLOW}📋 Viewing logs for ${name}...${NC}" + echo -e "${YELLOW}Log file: $logfile${NC}" + echo "" + + if [[ ! -f "$logfile" ]]; then + echo -e "${RED}❌ Log file not found: $logfile${NC}" + return 1 + fi + + # Display the logs + tail -f "$logfile" + else + # No specific tutorial, find the most recent log file + local latest_log=$(ls -t /tmp/agentex-*.log 2>/dev/null | head -1) + + if [[ -z "$latest_log" ]]; then + echo -e "${RED}❌ No agent log files found in /tmp/agentex-*.log${NC}" + echo -e "${YELLOW}Available log files:${NC}" + ls -lht /tmp/agentex-*.log 2>/dev/null || echo " (none)" + return 1 + fi + + echo -e "${YELLOW}📋 Viewing most recent agent logs...${NC}" + echo -e "${YELLOW}Log file: $latest_log${NC}" + echo "" + + # Display the logs + tail -f "$latest_log" + fi +} + +# Function to stop agent +stop_agent() { + local tutorial_path=$1 + local name=$(basename "$tutorial_path") + local pidfile="/tmp/agentex-${name}.pid" + local logfile="/tmp/agentex-${name}.log" + + echo -e "${YELLOW}🛑 Stopping ${name} agent...${NC}" + + # Check if PID file exists + if [[ ! -f "$pidfile" ]]; then + echo -e "${YELLOW}⚠️ No PID file found for ${name} agent${NC}" + return 0 + fi + + # Read PID from file + local pid=$(cat "$pidfile") + + # Check if process is running and kill it + if kill -0 "$pid" 2>/dev/null; then + echo -e "${YELLOW}Stopping ${name} agent (PID: $pid)${NC}" + kill "$pid" 2>/dev/null || true + rm -f "$pidfile" + else + echo -e "${YELLOW}⚠️ ${name} agent was not running${NC}" + rm -f "$pidfile" + fi + + echo -e "${GREEN}✅ ${name} agent stopped${NC}" + echo -e "${YELLOW}Logs available at: $logfile${NC}" + + return 0 +} + + +# Function to run test for a tutorial +run_test() { + local tutorial_path=$1 + local name=$(basename "$tutorial_path") + + echo -e "${YELLOW}🧪 Running tests for ${name}...${NC}" + + # Check if tutorial directory exists + if [[ ! -d "$tutorial_path" ]]; then + echo -e "${RED}❌ Tutorial directory not found: $tutorial_path${NC}" + return 1 + fi + + # Check if test file exists + if [[ ! -f "$tutorial_path/tests/test_agent.py" ]]; then + echo -e "${RED}❌ Test file not found: $tutorial_path/tests/test_agent.py${NC}" + return 1 + fi + + # Save current directory + local original_dir="$PWD" + + # Change to tutorial directory + cd "$tutorial_path" || return 1 + + + # Run the tests with retry mechanism. + # + # pytest is brought in explicitly via --with: the tutorials only list it + # under an optional `dev` extra (which `uv run` does not install), and it + # used to be pulled in transitively by agentex-sdk's runtime deps. Once + # agentex-sdk 0.11.5 dropped pytest as a runtime dep, `uv run pytest` could + # no longer find it ("Failed to spawn: pytest"). Requesting it directly is + # robust across all tutorials regardless of how each declares test deps. + local -a pytest_cmd=("uv" "run" "--with" "pytest" "--with" "pytest-asyncio" "pytest") + if [ "$BUILD_CLI" = true ]; then + local heavy_wheel slim_wheel + heavy_wheel=$(ls /home/runner/work/*/*/dist/agentex_sdk-*.whl 2>/dev/null | head -n1) + if [[ -z "$heavy_wheel" ]]; then + heavy_wheel=$(ls "${SCRIPT_DIR}"/../../dist/agentex_sdk-*.whl 2>/dev/null | head -n1) + fi + slim_wheel=$(ls /home/runner/work/*/*/dist/agentex_client-*.whl 2>/dev/null | head -n1) + if [[ -z "$slim_wheel" ]]; then + slim_wheel=$(ls "${SCRIPT_DIR}"/../../dist/agentex_client-*.whl 2>/dev/null | head -n1) + fi + if [[ -z "$heavy_wheel" || -z "$slim_wheel" ]]; then + echo -e "${RED}❌ BUILD_CLI=true but a wheel is missing (heavy='${heavy_wheel}' slim='${slim_wheel}'); refusing to test against the pre-installed SDK${NC}" + return 1 + fi + pytest_cmd=("uv" "run" "--with" "$heavy_wheel" "--with" "$slim_wheel" "--with" "pytest" "--with" "pytest-asyncio" "pytest") + fi + + local max_retries=5 + local retry_count=0 + local exit_code=1 + + while [ $retry_count -lt $max_retries ]; do + if [ $retry_count -gt 0 ]; then + echo -e "${YELLOW}🔄 Retrying tests (attempt $((retry_count + 1))/$max_retries)...${NC}" + fi + + # Stream pytest output directly in real-time + "${pytest_cmd[@]}" tests/test_agent.py -v -s + exit_code=$? + + if [ $exit_code -eq 0 ]; then + break + else + retry_count=$((retry_count + 1)) + if [ $retry_count -lt $max_retries ]; then + sleep 5 + fi + fi + done + + # Return to original directory + cd "$original_dir" + + if [ $exit_code -eq 0 ]; then + echo -e "${GREEN}✅ Tests passed for ${name}${NC}" + return 0 + else + echo -e "${RED}❌ Tests failed for ${name}${NC}" + return 1 + fi +} + +# Function to execute test flow for a single tutorial +execute_tutorial_test() { + local tutorial=$1 + + echo "" + echo "================================================================================" + echo "Testing: $tutorial" + echo "================================================================================" + + # Start the agent + if ! start_agent "$tutorial"; then + echo -e "${RED}❌ FAILED to start agent: $tutorial${NC}" + return 1 + fi + + # Run the tests + local test_passed=false + if run_test "$tutorial"; then + echo -e "${GREEN}✅ PASSED: $tutorial${NC}" + test_passed=true + else + echo -e "${RED}❌ FAILED: $tutorial${NC}" + fi + + # Stop the agent + stop_agent "$tutorial" + + echo "" + + if [ "$test_passed" = true ]; then + return 0 + else + return 1 + fi +} + +# Function to check if both built wheels are available +check_built_wheel() { + + # Navigate to the repo root (two levels up from examples/tutorials) + local repo_root="../../" + local original_dir="$PWD" + + cd "$repo_root" || { + echo -e "${RED}❌ Failed to navigate to repo root${NC}" + return 1 + } + + # Heavy ADK wheel + slim client wheel — we need both because heavy pins + # agentex-client which isn't on PyPI yet. + local heavy_wheel=$(ls /home/runner/work/*/*/dist/agentex_sdk-*.whl 2>/dev/null | head -n1) + local slim_wheel=$(ls /home/runner/work/*/*/dist/agentex_client-*.whl 2>/dev/null | head -n1) + if [[ -z "$heavy_wheel" ]]; then + echo -e "${RED}❌ No built heavy wheel found in dist/agentex_sdk-*.whl${NC}" + echo -e "${YELLOW}💡 Build it first: uv build --all-packages --wheel${NC}" + cd "$original_dir" + return 1 + fi + if [[ -z "$slim_wheel" ]]; then + echo -e "${RED}❌ No built slim wheel found in dist/agentex_client-*.whl${NC}" + echo -e "${YELLOW}💡 Build it first: uv build --wheel${NC}" + cd "$original_dir" + return 1 + fi + + # Test the heavy wheel by running agentex --help (uses both wheels for resolution) + if ! uv run --with "$heavy_wheel" --with "$slim_wheel" agentex --help >/dev/null 2>&1; then + echo -e "${RED}❌ Failed to run agentex with built wheels${NC}" + cd "$original_dir" + return 1 + fi + cd "$original_dir" + return 0 +} + + +# Main execution function +main() { + # Handle --view-logs flag + if [ "$VIEW_LOGS" = true ]; then + if [[ -n "$TUTORIAL_PATH" ]]; then + view_agent_logs "$TUTORIAL_PATH" + else + view_agent_logs + fi + exit 0 + fi + # Require tutorial path + if [[ -z "$TUTORIAL_PATH" ]]; then + echo -e "${RED}❌ Error: Tutorial path is required${NC}" + echo "" + echo "Usage:" + echo " ./run_agent_test.sh # Run single tutorial test" + echo " ./run_agent_test.sh --build-cli # Build CLI from source and run test" + echo " ./run_agent_test.sh --view-logs # View logs for specific tutorial" + echo " ./run_agent_test.sh --view-logs # View most recent agent logs" + echo "" + echo "Examples:" + echo " ./run_agent_test.sh 00_sync/000_hello_acp" + echo " ./run_agent_test.sh --build-cli 00_sync/000_hello_acp" + exit 1 + fi + + echo "================================================================================" + echo "Running Tutorial Test: $TUTORIAL_PATH" + echo "================================================================================" + + # Check prerequisites + check_prerequisites + + echo "" + + # Check built wheel if requested + if [ "$BUILD_CLI" = true ]; then + if ! check_built_wheel; then + echo -e "${RED}❌ Failed to find or verify built wheel${NC}" + exit 1 + fi + echo "" + fi + + # Execute the single tutorial test + if execute_tutorial_test "$TUTORIAL_PATH"; then + echo "" + echo "================================================================================" + echo -e "${GREEN}🎉 Test passed for: $TUTORIAL_PATH${NC}" + echo "================================================================================" + exit 0 + else + echo "" + echo "================================================================================" + echo -e "${RED}❌ Test failed for: $TUTORIAL_PATH${NC}" + echo "================================================================================" + exit 1 + fi +} + +# Run main function +main diff --git a/examples/tutorials/test_utils/async_utils.py b/examples/tutorials/test_utils/async_utils.py new file mode 100644 index 000000000..2187e98d8 --- /dev/null +++ b/examples/tutorials/test_utils/async_utils.py @@ -0,0 +1,286 @@ +""" +Utility functions for testing AgentEx async agents. + +This module provides helper functions for working with async (non-temporal) agents, +including task creation, event sending, response polling, and streaming. +""" + +import json +import time +import asyncio +import contextlib +from typing import Optional, AsyncGenerator +from datetime import datetime, timezone + +from agentex._client import AsyncAgentex +from agentex.types.task_message import TaskMessage +from agentex.types.agent_rpc_params import ParamsSendEventRequest +from agentex.types.agent_rpc_result import StreamTaskMessageDone, StreamTaskMessageFull +from agentex.types.text_content_param import TextContentParam + + +async def send_event_and_poll_yielding( + client: AsyncAgentex, + agent_id: str, + task_id: str, + user_message: str, + timeout: int = 30, + sleep_interval: float = 1.0, + yield_updates: bool = True, +) -> AsyncGenerator[TaskMessage, None]: + """ + Send an event to an agent and poll for responses, yielding messages as they arrive. + + Polls continuously until timeout is hit or the caller exits the loop. + + Args: + client: AgentEx client instance + agent_id: The agent ID + task_id: The task ID + user_message: The message content to send + timeout: Maximum seconds to wait for a response (default: 30) + sleep_interval: Seconds to sleep between polls (default: 1.0) + yield_updates: If True, yield messages again when their content changes (default: True for streaming) + + Yields: + TaskMessage objects as they are discovered during polling + """ + # Send the event + event_content = TextContentParam(type="text", author="user", content=user_message) + + # Capture timestamp before sending to account for clock skew + # Subtract 2 second buffer to ensure we don't filter out messages we just created + # (accounts for clock skew between client and server) + messages_created_after = time.time() - 2.0 + + await client.agents.send_event( + agent_id=agent_id, params=ParamsSendEventRequest(task_id=task_id, content=event_content) + ) + # Poll continuously until timeout + # Poll for messages created after we sent the event + async for message in poll_messages( + client=client, + task_id=task_id, + timeout=timeout, + sleep_interval=sleep_interval, + messages_created_after=messages_created_after, + yield_updates=yield_updates, + ): + yield message + + +async def poll_messages( + client: AsyncAgentex, + task_id: str, + timeout: int = 30, + sleep_interval: float = 1.0, + messages_created_after: Optional[float] = None, + yield_updates: bool = False, +) -> AsyncGenerator[TaskMessage, None]: + """ + Poll for messages continuously until timeout. + + Args: + client: AgentEx client instance + task_id: The task ID to poll messages for + timeout: Maximum seconds to poll (default: 30) + sleep_interval: Seconds to sleep between polls (default: 1.0) + messages_created_after: Optional timestamp to filter messages (Unix timestamp) + yield_updates: If True, yield messages again when their content changes (for streaming) + If False, only yield each message ID once (default: False) + + Yields: + TaskMessage objects as they are discovered or updated + """ + # Keep track of messages we've already yielded + seen_message_ids = set() + # Track message content hashes to detect updates (for streaming) + message_content_hashes: dict[str, int] = {} + start_time = datetime.now() + + # Poll continuously until timeout + while (datetime.now() - start_time).seconds < timeout: + messages = await client.messages.list(task_id=task_id) + + # Sort messages by created_at to ensure chronological order + # Use datetime.min for messages without created_at timestamp + sorted_messages = sorted( + messages, + key=lambda m: m.created_at if m.created_at else datetime.min.replace(tzinfo=timezone.utc) + ) + + new_messages_found = 0 + for message in sorted_messages: + # Check if message passes timestamp filter + if messages_created_after and message.created_at: + # If message.created_at is timezone-naive, assume it's UTC + if message.created_at.tzinfo is None: + msg_timestamp = message.created_at.replace(tzinfo=timezone.utc).timestamp() + else: + msg_timestamp = message.created_at.timestamp() + if msg_timestamp < messages_created_after: + continue + + # Some message objects may not have an ID; skip them since we use IDs for dedupe. + if not message.id: + continue + + # Check if this is a new message or an update to existing message + is_new_message = message.id not in seen_message_ids + + if yield_updates: + # For streaming: track content changes + # Use getattr to safely extract content and convert to string + # This handles various content structures at runtime + raw_content = getattr(message.content, 'content', message.content) if message.content else None + content_str = str(raw_content) if raw_content is not None else "" + + # Ensure streaming_status is also properly converted to string + streaming_status_str = str(message.streaming_status) if message.streaming_status is not None else "" + content_hash = hash(content_str + streaming_status_str) + is_updated = message.id in message_content_hashes and message_content_hashes[message.id] != content_hash + + if is_new_message or is_updated: + message_content_hashes[message.id] = content_hash + seen_message_ids.add(message.id) + new_messages_found += 1 + yield message + else: + # Original behavior: only yield each message ID once + if is_new_message: + seen_message_ids.add(message.id) + new_messages_found += 1 + yield message + + # Sleep before next poll + await asyncio.sleep(sleep_interval) + + +async def send_event_and_stream( + client: AsyncAgentex, + agent_id: str, + task_id: str, + user_message: str, + timeout: int = 30, +): + """ + Send an event to an agent and stream the response, yielding events as they arrive. + + This function now uses stream_agent_response() under the hood and yields events + up the stack as they arrive. + + Args: + client: AgentEx client instance + agent_id: The agent ID + task_id: The task ID + user_message: The message content to send + timeout: Maximum seconds to wait for stream completion (default: 30) + + Yields: + Parsed event dictionaries as they arrive from the stream + + Raises: + Exception: If streaming fails + """ + queue: asyncio.Queue[dict[str, object] | None] = asyncio.Queue() + stream_exc: BaseException | None = None + + async def consume_stream() -> None: + nonlocal stream_exc + try: + async for event in stream_agent_response( + client=client, + task_id=task_id, + timeout=timeout, + ): + await queue.put(event) + if event.get("type") == "done": + break + except BaseException as e: # noqa: BLE001 - propagate after draining + stream_exc = e + finally: + await queue.put(None) + + # Start consuming the stream *before* sending the event, so we don't block waiting for the first message. + stream_task = asyncio.create_task(consume_stream()) + + try: + event_content = TextContentParam(type="text", author="user", content=user_message) + await client.agents.send_event(agent_id=agent_id, params={"task_id": task_id, "content": event_content}) + + while True: + item = await queue.get() + if item is None: + break + yield item + + if stream_exc is not None: + raise stream_exc + finally: + if not stream_task.done(): + stream_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await stream_task + + +async def stream_agent_response( + client: AsyncAgentex, + task_id: str, + timeout: int = 30, +): + """ + Stream the agent response for a given task, yielding events as they arrive. + + Args: + client: AgentEx client instance + task_id: The task ID to stream messages from + timeout: Maximum seconds to wait for stream completion (default: 30) + + Yields: + Parsed event dictionaries as they arrive from the stream + """ + try: + # Add explicit timeout wrapper to force exit after timeout seconds + async with asyncio.timeout(timeout): + async with client.tasks.with_streaming_response.stream_events(task_id=task_id, timeout=timeout) as stream: + async for line in stream.iter_lines(): + if line.startswith("data: "): + # Parse the SSE data + data = line.strip()[6:] # Remove "data: " prefix + event = json.loads(data) + # Yield each event immediately as it arrives + yield event + + except asyncio.TimeoutError: + raise + except Exception as e: + raise + + +async def stream_task_messages( + client: AsyncAgentex, + task_id: str, + timeout: int = 30, +) -> AsyncGenerator[TaskMessage, None]: + """ + Stream the task messages for a given task, yielding messages as they arrive. + """ + async for event in stream_agent_response( + client=client, + task_id=task_id, + timeout=timeout, + ): + msg_type = event.get("type") + task_message: Optional[TaskMessage] = None + if msg_type == "full": + task_message_update_full = StreamTaskMessageFull.model_validate(event) + if task_message_update_full.parent_task_message and task_message_update_full.parent_task_message.id: + finished_message = await client.messages.retrieve(task_message_update_full.parent_task_message.id) + task_message = finished_message + elif msg_type == "done": + task_message_update_done = StreamTaskMessageDone.model_validate(event) + if task_message_update_done.parent_task_message and task_message_update_done.parent_task_message.id: + finished_message = await client.messages.retrieve(task_message_update_done.parent_task_message.id) + task_message = finished_message + if task_message: + yield task_message diff --git a/examples/tutorials/test_utils/sync.py b/examples/tutorials/test_utils/sync.py new file mode 100644 index 000000000..808ee0af1 --- /dev/null +++ b/examples/tutorials/test_utils/sync.py @@ -0,0 +1,95 @@ +""" +Utility functions for testing AgentEx agents. + +This module provides helper functions for validating agent responses +in both streaming and non-streaming scenarios. +""" +from __future__ import annotations + +from typing import List, Callable, Optional, Generator + +from agentex.types import TextDelta, TextContent +from agentex.types.agent_rpc_result import StreamTaskMessageDone +from agentex.types.agent_rpc_response import SendMessageResponse +from agentex.types.task_message_update import StreamTaskMessageFull, StreamTaskMessageDelta + + +def validate_text_content(content: TextContent, validator: Optional[Callable[[str], bool]] = None) -> str: + """ + Validate that content is TextContent and optionally run a custom validator. + + Args: + content: The content to validate + validator: Optional function that takes the content string and returns True if valid + + Returns: + The text content as a string + + Raises: + AssertionError: If validation fails + """ + assert isinstance(content, TextContent), f"Expected TextContent, got {type(content)}" + assert isinstance(content.content, str), "Content should be a string" + + if validator: + assert validator(content.content), f"Content validation failed: {content.content}" + + return content.content + + +def validate_text_in_string(text_to_find: str, text: str): + """ + Validate that text is a string and optionally run a custom validator. + + Args: + text: The text to validate + validator: Optional function that takes the text string and returns True if valid + """ + + assert text_to_find in text, f"Expected to find '{text_to_find}' in text." + + +def collect_streaming_response( + stream_generator: Generator[SendMessageResponse, None, None], +) -> tuple[str, List[SendMessageResponse]]: + """ + Collect and validate a streaming response. + + Args: + stream_generator: The generator yielding streaming chunks + + Returns: + Tuple of (aggregated_content from deltas, full_content from full messages) + + Raises: + AssertionError: If no chunks are received or no content is found + """ + aggregated_content = "" + chunks = [] + + for chunk in stream_generator: + task_message_update = chunk.result + chunks.append(chunk) + # Collect text deltas as they arrive + if isinstance(task_message_update, StreamTaskMessageDelta) and task_message_update.delta is not None: + delta = task_message_update.delta + if isinstance(delta, TextDelta) and delta.text_delta is not None: + aggregated_content += delta.text_delta + + # Or collect full messages + elif isinstance(task_message_update, StreamTaskMessageFull): + content = task_message_update.content + if isinstance(content, TextContent): + aggregated_content = content.content + + elif isinstance(task_message_update, StreamTaskMessageDone): + # Handle non-streaming response case pattern + break + # Validate we received something + if not chunks: + raise AssertionError("No streaming chunks were received, when at least 1 was expected.") + + if not aggregated_content: + raise AssertionError("No content was received in the streaming response.") + + return aggregated_content, chunks diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..8fb1f97cf --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,311 @@ +[project] +# This is the Stainless-generated REST client. The hand-authored ADK +# overlay (formerly `src/agentex/lib/*`) now lives in `adk/` and ships +# as the sibling `agentex-sdk` package — see `adk/pyproject.toml`. +name = "agentex-client" +version = "0.26.0" +description = "The official Python REST client for the Agentex API" +dynamic = ["readme"] +license = "Apache-2.0" +authors = [ +{ name = "Agentex", email = "roxanne.farhad@scale.com" }, +] + +dependencies = [ + "httpx>=0.28.1,<0.29", + "pydantic>=2.0.0, <3", + "typing-extensions>=4.14, <5", + "anyio>=3.5.0, <5", + "distro>=1.7.0, <2", + "sniffio", +] + +requires-python = ">= 3.11,<4" +classifiers = [ + "Typing :: Typed", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Operating System :: OS Independent", + "Operating System :: POSIX", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", + "Operating System :: Microsoft :: Windows", + "Topic :: Software Development :: Libraries :: Python Modules", + "License :: OSI Approved :: Apache Software License" +] + +[project.urls] +Homepage = "https://github.com/scaleapi/scale-agentex-python" +Repository = "https://github.com/scaleapi/scale-agentex-python" + +[project.optional-dependencies] +aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.9"] +dev = [ + "ruff>=0.3.4", +] + +# The `agentex` CLI entry point ships from the ADK package — see +# `adk/pyproject.toml`. The slim client has no CLI surface. + +[tool.uv.workspace] +# Dev: `uv sync --all-packages` installs both members editably. Shared source +# `src/agentex/lib/` ships only from the heavy (via adk/hatch_build.py). +members = ["adk"] + +[tool.uv.sources] +# Dev-only: resolve the ADK's agentex-client dep to this root package. +# Stripped from published wheels — the heavy wheel still pins the PyPI version. +agentex-client = { workspace = true } + +[tool.uv] +managed = true +required-version = ">=0.9" + +[dependency-groups] +# version pins are in uv.lock +dev = [ + "pyright==1.1.399", + "mypy==1.17", + "respx", + "pytest", + "pytest-asyncio", + "ruff", + "time-machine", + "dirty-equals>=0.6.0", + "importlib-metadata>=6.7.0", + "rich>=13.7.1", + "nest_asyncio==1.6.0", + "pytest-xdist>=3.6.1", + "debugpy>=1.8.15", + "ipywidgets>=8.1.7", + "nbstripout>=0.8.1", + "yaspin>=3.1.0", +] + +[build-system] +requires = ["hatchling==1.26.3", "hatch-fancy-pypi-readme"] +build-backend = "hatchling.build" + +[tool.hatch.build] +include = [ + "src/*" +] + +[tool.hatch.build.targets.wheel] +packages = ["src/agentex"] +# agentex/lib/* ships from the sibling agentex-sdk package (see adk/pyproject.toml). +# Excluding it here keeps the slim wheel disjoint from the heavy wheel so both +# can install into the same site-packages/agentex/ without file conflicts. +exclude = [ + "src/agentex/lib/**", +] + +[tool.hatch.build.targets.sdist] +# Basically everything except hidden files/directories (such as .github, .devcontainers, .python-version, etc) +include = [ + "/*.toml", + "/*.json", + "/*.lock", + "/*.md", + "/mypy.ini", + "/noxfile.py", + "bin/*", + "examples/*", + "src/*", + "tests/*", +] + +[tool.hatch.metadata.hooks.fancy-pypi-readme] +content-type = "text/markdown" + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] +path = "README.md" + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] +# replace relative links with absolute links +pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)' +replacement = '[\1](https://github.com/scaleapi/scale-agentex-python/tree/main/\g<2>)' + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--tb=short -n auto" +xfail_strict = true +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "session" +filterwarnings = [ + "error", + "ignore::pydantic.warnings.PydanticDeprecatedSince20", +] + +[tool.pyright] +# Default to basic type checking, but override for specific directories +typeCheckingMode = "basic" +pythonVersion = "3.12" + +exclude = [ + "_dev", + ".venv", + ".nox", + ".git", + "agentex-server", + "examples/tutorials", + # Exclude autogenerated Stainless code from type checking + "src/agentex/resources", + "src/agentex/types", + # Build-time hook; imports hatchling, a build dep absent from the synced env. + "adk/hatch_build.py", +] + +reportImplicitOverride = true +reportOverlappingOverload = false + +reportImportCycles = false +reportPrivateUsage = false + +# Ignore common issues in generated SDK code +reportMissingTypeStubs = false +reportUnknownParameterType = false +reportUnknownMemberType = false +reportUnknownArgumentType = false +reportUnknownVariableType = false + +# Enable strict type checking only for hand-written code +[[tool.pyright.executionEnvironments]] +root = "src/agentex/lib" +typeCheckingMode = "strict" +# But allow some flexibility in OpenAI module for complex type boundaries +reportArgumentType = false + +[[tool.pyright.executionEnvironments]] +root = "examples" +typeCheckingMode = "strict" +# Allow type ignores in tutorials for readability +reportUnnecessaryTypeIgnoreComment = false + +[[tool.pyright.executionEnvironments]] +root = "tests" +typeCheckingMode = "basic" +# Be loose on typing in tests unless testing types specifically +reportOptionalMemberAccess = false +reportArgumentType = false + +[tool.mypy] +pretty = true +show_error_codes = true + +# Exclude _files.py because mypy isn't smart enough to apply +# the correct type narrowing and as this is an internal module +# it's fine to just use Pyright. +# +# We also exclude our `tests` as mypy doesn't always infer +# types correctly and Pyright will still catch any type errors. +exclude = ['src/agentex/_files.py', '_dev/.*.py', 'tests/.*', 'examples/tutorials/.*'] + +strict_equality = true +implicit_reexport = true +check_untyped_defs = true +no_implicit_optional = true + +warn_return_any = true +warn_unreachable = true +warn_unused_configs = true + +# Turn these options off as it could cause conflicts +# with the Pyright options. +warn_unused_ignores = false +warn_redundant_casts = false + +disallow_any_generics = true +# disallow_untyped_defs = true +disallow_untyped_calls = true +disallow_subclassing_any = true +disallow_incomplete_defs = true +disallow_untyped_decorators = true +cache_fine_grained = true + +# By default, mypy reports an error if you assign a value to the result +# of a function call that doesn't return anything. We do this in our test +# cases: +# ``` +# result = ... +# assert result is None +# ``` +# Changing this codegen to make mypy happy would increase complexity +# and would not be worth it. +disable_error_code = "func-returns-value,overload-cannot-match,no-untyped-def" + +# https://github.com/python/mypy/issues/12162 +[[tool.mypy.overrides]] +module = "black.files.*" +ignore_errors = true +ignore_missing_imports = true + + +[tool.ruff] +line-length = 120 +output-format = "grouped" +target-version = "py38" + +[tool.ruff.format] +docstring-code-format = true + +[tool.ruff.lint] +select = [ + # isort + "I", + # bugbear rules + "B", + # remove unused imports + "F401", + # check for missing future annotations + "FA102", + # bare except statements + "E722", + # unused arguments + "ARG", + # print statements + "T201", + "T203", + # misuse of typing.TYPE_CHECKING + "TC004", + # import rules + "TID251", +] +ignore = [ + # mutable defaults + "B006", +] +unfixable = [ + # disable auto fix for print statements + "T201", + "T203", +] + +extend-safe-fixes = ["FA102"] + +[tool.ruff.lint.flake8-tidy-imports.banned-api] +"functools.lru_cache".msg = "This function does not retain type information for the wrapped function's arguments; The `lru_cache` function from `_utils` should be used instead" + +[tool.ruff.lint.isort] +length-sort = true +length-sort-straight = true +combine-as-imports = true +extra-standard-library = ["typing_extensions"] +known-first-party = ["agentex", "tests"] + +[tool.ruff.lint.per-file-ignores] +# Exclude autogenerated files from future annotations requirement +"src/agentex/resources/**.py" = ["FA102"] +"src/agentex/types/**.py" = ["FA102"] +"src/agentex/_*.py" = ["FA102"] +"bin/**.py" = ["T201", "T203"] +"scripts/**.py" = ["T201", "T203"] +"tests/**.py" = ["T201", "T203", "ARG001", "ARG002", "ARG005"] +"examples/**.py" = ["T201", "T203"] +"examples/**.ipynb" = ["T201", "T203"] +"examples/tutorials/**.py" = ["T201", "T203"] +"examples/tutorials/**.ipynb" = ["T201", "T203"] +"**/run_tests.py" = ["T201", "T203"] +"**/dev_tools/**.py" = ["T201", "T203"] diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 000000000..7bae5f5a3 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,81 @@ +{ + "packages": { + ".": { + "component": "agentex-client", + "extra-files": [ + "src/agentex/_version.py" + ] + }, + "adk": { + "component": "agentex-sdk" + } + }, + "plugins": [ + { + "type": "linked-versions", + "groupName": "agentex", + "components": [ + "agentex-client", + "agentex-sdk" + ] + } + ], + "$schema": "https://raw.githubusercontent.com/stainless-api/release-please/main/schemas/config.json", + "include-v-in-tag": true, + "include-component-in-tag": true, + "versioning": "prerelease", + "prerelease": true, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "pull-request-header": "Automated Release PR", + "pull-request-title-pattern": "release: ${version}", + "changelog-sections": [ + { + "type": "feat", + "section": "Features" + }, + { + "type": "fix", + "section": "Bug Fixes" + }, + { + "type": "perf", + "section": "Performance Improvements" + }, + { + "type": "revert", + "section": "Reverts" + }, + { + "type": "chore", + "section": "Chores" + }, + { + "type": "docs", + "section": "Documentation" + }, + { + "type": "style", + "section": "Styles" + }, + { + "type": "refactor", + "section": "Refactors" + }, + { + "type": "test", + "section": "Tests", + "hidden": true + }, + { + "type": "build", + "section": "Build System" + }, + { + "type": "ci", + "section": "Continuous Integration", + "hidden": true + } + ], + "release-type": "python" +} \ No newline at end of file diff --git a/requirements-dev.lock b/requirements-dev.lock new file mode 100644 index 000000000..ff876c6b5 --- /dev/null +++ b/requirements-dev.lock @@ -0,0 +1,510 @@ +# This file was autogenerated by uv via the following command: +# uv export --all-packages -o requirements-dev.lock --no-hashes +-e . + # via agentex-sdk +-e ./adk +aiohappyeyeballs==2.6.1 + # via aiohttp +aiohttp==3.13.3 + # via + # agentex-sdk + # litellm +aiosignal==1.4.0 + # via aiohttp +annotated-doc==0.0.4 + # via fastapi +annotated-types==0.7.0 + # via pydantic +anyio==4.12.1 + # via + # agentex-client + # claude-agent-sdk + # httpx + # httpx2 + # mcp + # openai + # scale-gp + # scale-gp-beta + # sse-starlette + # starlette + # watchfiles +asttokens==3.0.1 + # via stack-data +attrs==25.4.0 + # via + # aiohttp + # jsonschema + # referencing +bytecode==0.17.0 + # via ddtrace +certifi==2026.1.4 + # via + # httpcore + # httpx + # kubernetes + # requests +cffi==2.0.0 ; platform_python_implementation != 'PyPy' + # via cryptography +charset-normalizer==3.4.7 + # via requests +claude-agent-sdk==0.2.87 + # via agentex-sdk +click==8.4.1 + # via + # litellm + # typer + # uvicorn +cloudpickle==3.1.2 + # via agentex-sdk +colorama==0.4.6 ; sys_platform == 'win32' + # via + # click + # ipython + # pytest + # tqdm +comm==0.2.3 + # via ipywidgets +cryptography==48.0.0 + # via pyjwt +ddtrace==4.10.1 + # via agentex-sdk +debugpy==1.8.21 +decorator==5.3.1 + # via ipython +dirty-equals==0.11 +distro==1.9.0 + # via + # agentex-client + # openai + # scale-gp + # scale-gp-beta +durationpy==0.10 + # via kubernetes +envier==0.6.1 + # via ddtrace +execnet==2.1.2 + # via pytest-xdist +executing==2.2.1 + # via stack-data +fastapi==0.136.3 + # via agentex-sdk +fastjsonschema==2.21.2 + # via nbformat +fastuuid==0.14.0 + # via litellm +filelock==3.29.0 + # via huggingface-hub +frozenlist==1.8.0 + # via + # aiohttp + # aiosignal +fsspec==2026.4.0 + # via huggingface-hub +genai-prices==0.0.62 + # via pydantic-ai-slim +griffelib==2.0.2 + # via + # openai-agents + # pydantic-ai-slim +h11==0.16.0 + # via + # httpcore + # httpcore2 + # uvicorn +hf-xet==1.5.0 ; platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64' + # via huggingface-hub +httpcore==1.0.9 + # via httpx +httpcore2==2.3.0 + # via httpx2 +httpx==0.28.1 + # via + # agentex-client + # huggingface-hub + # langsmith + # litellm + # mcp + # openai + # pydantic-ai-slim + # pydantic-graph + # respx + # scale-gp + # scale-gp-beta +httpx-sse==0.4.3 + # via mcp +httpx2==2.3.0 + # via genai-prices +huggingface-hub==1.13.0 + # via tokenizers +idna==3.11 + # via + # anyio + # httpx + # httpx2 + # requests + # yarl +importlib-metadata==8.7.1 + # via litellm +iniconfig==2.3.0 + # via pytest +ipython==9.14.0 + # via ipywidgets +ipython-pygments-lexers==1.1.1 + # via ipython +ipywidgets==8.1.8 +jedi==0.20.0 + # via ipython +jinja2==3.1.6 + # via + # agentex-sdk + # litellm +jiter==0.15.0 + # via openai +json-log-formatter==1.1.1 + # via agentex-sdk +jsonpatch==1.33 + # via langchain-core +jsonpointer==3.1.1 + # via jsonpatch +jsonref==1.1.0 + # via agentex-sdk +jsonschema==4.26.0 + # via + # agentex-sdk + # litellm + # mcp + # nbformat +jsonschema-specifications==2025.9.1 + # via jsonschema +jupyter-core==5.9.1 + # via nbformat +jupyterlab-widgets==3.0.16 + # via ipywidgets +kubernetes==35.0.0 + # via agentex-sdk +langchain-core==1.4.0 + # via langgraph-checkpoint +langchain-protocol==0.0.16 + # via langchain-core +langgraph-checkpoint==4.1.1 + # via agentex-sdk +langsmith==0.8.8 + # via langchain-core +litellm==1.87.0 + # via agentex-sdk +logfire-api==4.35.0 + # via pydantic-graph +markdown-it-py==4.0.0 + # via rich +markupsafe==3.0.3 + # via jinja2 +matplotlib-inline==0.2.2 + # via ipython +mcp==1.27.2 + # via + # agentex-sdk + # claude-agent-sdk + # openai-agents +mdurl==0.1.2 + # via markdown-it-py +multidict==6.7.0 + # via + # aiohttp + # yarl +mypy==1.17.0 +mypy-extensions==1.1.0 + # via mypy +nbformat==5.10.4 + # via nbstripout +nbstripout==0.9.1 +nest-asyncio==1.6.0 +nexus-rpc==1.4.0 + # via temporalio +nodeenv==1.10.0 + # via pyright +oauthlib==3.3.1 + # via requests-oauthlib +openai==2.40.0 + # via + # agentex-sdk + # litellm + # openai-agents +openai-agents==0.14.8 + # via agentex-sdk +opentelemetry-api==1.42.1 + # via + # agentex-sdk + # ddtrace + # opentelemetry-sdk + # opentelemetry-semantic-conventions + # pydantic-ai-slim +opentelemetry-sdk==1.42.1 + # via agentex-sdk +opentelemetry-semantic-conventions==0.63b1 + # via opentelemetry-sdk +orjson==3.11.9 ; platform_python_implementation != 'PyPy' + # via langsmith +ormsgpack==1.12.2 + # via langgraph-checkpoint +packaging==25.0 + # via + # huggingface-hub + # langchain-core + # langsmith + # pytest +parso==0.8.7 + # via jedi +pathspec==1.0.3 + # via mypy +pexpect==4.9.0 ; sys_platform != 'emscripten' and sys_platform != 'win32' + # via ipython +platformdirs==4.10.0 + # via jupyter-core +pluggy==1.6.0 + # via pytest +prompt-toolkit==3.0.52 + # via + # ipython + # questionary +propcache==0.4.1 + # via + # aiohttp + # yarl +protobuf==6.33.6 + # via temporalio +psutil==7.2.2 ; sys_platform != 'emscripten' + # via ipython +ptyprocess==0.7.0 ; sys_platform != 'emscripten' and sys_platform != 'win32' + # via pexpect +pure-eval==0.2.3 + # via stack-data +pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' + # via cffi +pydantic==2.12.5 + # via + # agentex-client + # fastapi + # genai-prices + # langchain-core + # langsmith + # litellm + # mcp + # openai + # openai-agents + # pydantic-ai-slim + # pydantic-graph + # pydantic-settings + # python-on-whales + # scale-gp + # scale-gp-beta +pydantic-ai-slim==1.105.0 + # via agentex-sdk +pydantic-core==2.41.5 + # via pydantic +pydantic-graph==1.105.0 + # via pydantic-ai-slim +pydantic-settings==2.14.1 + # via mcp +pygments==2.19.2 + # via + # ipython + # ipython-pygments-lexers + # pytest + # rich +pyjwt==2.13.0 + # via mcp +pyright==1.1.399 +pytest==9.0.2 + # via + # pytest-asyncio + # pytest-xdist +pytest-asyncio==1.3.0 +pytest-xdist==3.8.0 +python-dateutil==2.9.0.post0 + # via kubernetes +python-dotenv==1.2.2 + # via + # litellm + # pydantic-settings +python-multipart==0.0.30 + # via mcp +python-on-whales==0.73.0 + # via agentex-sdk +pywin32==311 ; sys_platform == 'win32' + # via mcp +pyyaml==6.0.3 + # via + # agentex-sdk + # huggingface-hub + # kubernetes + # langchain-core +questionary==2.1.1 + # via agentex-sdk +redis==7.4.0 + # via agentex-sdk +referencing==0.37.0 + # via + # jsonschema + # jsonschema-specifications +regex==2026.5.9 + # via tiktoken +requests==2.34.2 + # via + # kubernetes + # langsmith + # openai-agents + # python-on-whales + # requests-oauthlib + # requests-toolbelt + # tiktoken +requests-oauthlib==2.0.0 + # via kubernetes +requests-toolbelt==1.0.0 + # via langsmith +respx==0.22.0 +rich==13.9.4 + # via + # agentex-sdk + # typer +rpds-py==2026.5.1 + # via + # jsonschema + # referencing +ruff==0.14.13 +scale-gp==0.1.0a62 + # via agentex-sdk +scale-gp-beta==0.2.0 + # via agentex-sdk +shellingham==1.5.4 + # via typer +six==1.17.0 + # via + # kubernetes + # python-dateutil +sniffio==1.3.1 + # via + # agentex-client + # claude-agent-sdk + # openai + # scale-gp + # scale-gp-beta +sse-starlette==3.4.4 + # via mcp +stack-data==0.6.3 + # via ipython +starlette==1.2.1 + # via + # agentex-sdk + # fastapi + # mcp + # sse-starlette +temporalio==1.27.2 + # via agentex-sdk +tenacity==9.1.4 + # via langchain-core +termcolor==3.3.0 + # via yaspin +tiktoken==0.13.0 + # via litellm +time-machine==3.2.0 +tokenizers==0.23.1 + # via litellm +tqdm==4.67.3 + # via + # huggingface-hub + # openai + # python-on-whales +traitlets==5.15.0 + # via + # ipython + # ipywidgets + # jupyter-core + # matplotlib-inline + # nbformat +truststore==0.10.4 + # via + # httpcore2 + # httpx2 +typer==0.16.1 + # via + # agentex-sdk + # huggingface-hub + # python-on-whales +types-protobuf==6.32.1.20260221 + # via temporalio +types-requests==2.33.0.20260518 + # via openai-agents +typing-extensions==4.15.0 + # via + # agentex-client + # aiosignal + # anyio + # fastapi + # huggingface-hub + # langchain-core + # langchain-protocol + # mcp + # mypy + # nexus-rpc + # openai + # openai-agents + # opentelemetry-api + # opentelemetry-sdk + # opentelemetry-semantic-conventions + # pydantic + # pydantic-core + # pyright + # pytest-asyncio + # python-on-whales + # referencing + # scale-gp + # scale-gp-beta + # starlette + # temporalio + # typer + # typing-inspection +typing-inspection==0.4.2 + # via + # fastapi + # mcp + # pydantic + # pydantic-ai-slim + # pydantic-graph + # pydantic-settings +urllib3==2.7.0 + # via + # kubernetes + # requests + # types-requests +uuid-utils==0.16.0 + # via + # langchain-core + # langsmith +uvicorn==0.48.0 + # via + # agentex-sdk + # mcp +watchfiles==0.24.0 + # via agentex-sdk +wcwidth==0.7.0 + # via prompt-toolkit +websocket-client==1.9.0 + # via kubernetes +websockets==16.0 + # via + # langsmith + # openai-agents +widgetsnbextension==4.0.15 + # via ipywidgets +wrapt==2.2.1 + # via ddtrace +xxhash==3.7.0 + # via langsmith +yarl==1.22.0 + # via aiohttp +yaspin==3.4.0 + # via agentex-sdk +zipp==3.23.0 + # via importlib-metadata +zstandard==0.25.0 + # via langsmith diff --git a/scripts/bootstrap b/scripts/bootstrap new file mode 100755 index 000000000..084b88899 --- /dev/null +++ b/scripts/bootstrap @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "${SKIP_BREW:-}" != "1" ] && [ -t 0 ]; then + brew bundle check >/dev/null 2>&1 || { + echo -n "==> Install Homebrew dependencies? (y/N): " + read -r response + case "$response" in + [yY][eE][sS]|[yY]) + brew bundle + ;; + *) + ;; + esac + echo + } +fi + +echo "==> Installing Python…" +uv python install + +echo "==> Installing Python dependencies…" +# --all-packages: install both workspace members (slim client + heavy ADK overlay). +uv sync --all-packages --all-extras + +echo "==> Exporting Python dependencies…" +# note: `--no-hashes` is required because of https://github.com/pypa/pip/issues/4995 +uv export --all-packages -o requirements-dev.lock --no-hashes diff --git a/scripts/check-slim-deps b/scripts/check-slim-deps new file mode 100755 index 000000000..d52a333d6 --- /dev/null +++ b/scripts/check-slim-deps @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Guardrail: the slim agentex-client must keep exactly its 6 bare-client deps +# — Stainless re-emitting an un-trimmed dashboard dep-list would break the split. + +set -e + +cd "$(dirname "$0")/.." + +python3 - <<'PY' +import re +import sys +import tomllib + +EXPECTED = {"httpx", "pydantic", "typing-extensions", "anyio", "distro", "sniffio"} + + +def norm(name: str) -> str: + return re.sub(r"[-_.]+", "-", name.strip().lower()) + + +with open("pyproject.toml", "rb") as f: + deps = tomllib.load(f)["project"]["dependencies"] + +got = {norm(re.split(r"[<>=!~ \[;]", d, maxsplit=1)[0]) for d in deps} +expected = {norm(n) for n in EXPECTED} + +if got != expected: + print("slim dependency drift in root pyproject.toml!", file=sys.stderr) + print(f" expected ({len(expected)}): {sorted(expected)}", file=sys.stderr) + print(f" got ({len(got)}): {sorted(got)}", file=sys.stderr) + print( + "If Stainless re-added ADK deps, trim the dashboard dep-list " + "(ADK deps belong in adk/pyproject.toml).", + file=sys.stderr, + ) + sys.exit(1) + +print(f"slim deps OK ({len(got)}): {sorted(got)}") +PY diff --git a/scripts/check-wheel-install b/scripts/check-wheel-install new file mode 100755 index 000000000..b80aed2d2 --- /dev/null +++ b/scripts/check-wheel-install @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Smoke: agentex-client + agentex-sdk must install together into one working +# agentex.* namespace. Builds + installs in a clean temp dir to avoid stale dist/. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +work="$(mktemp -d)" +echo "==> building both wheels into $work/dist" +uv build --all-packages --wheel --out-dir "$work/dist" + +venv="$work/venv" +uv venv "$venv" >/dev/null +echo "==> installing both wheels into a fresh venv" +uv pip install --python "$venv" "$work"/dist/agentex_client-*.whl "$work"/dist/agentex_sdk-*.whl + +echo "==> importing the merged namespace from the installed wheels" +"$venv/bin/python" - <<'PY' +import agentex.lib.adk # ADK overlay — ships in agentex-sdk +from agentex.types import Event # client surface — ships in agentex-client +from agentex.resources import states # client surface that "didn't land" in the incident + +print("agentex namespace OK:", Event.__name__, states.__name__) +PY diff --git a/scripts/format b/scripts/format new file mode 100755 index 000000000..c8e1f69d2 --- /dev/null +++ b/scripts/format @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Running ruff" +uv run ruff format +uv run ruff check --fix . +# run formatting again to fix any inconsistencies when imports are stripped +uv run ruff format + +echo "==> Formatting docs" +uv run python scripts/utils/ruffen-docs.py README.md $(find . -type f -name api.md) diff --git a/scripts/lint b/scripts/lint new file mode 100755 index 000000000..0a35b8d8c --- /dev/null +++ b/scripts/lint @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +if [ "$1" = "--fix" ]; then + echo "==> Running ruff with --fix" + uv run ruff check . --fix +else + echo "==> Running ruff" + uv run ruff check . +fi + +echo "==> Running pyright" +uv run pyright -p . + +echo "==> Making sure it imports" +uv run python -c 'import agentex' diff --git a/scripts/test b/scripts/test new file mode 100755 index 000000000..b01ec2c64 --- /dev/null +++ b/scripts/test @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + + + +export DEFER_PYDANTIC_BUILD=false + +# Note that we need to specify the patch version here so that uv +# won't use unstable (alpha, beta, rc) releases for the tests +PY_VERSION_MIN=">=3.12.0,<3.13" +PY_VERSION_MAX=">=3.13.0,<3.14" + +function run_tests() { + echo "==> Running tests with Pydantic v2" + uv run --isolated --all-packages --all-extras pytest "$@" +} + +# If UV_PYTHON is already set in the environment, just run the command once +if [[ -n "$UV_PYTHON" ]]; then + run_tests "$@" +else + # If UV_PYTHON is not set, run the command for min and max versions + + echo "==> Running tests for Python $PY_VERSION_MIN" + UV_PYTHON="$PY_VERSION_MIN" run_tests "$@" + + echo "==> Running tests for Python $PY_VERSION_MAX" + UV_PYTHON="$PY_VERSION_MAX" run_tests "$@" +fi diff --git a/scripts/utils/ruffen-docs.py b/scripts/utils/ruffen-docs.py new file mode 100644 index 000000000..0cf2bd2fd --- /dev/null +++ b/scripts/utils/ruffen-docs.py @@ -0,0 +1,167 @@ +# fork of https://github.com/asottile/blacken-docs adapted for ruff +from __future__ import annotations + +import re +import sys +import argparse +import textwrap +import contextlib +import subprocess +from typing import Match, Optional, Sequence, Generator, NamedTuple, cast + +MD_RE = re.compile( + r"(?P^(?P *)```\s*python\n)" r"(?P.*?)" r"(?P^(?P=indent)```\s*$)", + re.DOTALL | re.MULTILINE, +) +MD_PYCON_RE = re.compile( + r"(?P^(?P *)```\s*pycon\n)" r"(?P.*?)" r"(?P^(?P=indent)```.*$)", + re.DOTALL | re.MULTILINE, +) +PYCON_PREFIX = ">>> " +PYCON_CONTINUATION_PREFIX = "..." +PYCON_CONTINUATION_RE = re.compile( + rf"^{re.escape(PYCON_CONTINUATION_PREFIX)}( |$)", +) +DEFAULT_LINE_LENGTH = 100 + + +class CodeBlockError(NamedTuple): + offset: int + exc: Exception + + +def format_str( + src: str, +) -> tuple[str, Sequence[CodeBlockError]]: + errors: list[CodeBlockError] = [] + + @contextlib.contextmanager + def _collect_error(match: Match[str]) -> Generator[None, None, None]: + try: + yield + except Exception as e: + errors.append(CodeBlockError(match.start(), e)) + + def _md_match(match: Match[str]) -> str: + code = textwrap.dedent(match["code"]) + with _collect_error(match): + code = format_code_block(code) + code = textwrap.indent(code, match["indent"]) + return f"{match['before']}{code}{match['after']}" + + def _pycon_match(match: Match[str]) -> str: + code = "" + fragment = cast(Optional[str], None) + + def finish_fragment() -> None: + nonlocal code + nonlocal fragment + + if fragment is not None: + with _collect_error(match): + fragment = format_code_block(fragment) + fragment_lines = fragment.splitlines() + code += f"{PYCON_PREFIX}{fragment_lines[0]}\n" + for line in fragment_lines[1:]: + # Skip blank lines to handle Black adding a blank above + # functions within blocks. A blank line would end the REPL + # continuation prompt. + # + # >>> if True: + # ... def f(): + # ... pass + # ... + if line: + code += f"{PYCON_CONTINUATION_PREFIX} {line}\n" + if fragment_lines[-1].startswith(" "): + code += f"{PYCON_CONTINUATION_PREFIX}\n" + fragment = None + + indentation = None + for line in match["code"].splitlines(): + orig_line, line = line, line.lstrip() + if indentation is None and line: + indentation = len(orig_line) - len(line) + continuation_match = PYCON_CONTINUATION_RE.match(line) + if continuation_match and fragment is not None: + fragment += line[continuation_match.end() :] + "\n" + else: + finish_fragment() + if line.startswith(PYCON_PREFIX): + fragment = line[len(PYCON_PREFIX) :] + "\n" + else: + code += orig_line[indentation:] + "\n" + finish_fragment() + return code + + def _md_pycon_match(match: Match[str]) -> str: + code = _pycon_match(match) + code = textwrap.indent(code, match["indent"]) + return f"{match['before']}{code}{match['after']}" + + src = MD_RE.sub(_md_match, src) + src = MD_PYCON_RE.sub(_md_pycon_match, src) + return src, errors + + +def format_code_block(code: str) -> str: + return subprocess.check_output( + [ + sys.executable, + "-m", + "ruff", + "format", + "--stdin-filename=script.py", + f"--line-length={DEFAULT_LINE_LENGTH}", + ], + encoding="utf-8", + input=code, + ) + + +def format_file( + filename: str, + skip_errors: bool, +) -> int: + with open(filename, encoding="UTF-8") as f: + contents = f.read() + new_contents, errors = format_str(contents) + for error in errors: + lineno = contents[: error.offset].count("\n") + 1 + print(f"{filename}:{lineno}: code block parse error {error.exc}") + if errors and not skip_errors: + return 1 + if contents != new_contents: + print(f"{filename}: Rewriting...") + with open(filename, "w", encoding="UTF-8") as f: + f.write(new_contents) + return 0 + else: + return 0 + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "-l", + "--line-length", + type=int, + default=DEFAULT_LINE_LENGTH, + ) + parser.add_argument( + "-S", + "--skip-string-normalization", + action="store_true", + ) + parser.add_argument("-E", "--skip-errors", action="store_true") + parser.add_argument("filenames", nargs="*") + args = parser.parse_args(argv) + + retv = 0 + for filename in args.filenames: + retv |= format_file(filename, skip_errors=args.skip_errors) + return retv + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh new file mode 100755 index 000000000..e766fabe6 --- /dev/null +++ b/scripts/utils/upload-artifact.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -exuo pipefail + +FILENAME=$(basename dist/*.whl) + +RESPONSE=$(curl -X POST "$URL?filename=$FILENAME" \ + -H "Authorization: Bearer $AUTH" \ + -H "Content-Type: application/json") + +SIGNED_URL=$(echo "$RESPONSE" | jq -r '.url') + +if [[ "$SIGNED_URL" == "null" ]]; then + echo -e "\033[31mFailed to get signed URL.\033[0m" + exit 1 +fi + +UPLOAD_RESPONSE=$(curl -v -X PUT \ + -H "Content-Type: binary/octet-stream" \ + --data-binary "@dist/$FILENAME" "$SIGNED_URL" 2>&1) + +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" +else + echo -e "\033[31mFailed to upload artifact.\033[0m" + exit 1 +fi diff --git a/src/agentex/__init__.py b/src/agentex/__init__.py new file mode 100644 index 000000000..772c2c0f4 --- /dev/null +++ b/src/agentex/__init__.py @@ -0,0 +1,104 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import typing as _t + +from . import types +from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes, omit, not_given +from ._utils import file_from_path +from ._client import ( + ENVIRONMENTS, + Client, + Stream, + Agentex, + Timeout, + Transport, + AsyncClient, + AsyncStream, + AsyncAgentex, + RequestOptions, +) +from ._models import BaseModel +from ._version import __title__, __version__ +from ._response import APIResponse as APIResponse, AsyncAPIResponse as AsyncAPIResponse +from ._constants import DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES, DEFAULT_CONNECTION_LIMITS +from ._exceptions import ( + APIError, + AgentexError, + ConflictError, + NotFoundError, + APIStatusError, + RateLimitError, + APITimeoutError, + BadRequestError, + APIConnectionError, + AuthenticationError, + InternalServerError, + PermissionDeniedError, + UnprocessableEntityError, + APIResponseValidationError, +) +from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient +from ._utils._logs import setup_logging as _setup_logging + +__all__ = [ + "types", + "__version__", + "__title__", + "NoneType", + "Transport", + "ProxiesTypes", + "NotGiven", + "NOT_GIVEN", + "not_given", + "Omit", + "omit", + "AgentexError", + "APIError", + "APIStatusError", + "APITimeoutError", + "APIConnectionError", + "APIResponseValidationError", + "BadRequestError", + "AuthenticationError", + "PermissionDeniedError", + "NotFoundError", + "ConflictError", + "UnprocessableEntityError", + "RateLimitError", + "InternalServerError", + "Timeout", + "RequestOptions", + "Client", + "AsyncClient", + "Stream", + "AsyncStream", + "Agentex", + "AsyncAgentex", + "ENVIRONMENTS", + "file_from_path", + "BaseModel", + "DEFAULT_TIMEOUT", + "DEFAULT_MAX_RETRIES", + "DEFAULT_CONNECTION_LIMITS", + "DefaultHttpxClient", + "DefaultAsyncHttpxClient", + "DefaultAioHttpClient", +] + +if not _t.TYPE_CHECKING: + from ._utils._resources_proxy import resources as resources + +_setup_logging() + +# Update the __module__ attribute for exported symbols so that +# error messages point to this module instead of the module +# it was originally defined in, e.g. +# agentex._exceptions.NotFoundError -> agentex.NotFoundError +__locals = locals() +for __name in __all__: + if not __name.startswith("__"): + try: + __locals[__name].__module__ = "agentex" + except (TypeError, AttributeError): + # Some of our exported symbols are builtins which we can't set attributes for. + pass diff --git a/src/agentex/_base_client.py b/src/agentex/_base_client.py new file mode 100644 index 000000000..183a81bf2 --- /dev/null +++ b/src/agentex/_base_client.py @@ -0,0 +1,2131 @@ +from __future__ import annotations + +import sys +import json +import time +import uuid +import email +import asyncio +import inspect +import logging +import platform +import warnings +import email.utils +from types import TracebackType +from random import random +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Type, + Union, + Generic, + Mapping, + TypeVar, + Iterable, + Iterator, + Optional, + Generator, + AsyncIterator, + cast, + overload, +) +from typing_extensions import Literal, override, get_origin + +import anyio +import httpx +import distro +import pydantic +from httpx import URL +from pydantic import PrivateAttr + +from . import _exceptions +from ._qs import Querystring +from ._files import to_httpx_files, async_to_httpx_files +from ._types import ( + Body, + Omit, + Query, + Headers, + Timeout, + NotGiven, + ResponseT, + AnyMapping, + PostParser, + BinaryTypes, + RequestFiles, + HttpxSendArgs, + RequestOptions, + AsyncBinaryTypes, + HttpxRequestFiles, + ModelBuilderProtocol, + not_given, +) +from ._utils import is_dict, is_list, asyncify, is_given, lru_cache, is_mapping +from ._compat import PYDANTIC_V1, model_copy, model_dump +from ._models import GenericModel, FinalRequestOptions, validate_type, construct_type +from ._response import ( + APIResponse, + BaseAPIResponse, + AsyncAPIResponse, + extract_response_type, +) +from ._constants import ( + DEFAULT_TIMEOUT, + MAX_RETRY_DELAY, + DEFAULT_MAX_RETRIES, + INITIAL_RETRY_DELAY, + RAW_RESPONSE_HEADER, + OVERRIDE_CAST_TO_HEADER, + DEFAULT_CONNECTION_LIMITS, +) +from ._streaming import Stream, SSEDecoder, AsyncStream, SSEBytesDecoder +from ._exceptions import ( + APIStatusError, + APITimeoutError, + APIConnectionError, + APIResponseValidationError, +) +from ._utils._json import openapi_dumps + +log: logging.Logger = logging.getLogger(__name__) + +# TODO: make base page type vars covariant +SyncPageT = TypeVar("SyncPageT", bound="BaseSyncPage[Any]") +AsyncPageT = TypeVar("AsyncPageT", bound="BaseAsyncPage[Any]") + + +_T = TypeVar("_T") +_T_co = TypeVar("_T_co", covariant=True) + +_StreamT = TypeVar("_StreamT", bound=Stream[Any]) +_AsyncStreamT = TypeVar("_AsyncStreamT", bound=AsyncStream[Any]) + +if TYPE_CHECKING: + from httpx._config import ( + DEFAULT_TIMEOUT_CONFIG, # pyright: ignore[reportPrivateImportUsage] + ) + + HTTPX_DEFAULT_TIMEOUT = DEFAULT_TIMEOUT_CONFIG +else: + try: + from httpx._config import DEFAULT_TIMEOUT_CONFIG as HTTPX_DEFAULT_TIMEOUT + except ImportError: + # taken from https://github.com/encode/httpx/blob/3ba5fe0d7ac70222590e759c31442b1cab263791/httpx/_config.py#L366 + HTTPX_DEFAULT_TIMEOUT = Timeout(5.0) + + +class PageInfo: + """Stores the necessary information to build the request to retrieve the next page. + + Either `url` or `params` must be set. + """ + + url: URL | NotGiven + params: Query | NotGiven + json: Body | NotGiven + + @overload + def __init__( + self, + *, + url: URL, + ) -> None: ... + + @overload + def __init__( + self, + *, + params: Query, + ) -> None: ... + + @overload + def __init__( + self, + *, + json: Body, + ) -> None: ... + + def __init__( + self, + *, + url: URL | NotGiven = not_given, + json: Body | NotGiven = not_given, + params: Query | NotGiven = not_given, + ) -> None: + self.url = url + self.json = json + self.params = params + + @override + def __repr__(self) -> str: + if self.url: + return f"{self.__class__.__name__}(url={self.url})" + if self.json: + return f"{self.__class__.__name__}(json={self.json})" + return f"{self.__class__.__name__}(params={self.params})" + + +class BasePage(GenericModel, Generic[_T]): + """ + Defines the core interface for pagination. + + Type Args: + ModelT: The pydantic model that represents an item in the response. + + Methods: + has_next_page(): Check if there is another page available + next_page_info(): Get the necessary information to make a request for the next page + """ + + _options: FinalRequestOptions = PrivateAttr() + _model: Type[_T] = PrivateAttr() + + def has_next_page(self) -> bool: + items = self._get_page_items() + if not items: + return False + return self.next_page_info() is not None + + def next_page_info(self) -> Optional[PageInfo]: ... + + def _get_page_items(self) -> Iterable[_T]: # type: ignore[empty-body] + ... + + def _params_from_url(self, url: URL) -> httpx.QueryParams: + # TODO: do we have to preprocess params here? + return httpx.QueryParams(cast(Any, self._options.params)).merge(url.params) + + def _info_to_options(self, info: PageInfo) -> FinalRequestOptions: + options = model_copy(self._options) + options._strip_raw_response_header() + + if not isinstance(info.params, NotGiven): + options.params = {**options.params, **info.params} + return options + + if not isinstance(info.url, NotGiven): + params = self._params_from_url(info.url) + url = info.url.copy_with(params=params) + options.params = dict(url.params) + options.url = str(url) + return options + + if not isinstance(info.json, NotGiven): + if not is_mapping(info.json): + raise TypeError("Pagination is only supported with mappings") + + if not options.json_data: + options.json_data = {**info.json} + else: + if not is_mapping(options.json_data): + raise TypeError("Pagination is only supported with mappings") + + options.json_data = {**options.json_data, **info.json} + return options + + raise ValueError("Unexpected PageInfo state") + + +class BaseSyncPage(BasePage[_T], Generic[_T]): + _client: SyncAPIClient = pydantic.PrivateAttr() + + def _set_private_attributes( + self, + client: SyncAPIClient, + model: Type[_T], + options: FinalRequestOptions, + ) -> None: + if (not PYDANTIC_V1) and getattr(self, "__pydantic_private__", None) is None: + self.__pydantic_private__ = {} + + self._model = model + self._client = client + self._options = options + + # Pydantic uses a custom `__iter__` method to support casting BaseModels + # to dictionaries. e.g. dict(model). + # As we want to support `for item in page`, this is inherently incompatible + # with the default pydantic behaviour. It is not possible to support both + # use cases at once. Fortunately, this is not a big deal as all other pydantic + # methods should continue to work as expected as there is an alternative method + # to cast a model to a dictionary, model.dict(), which is used internally + # by pydantic. + def __iter__(self) -> Iterator[_T]: # type: ignore + for page in self.iter_pages(): + for item in page._get_page_items(): + yield item + + def iter_pages(self: SyncPageT) -> Iterator[SyncPageT]: + page = self + while True: + yield page + if page.has_next_page(): + page = page.get_next_page() + else: + return + + def get_next_page(self: SyncPageT) -> SyncPageT: + info = self.next_page_info() + if not info: + raise RuntimeError( + "No next page expected; please check `.has_next_page()` before calling `.get_next_page()`." + ) + + options = self._info_to_options(info) + return self._client._request_api_list(self._model, page=self.__class__, options=options) + + +class AsyncPaginator(Generic[_T, AsyncPageT]): + def __init__( + self, + client: AsyncAPIClient, + options: FinalRequestOptions, + page_cls: Type[AsyncPageT], + model: Type[_T], + ) -> None: + self._model = model + self._client = client + self._options = options + self._page_cls = page_cls + + def __await__(self) -> Generator[Any, None, AsyncPageT]: + return self._get_page().__await__() + + async def _get_page(self) -> AsyncPageT: + def _parser(resp: AsyncPageT) -> AsyncPageT: + resp._set_private_attributes( + model=self._model, + options=self._options, + client=self._client, + ) + return resp + + self._options.post_parser = _parser + + return await self._client.request(self._page_cls, self._options) + + async def __aiter__(self) -> AsyncIterator[_T]: + # https://github.com/microsoft/pyright/issues/3464 + page = cast( + AsyncPageT, + await self, # type: ignore + ) + async for item in page: + yield item + + +class BaseAsyncPage(BasePage[_T], Generic[_T]): + _client: AsyncAPIClient = pydantic.PrivateAttr() + + def _set_private_attributes( + self, + model: Type[_T], + client: AsyncAPIClient, + options: FinalRequestOptions, + ) -> None: + if (not PYDANTIC_V1) and getattr(self, "__pydantic_private__", None) is None: + self.__pydantic_private__ = {} + + self._model = model + self._client = client + self._options = options + + async def __aiter__(self) -> AsyncIterator[_T]: + async for page in self.iter_pages(): + for item in page._get_page_items(): + yield item + + async def iter_pages(self: AsyncPageT) -> AsyncIterator[AsyncPageT]: + page = self + while True: + yield page + if page.has_next_page(): + page = await page.get_next_page() + else: + return + + async def get_next_page(self: AsyncPageT) -> AsyncPageT: + info = self.next_page_info() + if not info: + raise RuntimeError( + "No next page expected; please check `.has_next_page()` before calling `.get_next_page()`." + ) + + options = self._info_to_options(info) + return await self._client._request_api_list(self._model, page=self.__class__, options=options) + + +_HttpxClientT = TypeVar("_HttpxClientT", bound=Union[httpx.Client, httpx.AsyncClient]) +_DefaultStreamT = TypeVar("_DefaultStreamT", bound=Union[Stream[Any], AsyncStream[Any]]) + + +class BaseClient(Generic[_HttpxClientT, _DefaultStreamT]): + _client: _HttpxClientT + _version: str + _base_url: URL + max_retries: int + timeout: Union[float, Timeout, None] + _strict_response_validation: bool + _idempotency_header: str | None + _default_stream_cls: type[_DefaultStreamT] | None = None + + def __init__( + self, + *, + version: str, + base_url: str | URL, + _strict_response_validation: bool, + max_retries: int = DEFAULT_MAX_RETRIES, + timeout: float | Timeout | None = DEFAULT_TIMEOUT, + custom_headers: Mapping[str, str] | None = None, + custom_query: Mapping[str, object] | None = None, + ) -> None: + self._version = version + self._base_url = self._enforce_trailing_slash(URL(base_url)) + self.max_retries = max_retries + self.timeout = timeout + self._custom_headers = custom_headers or {} + self._custom_query = custom_query or {} + self._strict_response_validation = _strict_response_validation + self._idempotency_header = None + self._platform: Platform | None = None + + if max_retries is None: # pyright: ignore[reportUnnecessaryComparison] + raise TypeError( + "max_retries cannot be None. If you want to disable retries, pass `0`; if you want unlimited retries, pass `math.inf` or a very high number; if you want the default behavior, pass `agentex.DEFAULT_MAX_RETRIES`" + ) + + def _enforce_trailing_slash(self, url: URL) -> URL: + if url.raw_path.endswith(b"/"): + return url + return url.copy_with(raw_path=url.raw_path + b"/") + + def _make_status_error_from_response( + self, + response: httpx.Response, + ) -> APIStatusError: + if response.is_closed and not response.is_stream_consumed: + # We can't read the response body as it has been closed + # before it was read. This can happen if an event hook + # raises a status error. + body = None + err_msg = f"Error code: {response.status_code}" + else: + err_text = response.text.strip() + body = err_text + + try: + body = json.loads(err_text) + err_msg = f"Error code: {response.status_code} - {body}" + except Exception: + err_msg = err_text or f"Error code: {response.status_code}" + + return self._make_status_error(err_msg, body=body, response=response) + + def _make_status_error( + self, + err_msg: str, + *, + body: object, + response: httpx.Response, + ) -> _exceptions.APIStatusError: + raise NotImplementedError() + + def _build_headers(self, options: FinalRequestOptions, *, retries_taken: int = 0) -> httpx.Headers: + custom_headers = options.headers or {} + headers_dict = _merge_mappings(self.default_headers, custom_headers) + self._validate_headers(headers_dict, custom_headers) + + # headers are case-insensitive while dictionaries are not. + headers = httpx.Headers(headers_dict) + + idempotency_header = self._idempotency_header + if idempotency_header and options.idempotency_key and idempotency_header not in headers: + headers[idempotency_header] = options.idempotency_key + + # Don't set these headers if they were already set or removed by the caller. We check + # `custom_headers`, which can contain `Omit()`, instead of `headers` to account for the removal case. + lower_custom_headers = [header.lower() for header in custom_headers] + if "x-stainless-retry-count" not in lower_custom_headers: + headers["x-stainless-retry-count"] = str(retries_taken) + if "x-stainless-read-timeout" not in lower_custom_headers: + timeout = self.timeout if isinstance(options.timeout, NotGiven) else options.timeout + if isinstance(timeout, Timeout): + timeout = timeout.read + if timeout is not None: + headers["x-stainless-read-timeout"] = str(timeout) + + return headers + + def _prepare_url(self, url: str) -> URL: + """ + Merge a URL argument together with any 'base_url' on the client, + to create the URL used for the outgoing request. + """ + # Copied from httpx's `_merge_url` method. + merge_url = URL(url) + if merge_url.is_relative_url: + merge_raw_path = self.base_url.raw_path + merge_url.raw_path.lstrip(b"/") + return self.base_url.copy_with(raw_path=merge_raw_path) + + return merge_url + + def _make_sse_decoder(self) -> SSEDecoder | SSEBytesDecoder: + return SSEDecoder() + + def _build_request( + self, + options: FinalRequestOptions, + *, + retries_taken: int = 0, + ) -> httpx.Request: + if log.isEnabledFor(logging.DEBUG): + log.debug( + "Request options: %s", + model_dump( + options, + exclude_unset=True, + # Pydantic v1 can't dump every type we support in content, so we exclude it for now. + exclude={ + "content", + } + if PYDANTIC_V1 + else {}, + ), + ) + kwargs: dict[str, Any] = {} + + json_data = options.json_data + if options.extra_json is not None: + if json_data is None: + json_data = cast(Body, options.extra_json) + elif is_mapping(json_data): + json_data = _merge_mappings(json_data, options.extra_json) + else: + raise RuntimeError(f"Unexpected JSON data type, {type(json_data)}, cannot merge with `extra_body`") + + headers = self._build_headers(options, retries_taken=retries_taken) + params = _merge_mappings(self.default_query, options.params) + content_type = headers.get("Content-Type") + files = options.files + + # If the given Content-Type header is multipart/form-data then it + # has to be removed so that httpx can generate the header with + # additional information for us as it has to be in this form + # for the server to be able to correctly parse the request: + # multipart/form-data; boundary=---abc-- + if content_type is not None and content_type.startswith("multipart/form-data"): + if "boundary" not in content_type: + # only remove the header if the boundary hasn't been explicitly set + # as the caller doesn't want httpx to come up with their own boundary + headers.pop("Content-Type") + + # As we are now sending multipart/form-data instead of application/json + # we need to tell httpx to use it, https://www.python-httpx.org/advanced/clients/#multipart-file-encoding + if json_data: + if not is_dict(json_data): + raise TypeError( + f"Expected query input to be a dictionary for multipart requests but got {type(json_data)} instead." + ) + kwargs["data"] = self._serialize_multipartform(json_data) + + # httpx determines whether or not to send a "multipart/form-data" + # request based on the truthiness of the "files" argument. + # This gets around that issue by generating a dict value that + # evaluates to true. + # + # https://github.com/encode/httpx/discussions/2399#discussioncomment-3814186 + if not files: + files = cast(HttpxRequestFiles, ForceMultipartDict()) + + prepared_url = self._prepare_url(options.url) + # preserve hard-coded query params from the url + if params and prepared_url.query: + params = {**dict(prepared_url.params.items()), **params} + prepared_url = prepared_url.copy_with(raw_path=prepared_url.raw_path.split(b"?", 1)[0]) + if "_" in prepared_url.host: + # work around https://github.com/encode/httpx/discussions/2880 + kwargs["extensions"] = {"sni_hostname": prepared_url.host.replace("_", "-")} + + is_body_allowed = options.method.lower() != "get" + + if is_body_allowed: + if options.content is not None and json_data is not None: + raise TypeError("Passing both `content` and `json_data` is not supported") + if options.content is not None and files is not None: + raise TypeError("Passing both `content` and `files` is not supported") + if options.content is not None: + kwargs["content"] = options.content + elif isinstance(json_data, bytes): + kwargs["content"] = json_data + elif not files: + # Don't set content when JSON is sent as multipart/form-data, + # since httpx's content param overrides other body arguments + kwargs["content"] = openapi_dumps(json_data) if is_given(json_data) and json_data is not None else None + kwargs["files"] = files + else: + headers.pop("Content-Type", None) + kwargs.pop("data", None) + + # TODO: report this error to httpx + return self._client.build_request( # pyright: ignore[reportUnknownMemberType] + headers=headers, + timeout=self.timeout if isinstance(options.timeout, NotGiven) else options.timeout, + method=options.method, + url=prepared_url, + # the `Query` type that we use is incompatible with qs' + # `Params` type as it needs to be typed as `Mapping[str, object]` + # so that passing a `TypedDict` doesn't cause an error. + # https://github.com/microsoft/pyright/issues/3526#event-6715453066 + params=self.qs.stringify(cast(Mapping[str, Any], params)) if params else None, + **kwargs, + ) + + def _serialize_multipartform(self, data: Mapping[object, object]) -> dict[str, object]: + items = self.qs.stringify_items( + # TODO: type ignore is required as stringify_items is well typed but we can't be + # well typed without heavy validation. + data, # type: ignore + array_format="brackets", + ) + serialized: dict[str, object] = {} + for key, value in items: + existing = serialized.get(key) + + if not existing: + serialized[key] = value + continue + + # If a value has already been set for this key then that + # means we're sending data like `array[]=[1, 2, 3]` and we + # need to tell httpx that we want to send multiple values with + # the same key which is done by using a list or a tuple. + # + # Note: 2d arrays should never result in the same key at both + # levels so it's safe to assume that if the value is a list, + # it was because we changed it to be a list. + if is_list(existing): + existing.append(value) + else: + serialized[key] = [existing, value] + + return serialized + + def _maybe_override_cast_to(self, cast_to: type[ResponseT], options: FinalRequestOptions) -> type[ResponseT]: + if not is_given(options.headers): + return cast_to + + # make a copy of the headers so we don't mutate user-input + headers = dict(options.headers) + + # we internally support defining a temporary header to override the + # default `cast_to` type for use with `.with_raw_response` and `.with_streaming_response` + # see _response.py for implementation details + override_cast_to = headers.pop(OVERRIDE_CAST_TO_HEADER, not_given) + if is_given(override_cast_to): + options.headers = headers + return cast(Type[ResponseT], override_cast_to) + + return cast_to + + def _should_stream_response_body(self, request: httpx.Request) -> bool: + return request.headers.get(RAW_RESPONSE_HEADER) == "stream" # type: ignore[no-any-return] + + def _process_response_data( + self, + *, + data: object, + cast_to: type[ResponseT], + response: httpx.Response, + ) -> ResponseT: + if data is None: + return cast(ResponseT, None) + + if cast_to is object: + return cast(ResponseT, data) + + try: + if inspect.isclass(cast_to) and issubclass(cast_to, ModelBuilderProtocol): + return cast(ResponseT, cast_to.build(response=response, data=data)) + + if self._strict_response_validation: + return cast(ResponseT, validate_type(type_=cast_to, value=data)) + + return cast(ResponseT, construct_type(type_=cast_to, value=data)) + except pydantic.ValidationError as err: + raise APIResponseValidationError(response=response, body=data) from err + + @property + def qs(self) -> Querystring: + return Querystring() + + @property + def custom_auth(self) -> httpx.Auth | None: + return None + + @property + def auth_headers(self) -> dict[str, str]: + return {} + + @property + def default_headers(self) -> dict[str, str | Omit]: + return { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": self.user_agent, + **self.platform_headers(), + **self.auth_headers, + **self._custom_headers, + } + + @property + def default_query(self) -> dict[str, object]: + return { + **self._custom_query, + } + + def _validate_headers( + self, + headers: Headers, # noqa: ARG002 + custom_headers: Headers, # noqa: ARG002 + ) -> None: + """Validate the given default headers and custom headers. + + Does nothing by default. + """ + return + + @property + def user_agent(self) -> str: + return f"{self.__class__.__name__}/Python {self._version}" + + @property + def base_url(self) -> URL: + return self._base_url + + @base_url.setter + def base_url(self, url: URL | str) -> None: + self._base_url = self._enforce_trailing_slash(url if isinstance(url, URL) else URL(url)) + + def platform_headers(self) -> Dict[str, str]: + # the actual implementation is in a separate `lru_cache` decorated + # function because adding `lru_cache` to methods will leak memory + # https://github.com/python/cpython/issues/88476 + return platform_headers(self._version, platform=self._platform) + + def _parse_retry_after_header(self, response_headers: Optional[httpx.Headers] = None) -> float | None: + """Returns a float of the number of seconds (not milliseconds) to wait after retrying, or None if unspecified. + + About the Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After + See also https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After#syntax + """ + if response_headers is None: + return None + + # First, try the non-standard `retry-after-ms` header for milliseconds, + # which is more precise than integer-seconds `retry-after` + try: + retry_ms_header = response_headers.get("retry-after-ms", None) + return float(retry_ms_header) / 1000 + except (TypeError, ValueError): + pass + + # Next, try parsing `retry-after` header as seconds (allowing nonstandard floats). + retry_header = response_headers.get("retry-after") + try: + # note: the spec indicates that this should only ever be an integer + # but if someone sends a float there's no reason for us to not respect it + return float(retry_header) + except (TypeError, ValueError): + pass + + # Last, try parsing `retry-after` as a date. + retry_date_tuple = email.utils.parsedate_tz(retry_header) + if retry_date_tuple is None: + return None + + retry_date = email.utils.mktime_tz(retry_date_tuple) + return float(retry_date - time.time()) + + def _calculate_retry_timeout( + self, + remaining_retries: int, + options: FinalRequestOptions, + response_headers: Optional[httpx.Headers] = None, + ) -> float: + max_retries = options.get_max_retries(self.max_retries) + + # If the API asks us to wait a certain amount of time (and it's a reasonable amount), just do what it says. + retry_after = self._parse_retry_after_header(response_headers) + if retry_after is not None and 0 < retry_after <= 60: + return retry_after + + # Also cap retry count to 1000 to avoid any potential overflows with `pow` + nb_retries = min(max_retries - remaining_retries, 1000) + + # Apply exponential backoff, but not more than the max. + sleep_seconds = min(INITIAL_RETRY_DELAY * pow(2.0, nb_retries), MAX_RETRY_DELAY) + + # Apply some jitter, plus-or-minus half a second. + jitter = 1 - 0.25 * random() + timeout = sleep_seconds * jitter + return timeout if timeout >= 0 else 0 + + def _should_retry(self, response: httpx.Response) -> bool: + # Note: this is not a standard header + should_retry_header = response.headers.get("x-should-retry") + + # If the server explicitly says whether or not to retry, obey. + if should_retry_header == "true": + log.debug("Retrying as header `x-should-retry` is set to `true`") + return True + if should_retry_header == "false": + log.debug("Not retrying as header `x-should-retry` is set to `false`") + return False + + # Retry on request timeouts. + if response.status_code == 408: + log.debug("Retrying due to status code %i", response.status_code) + return True + + # Retry on lock timeouts. + if response.status_code == 409: + log.debug("Retrying due to status code %i", response.status_code) + return True + + # Retry on rate limits. + if response.status_code == 429: + log.debug("Retrying due to status code %i", response.status_code) + return True + + # Retry internal errors. + if response.status_code >= 500: + log.debug("Retrying due to status code %i", response.status_code) + return True + + log.debug("Not retrying") + return False + + def _idempotency_key(self) -> str: + return f"stainless-python-retry-{uuid.uuid4()}" + + +class _DefaultHttpxClient(httpx.Client): + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("timeout", DEFAULT_TIMEOUT) + kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) + kwargs.setdefault("follow_redirects", True) + super().__init__(**kwargs) + + +if TYPE_CHECKING: + DefaultHttpxClient = httpx.Client + """An alias to `httpx.Client` that provides the same defaults that this SDK + uses internally. + + This is useful because overriding the `http_client` with your own instance of + `httpx.Client` will result in httpx's defaults being used, not ours. + """ +else: + DefaultHttpxClient = _DefaultHttpxClient + + +class SyncHttpxClientWrapper(DefaultHttpxClient): + def __del__(self) -> None: + if self.is_closed: + return + + try: + self.close() + except Exception: + pass + + +class SyncAPIClient(BaseClient[httpx.Client, Stream[Any]]): + _client: httpx.Client + _default_stream_cls: type[Stream[Any]] | None = None + + def __init__( + self, + *, + version: str, + base_url: str | URL, + max_retries: int = DEFAULT_MAX_RETRIES, + timeout: float | Timeout | None | NotGiven = not_given, + http_client: httpx.Client | None = None, + custom_headers: Mapping[str, str] | None = None, + custom_query: Mapping[str, object] | None = None, + _strict_response_validation: bool, + ) -> None: + if not is_given(timeout): + # if the user passed in a custom http client with a non-default + # timeout set then we use that timeout. + # + # note: there is an edge case here where the user passes in a client + # where they've explicitly set the timeout to match the default timeout + # as this check is structural, meaning that we'll think they didn't + # pass in a timeout and will ignore it + if http_client and http_client.timeout != HTTPX_DEFAULT_TIMEOUT: + timeout = http_client.timeout + else: + timeout = DEFAULT_TIMEOUT + + if http_client is not None and not isinstance(http_client, httpx.Client): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError( + f"Invalid `http_client` argument; Expected an instance of `httpx.Client` but got {type(http_client)}" + ) + + super().__init__( + version=version, + # cast to a valid type because mypy doesn't understand our type narrowing + timeout=cast(Timeout, timeout), + base_url=base_url, + max_retries=max_retries, + custom_query=custom_query, + custom_headers=custom_headers, + _strict_response_validation=_strict_response_validation, + ) + self._client = http_client or SyncHttpxClientWrapper( + base_url=base_url, + # cast to a valid type because mypy doesn't understand our type narrowing + timeout=cast(Timeout, timeout), + ) + + def is_closed(self) -> bool: + return self._client.is_closed + + def close(self) -> None: + """Close the underlying HTTPX client. + + The client will *not* be usable after this. + """ + # If an error is thrown while constructing a client, self._client + # may not be present + if hasattr(self, "_client"): + self._client.close() + + def __enter__(self: _T) -> _T: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.close() + + def _prepare_options( + self, + options: FinalRequestOptions, # noqa: ARG002 + ) -> FinalRequestOptions: + """Hook for mutating the given options""" + return options + + def _prepare_request( + self, + request: httpx.Request, # noqa: ARG002 + ) -> None: + """This method is used as a callback for mutating the `Request` object + after it has been constructed. + This is useful for cases where you want to add certain headers based off of + the request properties, e.g. `url`, `method` etc. + """ + return None + + @overload + def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: Literal[True], + stream_cls: Type[_StreamT], + ) -> _StreamT: ... + + @overload + def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: bool = False, + stream_cls: Type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: ... + + def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: bool = False, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: + cast_to = self._maybe_override_cast_to(cast_to, options) + + # create a copy of the options we were given so that if the + # options are mutated later & we then retry, the retries are + # given the original options + input_options = model_copy(options) + if input_options.idempotency_key is None and input_options.method.lower() != "get": + # ensure the idempotency key is reused between requests + input_options.idempotency_key = self._idempotency_key() + + response: httpx.Response | None = None + max_retries = input_options.get_max_retries(self.max_retries) + + retries_taken = 0 + for retries_taken in range(max_retries + 1): + options = model_copy(input_options) + options = self._prepare_options(options) + + remaining_retries = max_retries - retries_taken + request = self._build_request(options, retries_taken=retries_taken) + self._prepare_request(request) + + kwargs: HttpxSendArgs = {} + if self.custom_auth is not None: + kwargs["auth"] = self.custom_auth + + if options.follow_redirects is not None: + kwargs["follow_redirects"] = options.follow_redirects + + log.debug("Sending HTTP Request: %s %s", request.method, request.url) + + response = None + try: + response = self._client.send( + request, + stream=stream or self._should_stream_response_body(request=request), + **kwargs, + ) + except httpx.TimeoutException as err: + log.debug("Encountered httpx.TimeoutException", exc_info=True) + + if remaining_retries > 0: + self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising timeout error") + raise APITimeoutError(request=request) from err + except Exception as err: + log.debug("Encountered Exception", exc_info=True) + + if remaining_retries > 0: + self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising connection error") + raise APIConnectionError(request=request) from err + + log.debug( + 'HTTP Response: %s %s "%i %s" %s', + request.method, + request.url, + response.status_code, + response.reason_phrase, + response.headers, + ) + + try: + response.raise_for_status() + except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code + log.debug("Encountered httpx.HTTPStatusError", exc_info=True) + + if remaining_retries > 0 and self._should_retry(err.response): + err.response.close() + self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=response, + ) + continue + + # If the response is streamed then we need to explicitly read the response + # to completion before attempting to access the response text. + if not err.response.is_closed: + err.response.read() + + log.debug("Re-raising status error") + raise self._make_status_error_from_response(err.response) from None + + break + + assert response is not None, "could not resolve response (should never happen)" + return self._process_response( + cast_to=cast_to, + options=options, + response=response, + stream=stream, + stream_cls=stream_cls, + retries_taken=retries_taken, + ) + + def _sleep_for_retry( + self, *, retries_taken: int, max_retries: int, options: FinalRequestOptions, response: httpx.Response | None + ) -> None: + remaining_retries = max_retries - retries_taken + if remaining_retries == 1: + log.debug("1 retry left") + else: + log.debug("%i retries left", remaining_retries) + + timeout = self._calculate_retry_timeout(remaining_retries, options, response.headers if response else None) + log.info("Retrying request to %s in %f seconds", options.url, timeout) + + time.sleep(timeout) + + def _process_response( + self, + *, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + response: httpx.Response, + stream: bool, + stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None, + retries_taken: int = 0, + ) -> ResponseT: + origin = get_origin(cast_to) or cast_to + + if ( + inspect.isclass(origin) + and issubclass(origin, BaseAPIResponse) + # we only want to actually return the custom BaseAPIResponse class if we're + # returning the raw response, or if we're not streaming SSE, as if we're streaming + # SSE then `cast_to` doesn't actively reflect the type we need to parse into + and (not stream or bool(response.request.headers.get(RAW_RESPONSE_HEADER))) + ): + if not issubclass(origin, APIResponse): + raise TypeError(f"API Response types must subclass {APIResponse}; Received {origin}") + + response_cls = cast("type[BaseAPIResponse[Any]]", cast_to) + return cast( + ResponseT, + response_cls( + raw=response, + client=self, + cast_to=extract_response_type(response_cls), + stream=stream, + stream_cls=stream_cls, + options=options, + retries_taken=retries_taken, + ), + ) + + if cast_to == httpx.Response: + return cast(ResponseT, response) + + api_response = APIResponse( + raw=response, + client=self, + cast_to=cast("type[ResponseT]", cast_to), # pyright: ignore[reportUnnecessaryCast] + stream=stream, + stream_cls=stream_cls, + options=options, + retries_taken=retries_taken, + ) + if bool(response.request.headers.get(RAW_RESPONSE_HEADER)): + return cast(ResponseT, api_response) + + return api_response.parse() + + def _request_api_list( + self, + model: Type[object], + page: Type[SyncPageT], + options: FinalRequestOptions, + ) -> SyncPageT: + def _parser(resp: SyncPageT) -> SyncPageT: + resp._set_private_attributes( + client=self, + model=model, + options=options, + ) + return resp + + options.post_parser = _parser + + return self.request(page, options, stream=False) + + @overload + def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: Literal[True], + stream_cls: type[_StreamT], + ) -> _StreamT: ... + + @overload + def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: bool, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: ... + + def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: bool = False, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: + opts = FinalRequestOptions.construct(method="get", url=path, **options) + # cast is required because mypy complains about returning Any even though + # it understands the type variables + return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)) + + @overload + def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + content: BinaryTypes | None = None, + options: RequestOptions = {}, + files: RequestFiles | None = None, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + content: BinaryTypes | None = None, + options: RequestOptions = {}, + files: RequestFiles | None = None, + stream: Literal[True], + stream_cls: type[_StreamT], + ) -> _StreamT: ... + + @overload + def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + content: BinaryTypes | None = None, + options: RequestOptions = {}, + files: RequestFiles | None = None, + stream: bool, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: ... + + def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + content: BinaryTypes | None = None, + options: RequestOptions = {}, + files: RequestFiles | None = None, + stream: bool = False, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct( + method="post", url=path, json_data=body, content=content, files=to_httpx_files(files), **options + ) + return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)) + + def patch( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + content: BinaryTypes | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct( + method="patch", url=path, json_data=body, content=content, files=to_httpx_files(files), **options + ) + return self.request(cast_to, opts) + + def put( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + content: BinaryTypes | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct( + method="put", url=path, json_data=body, content=content, files=to_httpx_files(files), **options + ) + return self.request(cast_to, opts) + + def delete( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + content: BinaryTypes | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options) + return self.request(cast_to, opts) + + def get_api_list( + self, + path: str, + *, + model: Type[object], + page: Type[SyncPageT], + body: Body | None = None, + options: RequestOptions = {}, + method: str = "get", + ) -> SyncPageT: + opts = FinalRequestOptions.construct(method=method, url=path, json_data=body, **options) + return self._request_api_list(model, page, opts) + + +class _DefaultAsyncHttpxClient(httpx.AsyncClient): + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("timeout", DEFAULT_TIMEOUT) + kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) + kwargs.setdefault("follow_redirects", True) + super().__init__(**kwargs) + + +try: + import httpx_aiohttp +except ImportError: + + class _DefaultAioHttpClient(httpx.AsyncClient): + def __init__(self, **_kwargs: Any) -> None: + raise RuntimeError("To use the aiohttp client you must have installed the package with the `aiohttp` extra") +else: + + class _DefaultAioHttpClient(httpx_aiohttp.HttpxAiohttpClient): # type: ignore + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("timeout", DEFAULT_TIMEOUT) + kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) + kwargs.setdefault("follow_redirects", True) + + super().__init__(**kwargs) + + +if TYPE_CHECKING: + DefaultAsyncHttpxClient = httpx.AsyncClient + """An alias to `httpx.AsyncClient` that provides the same defaults that this SDK + uses internally. + + This is useful because overriding the `http_client` with your own instance of + `httpx.AsyncClient` will result in httpx's defaults being used, not ours. + """ + + DefaultAioHttpClient = httpx.AsyncClient + """An alias to `httpx.AsyncClient` that changes the default HTTP transport to `aiohttp`.""" +else: + DefaultAsyncHttpxClient = _DefaultAsyncHttpxClient + DefaultAioHttpClient = _DefaultAioHttpClient + + +class AsyncHttpxClientWrapper(DefaultAsyncHttpxClient): + def __del__(self) -> None: + if self.is_closed: + return + + try: + # TODO(someday): support non asyncio runtimes here + asyncio.get_running_loop().create_task(self.aclose()) + except Exception: + pass + + +class AsyncAPIClient(BaseClient[httpx.AsyncClient, AsyncStream[Any]]): + _client: httpx.AsyncClient + _default_stream_cls: type[AsyncStream[Any]] | None = None + + def __init__( + self, + *, + version: str, + base_url: str | URL, + _strict_response_validation: bool, + max_retries: int = DEFAULT_MAX_RETRIES, + timeout: float | Timeout | None | NotGiven = not_given, + http_client: httpx.AsyncClient | None = None, + custom_headers: Mapping[str, str] | None = None, + custom_query: Mapping[str, object] | None = None, + ) -> None: + if not is_given(timeout): + # if the user passed in a custom http client with a non-default + # timeout set then we use that timeout. + # + # note: there is an edge case here where the user passes in a client + # where they've explicitly set the timeout to match the default timeout + # as this check is structural, meaning that we'll think they didn't + # pass in a timeout and will ignore it + if http_client and http_client.timeout != HTTPX_DEFAULT_TIMEOUT: + timeout = http_client.timeout + else: + timeout = DEFAULT_TIMEOUT + + if http_client is not None and not isinstance(http_client, httpx.AsyncClient): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError( + f"Invalid `http_client` argument; Expected an instance of `httpx.AsyncClient` but got {type(http_client)}" + ) + + super().__init__( + version=version, + base_url=base_url, + # cast to a valid type because mypy doesn't understand our type narrowing + timeout=cast(Timeout, timeout), + max_retries=max_retries, + custom_query=custom_query, + custom_headers=custom_headers, + _strict_response_validation=_strict_response_validation, + ) + self._client = http_client or AsyncHttpxClientWrapper( + base_url=base_url, + # cast to a valid type because mypy doesn't understand our type narrowing + timeout=cast(Timeout, timeout), + ) + + def is_closed(self) -> bool: + return self._client.is_closed + + async def close(self) -> None: + """Close the underlying HTTPX client. + + The client will *not* be usable after this. + """ + await self._client.aclose() + + async def __aenter__(self: _T) -> _T: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self.close() + + async def _prepare_options( + self, + options: FinalRequestOptions, # noqa: ARG002 + ) -> FinalRequestOptions: + """Hook for mutating the given options""" + return options + + async def _prepare_request( + self, + request: httpx.Request, # noqa: ARG002 + ) -> None: + """This method is used as a callback for mutating the `Request` object + after it has been constructed. + This is useful for cases where you want to add certain headers based off of + the request properties, e.g. `url`, `method` etc. + """ + return None + + @overload + async def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + async def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: Literal[True], + stream_cls: type[_AsyncStreamT], + ) -> _AsyncStreamT: ... + + @overload + async def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: bool, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: ... + + async def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + *, + stream: bool = False, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: + if self._platform is None: + # `get_platform` can make blocking IO calls so we + # execute it earlier while we are in an async context + self._platform = await asyncify(get_platform)() + + cast_to = self._maybe_override_cast_to(cast_to, options) + + # create a copy of the options we were given so that if the + # options are mutated later & we then retry, the retries are + # given the original options + input_options = model_copy(options) + if input_options.idempotency_key is None and input_options.method.lower() != "get": + # ensure the idempotency key is reused between requests + input_options.idempotency_key = self._idempotency_key() + + response: httpx.Response | None = None + max_retries = input_options.get_max_retries(self.max_retries) + + retries_taken = 0 + for retries_taken in range(max_retries + 1): + options = model_copy(input_options) + options = await self._prepare_options(options) + + remaining_retries = max_retries - retries_taken + request = self._build_request(options, retries_taken=retries_taken) + await self._prepare_request(request) + + kwargs: HttpxSendArgs = {} + if self.custom_auth is not None: + kwargs["auth"] = self.custom_auth + + if options.follow_redirects is not None: + kwargs["follow_redirects"] = options.follow_redirects + + log.debug("Sending HTTP Request: %s %s", request.method, request.url) + + response = None + try: + response = await self._client.send( + request, + stream=stream or self._should_stream_response_body(request=request), + **kwargs, + ) + except httpx.TimeoutException as err: + log.debug("Encountered httpx.TimeoutException", exc_info=True) + + if remaining_retries > 0: + await self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising timeout error") + raise APITimeoutError(request=request) from err + except Exception as err: + log.debug("Encountered Exception", exc_info=True) + + if remaining_retries > 0: + await self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising connection error") + raise APIConnectionError(request=request) from err + + log.debug( + 'HTTP Response: %s %s "%i %s" %s', + request.method, + request.url, + response.status_code, + response.reason_phrase, + response.headers, + ) + + try: + response.raise_for_status() + except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code + log.debug("Encountered httpx.HTTPStatusError", exc_info=True) + + if remaining_retries > 0 and self._should_retry(err.response): + await err.response.aclose() + await self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=response, + ) + continue + + # If the response is streamed then we need to explicitly read the response + # to completion before attempting to access the response text. + if not err.response.is_closed: + await err.response.aread() + + log.debug("Re-raising status error") + raise self._make_status_error_from_response(err.response) from None + + break + + assert response is not None, "could not resolve response (should never happen)" + return await self._process_response( + cast_to=cast_to, + options=options, + response=response, + stream=stream, + stream_cls=stream_cls, + retries_taken=retries_taken, + ) + + async def _sleep_for_retry( + self, *, retries_taken: int, max_retries: int, options: FinalRequestOptions, response: httpx.Response | None + ) -> None: + remaining_retries = max_retries - retries_taken + if remaining_retries == 1: + log.debug("1 retry left") + else: + log.debug("%i retries left", remaining_retries) + + timeout = self._calculate_retry_timeout(remaining_retries, options, response.headers if response else None) + log.info("Retrying request to %s in %f seconds", options.url, timeout) + + await anyio.sleep(timeout) + + async def _process_response( + self, + *, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + response: httpx.Response, + stream: bool, + stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None, + retries_taken: int = 0, + ) -> ResponseT: + origin = get_origin(cast_to) or cast_to + + if ( + inspect.isclass(origin) + and issubclass(origin, BaseAPIResponse) + # we only want to actually return the custom BaseAPIResponse class if we're + # returning the raw response, or if we're not streaming SSE, as if we're streaming + # SSE then `cast_to` doesn't actively reflect the type we need to parse into + and (not stream or bool(response.request.headers.get(RAW_RESPONSE_HEADER))) + ): + if not issubclass(origin, AsyncAPIResponse): + raise TypeError(f"API Response types must subclass {AsyncAPIResponse}; Received {origin}") + + response_cls = cast("type[BaseAPIResponse[Any]]", cast_to) + return cast( + "ResponseT", + response_cls( + raw=response, + client=self, + cast_to=extract_response_type(response_cls), + stream=stream, + stream_cls=stream_cls, + options=options, + retries_taken=retries_taken, + ), + ) + + if cast_to == httpx.Response: + return cast(ResponseT, response) + + api_response = AsyncAPIResponse( + raw=response, + client=self, + cast_to=cast("type[ResponseT]", cast_to), # pyright: ignore[reportUnnecessaryCast] + stream=stream, + stream_cls=stream_cls, + options=options, + retries_taken=retries_taken, + ) + if bool(response.request.headers.get(RAW_RESPONSE_HEADER)): + return cast(ResponseT, api_response) + + return await api_response.parse() + + def _request_api_list( + self, + model: Type[_T], + page: Type[AsyncPageT], + options: FinalRequestOptions, + ) -> AsyncPaginator[_T, AsyncPageT]: + return AsyncPaginator(client=self, options=options, page_cls=page, model=model) + + @overload + async def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + async def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: Literal[True], + stream_cls: type[_AsyncStreamT], + ) -> _AsyncStreamT: ... + + @overload + async def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: bool, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: ... + + async def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: bool = False, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: + opts = FinalRequestOptions.construct(method="get", url=path, **options) + return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls) + + @overload + async def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + content: AsyncBinaryTypes | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + stream: Literal[False] = False, + ) -> ResponseT: ... + + @overload + async def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + content: AsyncBinaryTypes | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + stream: Literal[True], + stream_cls: type[_AsyncStreamT], + ) -> _AsyncStreamT: ... + + @overload + async def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + content: AsyncBinaryTypes | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + stream: bool, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: ... + + async def post( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + content: AsyncBinaryTypes | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + stream: bool = False, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct( + method="post", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options + ) + return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls) + + async def patch( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + content: AsyncBinaryTypes | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct( + method="patch", + url=path, + json_data=body, + content=content, + files=await async_to_httpx_files(files), + **options, + ) + return await self.request(cast_to, opts) + + async def put( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + content: AsyncBinaryTypes | None = None, + files: RequestFiles | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct( + method="put", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options + ) + return await self.request(cast_to, opts) + + async def delete( + self, + path: str, + *, + cast_to: Type[ResponseT], + body: Body | None = None, + content: AsyncBinaryTypes | None = None, + options: RequestOptions = {}, + ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options) + return await self.request(cast_to, opts) + + def get_api_list( + self, + path: str, + *, + model: Type[_T], + page: Type[AsyncPageT], + body: Body | None = None, + options: RequestOptions = {}, + method: str = "get", + ) -> AsyncPaginator[_T, AsyncPageT]: + opts = FinalRequestOptions.construct(method=method, url=path, json_data=body, **options) + return self._request_api_list(model, page, opts) + + +def make_request_options( + *, + query: Query | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + idempotency_key: str | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + post_parser: PostParser | NotGiven = not_given, +) -> RequestOptions: + """Create a dict of type RequestOptions without keys of NotGiven values.""" + options: RequestOptions = {} + if extra_headers is not None: + options["headers"] = extra_headers + + if extra_body is not None: + options["extra_json"] = cast(AnyMapping, extra_body) + + if query is not None: + options["params"] = query + + if extra_query is not None: + options["params"] = {**options.get("params", {}), **extra_query} + + if not isinstance(timeout, NotGiven): + options["timeout"] = timeout + + if idempotency_key is not None: + options["idempotency_key"] = idempotency_key + + if is_given(post_parser): + # internal + options["post_parser"] = post_parser # type: ignore + + return options + + +class ForceMultipartDict(Dict[str, None]): + def __bool__(self) -> bool: + return True + + +class OtherPlatform: + def __init__(self, name: str) -> None: + self.name = name + + @override + def __str__(self) -> str: + return f"Other:{self.name}" + + +Platform = Union[ + OtherPlatform, + Literal[ + "MacOS", + "Linux", + "Windows", + "FreeBSD", + "OpenBSD", + "iOS", + "Android", + "Unknown", + ], +] + + +def get_platform() -> Platform: + try: + system = platform.system().lower() + platform_name = platform.platform().lower() + except Exception: + return "Unknown" + + if "iphone" in platform_name or "ipad" in platform_name: + # Tested using Python3IDE on an iPhone 11 and Pythonista on an iPad 7 + # system is Darwin and platform_name is a string like: + # - Darwin-21.6.0-iPhone12,1-64bit + # - Darwin-21.6.0-iPad7,11-64bit + return "iOS" + + if system == "darwin": + return "MacOS" + + if system == "windows": + return "Windows" + + if "android" in platform_name: + # Tested using Pydroid 3 + # system is Linux and platform_name is a string like 'Linux-5.10.81-android12-9-00001-geba40aecb3b7-ab8534902-aarch64-with-libc' + return "Android" + + if system == "linux": + # https://distro.readthedocs.io/en/latest/#distro.id + distro_id = distro.id() + if distro_id == "freebsd": + return "FreeBSD" + + if distro_id == "openbsd": + return "OpenBSD" + + return "Linux" + + if platform_name: + return OtherPlatform(platform_name) + + return "Unknown" + + +@lru_cache(maxsize=None) +def platform_headers(version: str, *, platform: Platform | None) -> Dict[str, str]: + return { + "X-Stainless-Lang": "python", + "X-Stainless-Package-Version": version, + "X-Stainless-OS": str(platform or get_platform()), + "X-Stainless-Arch": str(get_architecture()), + "X-Stainless-Runtime": get_python_runtime(), + "X-Stainless-Runtime-Version": get_python_version(), + } + + +class OtherArch: + def __init__(self, name: str) -> None: + self.name = name + + @override + def __str__(self) -> str: + return f"other:{self.name}" + + +Arch = Union[OtherArch, Literal["x32", "x64", "arm", "arm64", "unknown"]] + + +def get_python_runtime() -> str: + try: + return platform.python_implementation() + except Exception: + return "unknown" + + +def get_python_version() -> str: + try: + return platform.python_version() + except Exception: + return "unknown" + + +def get_architecture() -> Arch: + try: + machine = platform.machine().lower() + except Exception: + return "unknown" + + if machine in ("arm64", "aarch64"): + return "arm64" + + # TODO: untested + if machine == "arm": + return "arm" + + if machine == "x86_64": + return "x64" + + # TODO: untested + if sys.maxsize <= 2**32: + return "x32" + + if machine: + return OtherArch(machine) + + return "unknown" + + +def _merge_mappings( + obj1: Mapping[_T_co, Union[_T, Omit]], + obj2: Mapping[_T_co, Union[_T, Omit]], +) -> Dict[_T_co, _T]: + """Merge two mappings of the same type, removing any values that are instances of `Omit`. + + In cases with duplicate keys the second mapping takes precedence. + """ + merged = {**obj1, **obj2} + return {key: value for key, value in merged.items() if not isinstance(value, Omit)} diff --git a/src/agentex/_client.py b/src/agentex/_client.py new file mode 100644 index 000000000..b52ae6b78 --- /dev/null +++ b/src/agentex/_client.py @@ -0,0 +1,871 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, Any, Dict, Mapping, cast +from typing_extensions import Self, Literal, override + +import httpx + +from . import _exceptions +from ._qs import Querystring +from ._types import ( + Omit, + Timeout, + NotGiven, + Transport, + ProxiesTypes, + RequestOptions, + not_given, +) +from ._utils import ( + is_given, + is_mapping_t, + get_async_library, +) +from ._compat import cached_property +from ._version import __version__ +from ._streaming import Stream as Stream, AsyncStream as AsyncStream +from ._exceptions import APIStatusError +from ._base_client import ( + DEFAULT_MAX_RETRIES, + SyncAPIClient, + AsyncAPIClient, +) + +if TYPE_CHECKING: + from .resources import ( + spans, + tasks, + agents, + events, + states, + tracker, + messages, + webhooks, + checkpoints, + deployment_history, + ) + from .resources.spans import SpansResource, AsyncSpansResource + from .resources.tasks import TasksResource, AsyncTasksResource + from .resources.events import EventsResource, AsyncEventsResource + from .resources.states import StatesResource, AsyncStatesResource + from .resources.tracker import TrackerResource, AsyncTrackerResource + from .resources.webhooks import WebhooksResource, AsyncWebhooksResource + from .resources.checkpoints import CheckpointsResource, AsyncCheckpointsResource + from .resources.agents.agents import AgentsResource, AsyncAgentsResource + from .resources.messages.messages import MessagesResource, AsyncMessagesResource + from .resources.deployment_history import DeploymentHistoryResource, AsyncDeploymentHistoryResource + +__all__ = [ + "ENVIRONMENTS", + "Timeout", + "Transport", + "ProxiesTypes", + "RequestOptions", + "Agentex", + "AsyncAgentex", + "Client", + "AsyncClient", +] + +ENVIRONMENTS: Dict[str, str] = { + "production": "http://localhost:5003", + "development": "http://localhost:5003", +} + + +class Agentex(SyncAPIClient): + # client options + api_key: str | None + + _environment: Literal["production", "development"] | NotGiven + + def __init__( + self, + *, + api_key: str | None = None, + environment: Literal["production", "development"] | NotGiven = not_given, + base_url: str | httpx.URL | None | NotGiven = not_given, + timeout: float | Timeout | None | NotGiven = not_given, + max_retries: int = DEFAULT_MAX_RETRIES, + default_headers: Mapping[str, str] | None = None, + default_query: Mapping[str, object] | None = None, + # Configure a custom httpx client. + # We provide a `DefaultHttpxClient` class that you can pass to retain the default values we use for `limits`, `timeout` & `follow_redirects`. + # See the [httpx documentation](https://www.python-httpx.org/api/#client) for more details. + http_client: httpx.Client | None = None, + # Enable or disable schema validation for data returned by the API. + # When enabled an error APIResponseValidationError is raised + # if the API responds with invalid data for the expected schema. + # + # This parameter may be removed or changed in the future. + # If you rely on this feature, please open a GitHub issue + # outlining your use-case to help us decide if it should be + # part of our public interface in the future. + _strict_response_validation: bool = False, + ) -> None: + """Construct a new synchronous Agentex client instance. + + This automatically infers the `api_key` argument from the `AGENTEX_SDK_API_KEY` environment variable if it is not provided. + """ + if api_key is None: + api_key = os.environ.get("AGENTEX_SDK_API_KEY") + self.api_key = api_key + + self._environment = environment + + base_url_env = os.environ.get("AGENTEX_BASE_URL") + if is_given(base_url) and base_url is not None: + # cast required because mypy doesn't understand the type narrowing + base_url = cast("str | httpx.URL", base_url) # pyright: ignore[reportUnnecessaryCast] + elif is_given(environment): + if base_url_env and base_url is not None: + raise ValueError( + "Ambiguous URL; The `AGENTEX_BASE_URL` env var and the `environment` argument are given. If you want to use the environment, you must pass base_url=None", + ) + + try: + base_url = ENVIRONMENTS[environment] + except KeyError as exc: + raise ValueError(f"Unknown environment: {environment}") from exc + elif base_url_env is not None: + base_url = base_url_env + else: + self._environment = environment = "production" + + try: + base_url = ENVIRONMENTS[environment] + except KeyError as exc: + raise ValueError(f"Unknown environment: {environment}") from exc + + custom_headers_env = os.environ.get("AGENTEX_CUSTOM_HEADERS") + if custom_headers_env is not None: + parsed: dict[str, str] = {} + for line in custom_headers_env.split("\n"): + colon = line.find(":") + if colon >= 0: + parsed[line[:colon].strip()] = line[colon + 1 :].strip() + default_headers = {**parsed, **(default_headers if is_mapping_t(default_headers) else {})} + + super().__init__( + version=__version__, + base_url=base_url, + max_retries=max_retries, + timeout=timeout, + http_client=http_client, + custom_headers=default_headers, + custom_query=default_query, + _strict_response_validation=_strict_response_validation, + ) + + @cached_property + def agents(self) -> AgentsResource: + from .resources.agents import AgentsResource + + return AgentsResource(self) + + @cached_property + def tasks(self) -> TasksResource: + from .resources.tasks import TasksResource + + return TasksResource(self) + + @cached_property + def messages(self) -> MessagesResource: + from .resources.messages import MessagesResource + + return MessagesResource(self) + + @cached_property + def spans(self) -> SpansResource: + from .resources.spans import SpansResource + + return SpansResource(self) + + @cached_property + def states(self) -> StatesResource: + from .resources.states import StatesResource + + return StatesResource(self) + + @cached_property + def events(self) -> EventsResource: + from .resources.events import EventsResource + + return EventsResource(self) + + @cached_property + def tracker(self) -> TrackerResource: + from .resources.tracker import TrackerResource + + return TrackerResource(self) + + @cached_property + def deployment_history(self) -> DeploymentHistoryResource: + from .resources.deployment_history import DeploymentHistoryResource + + return DeploymentHistoryResource(self) + + @cached_property + def checkpoints(self) -> CheckpointsResource: + from .resources.checkpoints import CheckpointsResource + + return CheckpointsResource(self) + + @cached_property + def webhooks(self) -> WebhooksResource: + from .resources.webhooks import WebhooksResource + + return WebhooksResource(self) + + @cached_property + def with_raw_response(self) -> AgentexWithRawResponse: + return AgentexWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AgentexWithStreamedResponse: + return AgentexWithStreamedResponse(self) + + @property + @override + def qs(self) -> Querystring: + return Querystring(array_format="comma") + + @property + @override + def auth_headers(self) -> dict[str, str]: + api_key = self.api_key + if api_key is None: + return {} + return {"Authorization": f"Bearer {api_key}"} + + @property + @override + def default_headers(self) -> dict[str, str | Omit]: + return { + **super().default_headers, + "X-Stainless-Async": "false", + **self._custom_headers, + } + + def copy( + self, + *, + api_key: str | None = None, + environment: Literal["production", "development"] | None = None, + base_url: str | httpx.URL | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + http_client: httpx.Client | None = None, + max_retries: int | NotGiven = not_given, + default_headers: Mapping[str, str] | None = None, + set_default_headers: Mapping[str, str] | None = None, + default_query: Mapping[str, object] | None = None, + set_default_query: Mapping[str, object] | None = None, + _extra_kwargs: Mapping[str, Any] = {}, + ) -> Self: + """ + Create a new client instance re-using the same options given to the current client with optional overriding. + """ + if default_headers is not None and set_default_headers is not None: + raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive") + + if default_query is not None and set_default_query is not None: + raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive") + + headers = self._custom_headers + if default_headers is not None: + headers = {**headers, **default_headers} + elif set_default_headers is not None: + headers = set_default_headers + + params = self._custom_query + if default_query is not None: + params = {**params, **default_query} + elif set_default_query is not None: + params = set_default_query + + http_client = http_client or self._client + return self.__class__( + api_key=api_key or self.api_key, + base_url=base_url or self.base_url, + environment=environment or self._environment, + timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, + http_client=http_client, + max_retries=max_retries if is_given(max_retries) else self.max_retries, + default_headers=headers, + default_query=params, + **_extra_kwargs, + ) + + # Alias for `copy` for nicer inline usage, e.g. + # client.with_options(timeout=10).foo.create(...) + with_options = copy + + @override + def _make_status_error( + self, + err_msg: str, + *, + body: object, + response: httpx.Response, + ) -> APIStatusError: + if response.status_code == 400: + return _exceptions.BadRequestError(err_msg, response=response, body=body) + + if response.status_code == 401: + return _exceptions.AuthenticationError(err_msg, response=response, body=body) + + if response.status_code == 403: + return _exceptions.PermissionDeniedError(err_msg, response=response, body=body) + + if response.status_code == 404: + return _exceptions.NotFoundError(err_msg, response=response, body=body) + + if response.status_code == 409: + return _exceptions.ConflictError(err_msg, response=response, body=body) + + if response.status_code == 422: + return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body) + + if response.status_code == 429: + return _exceptions.RateLimitError(err_msg, response=response, body=body) + + if response.status_code >= 500: + return _exceptions.InternalServerError(err_msg, response=response, body=body) + return APIStatusError(err_msg, response=response, body=body) + + +class AsyncAgentex(AsyncAPIClient): + # client options + api_key: str | None + + _environment: Literal["production", "development"] | NotGiven + + def __init__( + self, + *, + api_key: str | None = None, + environment: Literal["production", "development"] | NotGiven = not_given, + base_url: str | httpx.URL | None | NotGiven = not_given, + timeout: float | Timeout | None | NotGiven = not_given, + max_retries: int = DEFAULT_MAX_RETRIES, + default_headers: Mapping[str, str] | None = None, + default_query: Mapping[str, object] | None = None, + # Configure a custom httpx client. + # We provide a `DefaultAsyncHttpxClient` class that you can pass to retain the default values we use for `limits`, `timeout` & `follow_redirects`. + # See the [httpx documentation](https://www.python-httpx.org/api/#asyncclient) for more details. + http_client: httpx.AsyncClient | None = None, + # Enable or disable schema validation for data returned by the API. + # When enabled an error APIResponseValidationError is raised + # if the API responds with invalid data for the expected schema. + # + # This parameter may be removed or changed in the future. + # If you rely on this feature, please open a GitHub issue + # outlining your use-case to help us decide if it should be + # part of our public interface in the future. + _strict_response_validation: bool = False, + ) -> None: + """Construct a new async AsyncAgentex client instance. + + This automatically infers the `api_key` argument from the `AGENTEX_SDK_API_KEY` environment variable if it is not provided. + """ + if api_key is None: + api_key = os.environ.get("AGENTEX_SDK_API_KEY") + self.api_key = api_key + + self._environment = environment + + base_url_env = os.environ.get("AGENTEX_BASE_URL") + if is_given(base_url) and base_url is not None: + # cast required because mypy doesn't understand the type narrowing + base_url = cast("str | httpx.URL", base_url) # pyright: ignore[reportUnnecessaryCast] + elif is_given(environment): + if base_url_env and base_url is not None: + raise ValueError( + "Ambiguous URL; The `AGENTEX_BASE_URL` env var and the `environment` argument are given. If you want to use the environment, you must pass base_url=None", + ) + + try: + base_url = ENVIRONMENTS[environment] + except KeyError as exc: + raise ValueError(f"Unknown environment: {environment}") from exc + elif base_url_env is not None: + base_url = base_url_env + else: + self._environment = environment = "production" + + try: + base_url = ENVIRONMENTS[environment] + except KeyError as exc: + raise ValueError(f"Unknown environment: {environment}") from exc + + custom_headers_env = os.environ.get("AGENTEX_CUSTOM_HEADERS") + if custom_headers_env is not None: + parsed: dict[str, str] = {} + for line in custom_headers_env.split("\n"): + colon = line.find(":") + if colon >= 0: + parsed[line[:colon].strip()] = line[colon + 1 :].strip() + default_headers = {**parsed, **(default_headers if is_mapping_t(default_headers) else {})} + + super().__init__( + version=__version__, + base_url=base_url, + max_retries=max_retries, + timeout=timeout, + http_client=http_client, + custom_headers=default_headers, + custom_query=default_query, + _strict_response_validation=_strict_response_validation, + ) + + @cached_property + def agents(self) -> AsyncAgentsResource: + from .resources.agents import AsyncAgentsResource + + return AsyncAgentsResource(self) + + @cached_property + def tasks(self) -> AsyncTasksResource: + from .resources.tasks import AsyncTasksResource + + return AsyncTasksResource(self) + + @cached_property + def messages(self) -> AsyncMessagesResource: + from .resources.messages import AsyncMessagesResource + + return AsyncMessagesResource(self) + + @cached_property + def spans(self) -> AsyncSpansResource: + from .resources.spans import AsyncSpansResource + + return AsyncSpansResource(self) + + @cached_property + def states(self) -> AsyncStatesResource: + from .resources.states import AsyncStatesResource + + return AsyncStatesResource(self) + + @cached_property + def events(self) -> AsyncEventsResource: + from .resources.events import AsyncEventsResource + + return AsyncEventsResource(self) + + @cached_property + def tracker(self) -> AsyncTrackerResource: + from .resources.tracker import AsyncTrackerResource + + return AsyncTrackerResource(self) + + @cached_property + def deployment_history(self) -> AsyncDeploymentHistoryResource: + from .resources.deployment_history import AsyncDeploymentHistoryResource + + return AsyncDeploymentHistoryResource(self) + + @cached_property + def checkpoints(self) -> AsyncCheckpointsResource: + from .resources.checkpoints import AsyncCheckpointsResource + + return AsyncCheckpointsResource(self) + + @cached_property + def webhooks(self) -> AsyncWebhooksResource: + from .resources.webhooks import AsyncWebhooksResource + + return AsyncWebhooksResource(self) + + @cached_property + def with_raw_response(self) -> AsyncAgentexWithRawResponse: + return AsyncAgentexWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncAgentexWithStreamedResponse: + return AsyncAgentexWithStreamedResponse(self) + + @property + @override + def qs(self) -> Querystring: + return Querystring(array_format="comma") + + @property + @override + def auth_headers(self) -> dict[str, str]: + api_key = self.api_key + if api_key is None: + return {} + return {"Authorization": f"Bearer {api_key}"} + + @property + @override + def default_headers(self) -> dict[str, str | Omit]: + return { + **super().default_headers, + "X-Stainless-Async": f"async:{get_async_library()}", + **self._custom_headers, + } + + def copy( + self, + *, + api_key: str | None = None, + environment: Literal["production", "development"] | None = None, + base_url: str | httpx.URL | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + http_client: httpx.AsyncClient | None = None, + max_retries: int | NotGiven = not_given, + default_headers: Mapping[str, str] | None = None, + set_default_headers: Mapping[str, str] | None = None, + default_query: Mapping[str, object] | None = None, + set_default_query: Mapping[str, object] | None = None, + _extra_kwargs: Mapping[str, Any] = {}, + ) -> Self: + """ + Create a new client instance re-using the same options given to the current client with optional overriding. + """ + if default_headers is not None and set_default_headers is not None: + raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive") + + if default_query is not None and set_default_query is not None: + raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive") + + headers = self._custom_headers + if default_headers is not None: + headers = {**headers, **default_headers} + elif set_default_headers is not None: + headers = set_default_headers + + params = self._custom_query + if default_query is not None: + params = {**params, **default_query} + elif set_default_query is not None: + params = set_default_query + + http_client = http_client or self._client + return self.__class__( + api_key=api_key or self.api_key, + base_url=base_url or self.base_url, + environment=environment or self._environment, + timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, + http_client=http_client, + max_retries=max_retries if is_given(max_retries) else self.max_retries, + default_headers=headers, + default_query=params, + **_extra_kwargs, + ) + + # Alias for `copy` for nicer inline usage, e.g. + # client.with_options(timeout=10).foo.create(...) + with_options = copy + + @override + def _make_status_error( + self, + err_msg: str, + *, + body: object, + response: httpx.Response, + ) -> APIStatusError: + if response.status_code == 400: + return _exceptions.BadRequestError(err_msg, response=response, body=body) + + if response.status_code == 401: + return _exceptions.AuthenticationError(err_msg, response=response, body=body) + + if response.status_code == 403: + return _exceptions.PermissionDeniedError(err_msg, response=response, body=body) + + if response.status_code == 404: + return _exceptions.NotFoundError(err_msg, response=response, body=body) + + if response.status_code == 409: + return _exceptions.ConflictError(err_msg, response=response, body=body) + + if response.status_code == 422: + return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body) + + if response.status_code == 429: + return _exceptions.RateLimitError(err_msg, response=response, body=body) + + if response.status_code >= 500: + return _exceptions.InternalServerError(err_msg, response=response, body=body) + return APIStatusError(err_msg, response=response, body=body) + + +class AgentexWithRawResponse: + _client: Agentex + + def __init__(self, client: Agentex) -> None: + self._client = client + + @cached_property + def agents(self) -> agents.AgentsResourceWithRawResponse: + from .resources.agents import AgentsResourceWithRawResponse + + return AgentsResourceWithRawResponse(self._client.agents) + + @cached_property + def tasks(self) -> tasks.TasksResourceWithRawResponse: + from .resources.tasks import TasksResourceWithRawResponse + + return TasksResourceWithRawResponse(self._client.tasks) + + @cached_property + def messages(self) -> messages.MessagesResourceWithRawResponse: + from .resources.messages import MessagesResourceWithRawResponse + + return MessagesResourceWithRawResponse(self._client.messages) + + @cached_property + def spans(self) -> spans.SpansResourceWithRawResponse: + from .resources.spans import SpansResourceWithRawResponse + + return SpansResourceWithRawResponse(self._client.spans) + + @cached_property + def states(self) -> states.StatesResourceWithRawResponse: + from .resources.states import StatesResourceWithRawResponse + + return StatesResourceWithRawResponse(self._client.states) + + @cached_property + def events(self) -> events.EventsResourceWithRawResponse: + from .resources.events import EventsResourceWithRawResponse + + return EventsResourceWithRawResponse(self._client.events) + + @cached_property + def tracker(self) -> tracker.TrackerResourceWithRawResponse: + from .resources.tracker import TrackerResourceWithRawResponse + + return TrackerResourceWithRawResponse(self._client.tracker) + + @cached_property + def deployment_history(self) -> deployment_history.DeploymentHistoryResourceWithRawResponse: + from .resources.deployment_history import DeploymentHistoryResourceWithRawResponse + + return DeploymentHistoryResourceWithRawResponse(self._client.deployment_history) + + @cached_property + def checkpoints(self) -> checkpoints.CheckpointsResourceWithRawResponse: + from .resources.checkpoints import CheckpointsResourceWithRawResponse + + return CheckpointsResourceWithRawResponse(self._client.checkpoints) + + @cached_property + def webhooks(self) -> webhooks.WebhooksResourceWithRawResponse: + from .resources.webhooks import WebhooksResourceWithRawResponse + + return WebhooksResourceWithRawResponse(self._client.webhooks) + + +class AsyncAgentexWithRawResponse: + _client: AsyncAgentex + + def __init__(self, client: AsyncAgentex) -> None: + self._client = client + + @cached_property + def agents(self) -> agents.AsyncAgentsResourceWithRawResponse: + from .resources.agents import AsyncAgentsResourceWithRawResponse + + return AsyncAgentsResourceWithRawResponse(self._client.agents) + + @cached_property + def tasks(self) -> tasks.AsyncTasksResourceWithRawResponse: + from .resources.tasks import AsyncTasksResourceWithRawResponse + + return AsyncTasksResourceWithRawResponse(self._client.tasks) + + @cached_property + def messages(self) -> messages.AsyncMessagesResourceWithRawResponse: + from .resources.messages import AsyncMessagesResourceWithRawResponse + + return AsyncMessagesResourceWithRawResponse(self._client.messages) + + @cached_property + def spans(self) -> spans.AsyncSpansResourceWithRawResponse: + from .resources.spans import AsyncSpansResourceWithRawResponse + + return AsyncSpansResourceWithRawResponse(self._client.spans) + + @cached_property + def states(self) -> states.AsyncStatesResourceWithRawResponse: + from .resources.states import AsyncStatesResourceWithRawResponse + + return AsyncStatesResourceWithRawResponse(self._client.states) + + @cached_property + def events(self) -> events.AsyncEventsResourceWithRawResponse: + from .resources.events import AsyncEventsResourceWithRawResponse + + return AsyncEventsResourceWithRawResponse(self._client.events) + + @cached_property + def tracker(self) -> tracker.AsyncTrackerResourceWithRawResponse: + from .resources.tracker import AsyncTrackerResourceWithRawResponse + + return AsyncTrackerResourceWithRawResponse(self._client.tracker) + + @cached_property + def deployment_history(self) -> deployment_history.AsyncDeploymentHistoryResourceWithRawResponse: + from .resources.deployment_history import AsyncDeploymentHistoryResourceWithRawResponse + + return AsyncDeploymentHistoryResourceWithRawResponse(self._client.deployment_history) + + @cached_property + def checkpoints(self) -> checkpoints.AsyncCheckpointsResourceWithRawResponse: + from .resources.checkpoints import AsyncCheckpointsResourceWithRawResponse + + return AsyncCheckpointsResourceWithRawResponse(self._client.checkpoints) + + @cached_property + def webhooks(self) -> webhooks.AsyncWebhooksResourceWithRawResponse: + from .resources.webhooks import AsyncWebhooksResourceWithRawResponse + + return AsyncWebhooksResourceWithRawResponse(self._client.webhooks) + + +class AgentexWithStreamedResponse: + _client: Agentex + + def __init__(self, client: Agentex) -> None: + self._client = client + + @cached_property + def agents(self) -> agents.AgentsResourceWithStreamingResponse: + from .resources.agents import AgentsResourceWithStreamingResponse + + return AgentsResourceWithStreamingResponse(self._client.agents) + + @cached_property + def tasks(self) -> tasks.TasksResourceWithStreamingResponse: + from .resources.tasks import TasksResourceWithStreamingResponse + + return TasksResourceWithStreamingResponse(self._client.tasks) + + @cached_property + def messages(self) -> messages.MessagesResourceWithStreamingResponse: + from .resources.messages import MessagesResourceWithStreamingResponse + + return MessagesResourceWithStreamingResponse(self._client.messages) + + @cached_property + def spans(self) -> spans.SpansResourceWithStreamingResponse: + from .resources.spans import SpansResourceWithStreamingResponse + + return SpansResourceWithStreamingResponse(self._client.spans) + + @cached_property + def states(self) -> states.StatesResourceWithStreamingResponse: + from .resources.states import StatesResourceWithStreamingResponse + + return StatesResourceWithStreamingResponse(self._client.states) + + @cached_property + def events(self) -> events.EventsResourceWithStreamingResponse: + from .resources.events import EventsResourceWithStreamingResponse + + return EventsResourceWithStreamingResponse(self._client.events) + + @cached_property + def tracker(self) -> tracker.TrackerResourceWithStreamingResponse: + from .resources.tracker import TrackerResourceWithStreamingResponse + + return TrackerResourceWithStreamingResponse(self._client.tracker) + + @cached_property + def deployment_history(self) -> deployment_history.DeploymentHistoryResourceWithStreamingResponse: + from .resources.deployment_history import DeploymentHistoryResourceWithStreamingResponse + + return DeploymentHistoryResourceWithStreamingResponse(self._client.deployment_history) + + @cached_property + def checkpoints(self) -> checkpoints.CheckpointsResourceWithStreamingResponse: + from .resources.checkpoints import CheckpointsResourceWithStreamingResponse + + return CheckpointsResourceWithStreamingResponse(self._client.checkpoints) + + @cached_property + def webhooks(self) -> webhooks.WebhooksResourceWithStreamingResponse: + from .resources.webhooks import WebhooksResourceWithStreamingResponse + + return WebhooksResourceWithStreamingResponse(self._client.webhooks) + + +class AsyncAgentexWithStreamedResponse: + _client: AsyncAgentex + + def __init__(self, client: AsyncAgentex) -> None: + self._client = client + + @cached_property + def agents(self) -> agents.AsyncAgentsResourceWithStreamingResponse: + from .resources.agents import AsyncAgentsResourceWithStreamingResponse + + return AsyncAgentsResourceWithStreamingResponse(self._client.agents) + + @cached_property + def tasks(self) -> tasks.AsyncTasksResourceWithStreamingResponse: + from .resources.tasks import AsyncTasksResourceWithStreamingResponse + + return AsyncTasksResourceWithStreamingResponse(self._client.tasks) + + @cached_property + def messages(self) -> messages.AsyncMessagesResourceWithStreamingResponse: + from .resources.messages import AsyncMessagesResourceWithStreamingResponse + + return AsyncMessagesResourceWithStreamingResponse(self._client.messages) + + @cached_property + def spans(self) -> spans.AsyncSpansResourceWithStreamingResponse: + from .resources.spans import AsyncSpansResourceWithStreamingResponse + + return AsyncSpansResourceWithStreamingResponse(self._client.spans) + + @cached_property + def states(self) -> states.AsyncStatesResourceWithStreamingResponse: + from .resources.states import AsyncStatesResourceWithStreamingResponse + + return AsyncStatesResourceWithStreamingResponse(self._client.states) + + @cached_property + def events(self) -> events.AsyncEventsResourceWithStreamingResponse: + from .resources.events import AsyncEventsResourceWithStreamingResponse + + return AsyncEventsResourceWithStreamingResponse(self._client.events) + + @cached_property + def tracker(self) -> tracker.AsyncTrackerResourceWithStreamingResponse: + from .resources.tracker import AsyncTrackerResourceWithStreamingResponse + + return AsyncTrackerResourceWithStreamingResponse(self._client.tracker) + + @cached_property + def deployment_history(self) -> deployment_history.AsyncDeploymentHistoryResourceWithStreamingResponse: + from .resources.deployment_history import AsyncDeploymentHistoryResourceWithStreamingResponse + + return AsyncDeploymentHistoryResourceWithStreamingResponse(self._client.deployment_history) + + @cached_property + def checkpoints(self) -> checkpoints.AsyncCheckpointsResourceWithStreamingResponse: + from .resources.checkpoints import AsyncCheckpointsResourceWithStreamingResponse + + return AsyncCheckpointsResourceWithStreamingResponse(self._client.checkpoints) + + @cached_property + def webhooks(self) -> webhooks.AsyncWebhooksResourceWithStreamingResponse: + from .resources.webhooks import AsyncWebhooksResourceWithStreamingResponse + + return AsyncWebhooksResourceWithStreamingResponse(self._client.webhooks) + + +Client = Agentex + +AsyncClient = AsyncAgentex diff --git a/src/agentex/_compat.py b/src/agentex/_compat.py new file mode 100644 index 000000000..e6690a4f2 --- /dev/null +++ b/src/agentex/_compat.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Union, Generic, TypeVar, Callable, cast, overload +from datetime import date, datetime +from typing_extensions import Self, Literal, TypedDict + +import pydantic +from pydantic.fields import FieldInfo + +from ._types import IncEx, StrBytesIntFloat + +_T = TypeVar("_T") +_ModelT = TypeVar("_ModelT", bound=pydantic.BaseModel) + +# --------------- Pydantic v2, v3 compatibility --------------- + +# Pyright incorrectly reports some of our functions as overriding a method when they don't +# pyright: reportIncompatibleMethodOverride=false + +PYDANTIC_V1 = pydantic.VERSION.startswith("1.") + +if TYPE_CHECKING: + + def parse_date(value: date | StrBytesIntFloat) -> date: # noqa: ARG001 + ... + + def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime: # noqa: ARG001 + ... + + def get_args(t: type[Any]) -> tuple[Any, ...]: # noqa: ARG001 + ... + + def is_union(tp: type[Any] | None) -> bool: # noqa: ARG001 + ... + + def get_origin(t: type[Any]) -> type[Any] | None: # noqa: ARG001 + ... + + def is_literal_type(type_: type[Any]) -> bool: # noqa: ARG001 + ... + + def is_typeddict(type_: type[Any]) -> bool: # noqa: ARG001 + ... + +else: + # v1 re-exports + if PYDANTIC_V1: + from pydantic.typing import ( + get_args as get_args, + is_union as is_union, + get_origin as get_origin, + is_typeddict as is_typeddict, + is_literal_type as is_literal_type, + ) + from pydantic.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime + else: + from ._utils import ( + get_args as get_args, + is_union as is_union, + get_origin as get_origin, + parse_date as parse_date, + is_typeddict as is_typeddict, + parse_datetime as parse_datetime, + is_literal_type as is_literal_type, + ) + + +# refactored config +if TYPE_CHECKING: + from pydantic import ConfigDict as ConfigDict +else: + if PYDANTIC_V1: + # TODO: provide an error message here? + ConfigDict = None + else: + from pydantic import ConfigDict as ConfigDict + + +# renamed methods / properties +def parse_obj(model: type[_ModelT], value: object) -> _ModelT: + if PYDANTIC_V1: + return cast(_ModelT, model.parse_obj(value)) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] + else: + return model.model_validate(value) + + +def field_is_required(field: FieldInfo) -> bool: + if PYDANTIC_V1: + return field.required # type: ignore + return field.is_required() + + +def field_get_default(field: FieldInfo) -> Any: + value = field.get_default() + if PYDANTIC_V1: + return value + from pydantic_core import PydanticUndefined + + if value == PydanticUndefined: + return None + return value + + +def field_outer_type(field: FieldInfo) -> Any: + if PYDANTIC_V1: + return field.outer_type_ # type: ignore + return field.annotation + + +def get_model_config(model: type[pydantic.BaseModel]) -> Any: + if PYDANTIC_V1: + return model.__config__ # type: ignore + return model.model_config + + +def get_model_fields(model: type[pydantic.BaseModel]) -> dict[str, FieldInfo]: + if PYDANTIC_V1: + return model.__fields__ # type: ignore + return model.model_fields + + +def model_copy(model: _ModelT, *, deep: bool = False) -> _ModelT: + if PYDANTIC_V1: + return model.copy(deep=deep) # type: ignore + return model.model_copy(deep=deep) + + +def model_json(model: pydantic.BaseModel, *, indent: int | None = None) -> str: + if PYDANTIC_V1: + return model.json(indent=indent) # type: ignore + return model.model_dump_json(indent=indent) + + +class _ModelDumpKwargs(TypedDict, total=False): + by_alias: bool + + +def model_dump( + model: pydantic.BaseModel, + *, + exclude: IncEx | None = None, + exclude_unset: bool = False, + exclude_defaults: bool = False, + warnings: bool = True, + mode: Literal["json", "python"] = "python", + by_alias: bool | None = None, +) -> dict[str, Any]: + if (not PYDANTIC_V1) or hasattr(model, "model_dump"): + kwargs: _ModelDumpKwargs = {} + if by_alias is not None: + kwargs["by_alias"] = by_alias + return model.model_dump( + mode=mode, + exclude=exclude, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + # warnings are not supported in Pydantic v1 + warnings=True if PYDANTIC_V1 else warnings, + **kwargs, + ) + return cast( + "dict[str, Any]", + model.dict( # pyright: ignore[reportDeprecated, reportUnnecessaryCast] + exclude=exclude, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, by_alias=bool(by_alias) + ), + ) + + +def model_parse(model: type[_ModelT], data: Any) -> _ModelT: + if PYDANTIC_V1: + return model.parse_obj(data) # pyright: ignore[reportDeprecated] + return model.model_validate(data) + + +# generic models +if TYPE_CHECKING: + + class GenericModel(pydantic.BaseModel): ... + +else: + if PYDANTIC_V1: + import pydantic.generics + + class GenericModel(pydantic.generics.GenericModel, pydantic.BaseModel): ... + else: + # there no longer needs to be a distinction in v2 but + # we still have to create our own subclass to avoid + # inconsistent MRO ordering errors + class GenericModel(pydantic.BaseModel): ... + + +# cached properties +if TYPE_CHECKING: + cached_property = property + + # we define a separate type (copied from typeshed) + # that represents that `cached_property` is `set`able + # at runtime, which differs from `@property`. + # + # this is a separate type as editors likely special case + # `@property` and we don't want to cause issues just to have + # more helpful internal types. + + class typed_cached_property(Generic[_T]): + func: Callable[[Any], _T] + attrname: str | None + + def __init__(self, func: Callable[[Any], _T]) -> None: ... + + @overload + def __get__(self, instance: None, owner: type[Any] | None = None) -> Self: ... + + @overload + def __get__(self, instance: object, owner: type[Any] | None = None) -> _T: ... + + def __get__(self, instance: object, owner: type[Any] | None = None) -> _T | Self: + raise NotImplementedError() + + def __set_name__(self, owner: type[Any], name: str) -> None: ... + + # __set__ is not defined at runtime, but @cached_property is designed to be settable + def __set__(self, instance: object, value: _T) -> None: ... +else: + from functools import cached_property as cached_property + + typed_cached_property = cached_property diff --git a/src/agentex/_constants.py b/src/agentex/_constants.py new file mode 100644 index 000000000..ccb3ec52f --- /dev/null +++ b/src/agentex/_constants.py @@ -0,0 +1,14 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import httpx + +RAW_RESPONSE_HEADER = "X-Stainless-Raw-Response" +OVERRIDE_CAST_TO_HEADER = "____stainless_override_cast_to" + +# default timeout is 1 minute +DEFAULT_TIMEOUT = httpx.Timeout(timeout=300, connect=5.0) +DEFAULT_MAX_RETRIES = 2 +DEFAULT_CONNECTION_LIMITS = httpx.Limits(max_connections=1000, max_keepalive_connections=1000) + +INITIAL_RETRY_DELAY = 0.5 +MAX_RETRY_DELAY = 8.0 diff --git a/src/agentex/_exceptions.py b/src/agentex/_exceptions.py new file mode 100644 index 000000000..8f26c82b6 --- /dev/null +++ b/src/agentex/_exceptions.py @@ -0,0 +1,108 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal + +import httpx + +__all__ = [ + "BadRequestError", + "AuthenticationError", + "PermissionDeniedError", + "NotFoundError", + "ConflictError", + "UnprocessableEntityError", + "RateLimitError", + "InternalServerError", +] + + +class AgentexError(Exception): + pass + + +class APIError(AgentexError): + message: str + request: httpx.Request + + body: object | None + """The API response body. + + If the API responded with a valid JSON structure then this property will be the + decoded result. + + If it isn't a valid JSON structure then this will be the raw response. + + If there was no response associated with this error then it will be `None`. + """ + + def __init__(self, message: str, request: httpx.Request, *, body: object | None) -> None: # noqa: ARG002 + super().__init__(message) + self.request = request + self.message = message + self.body = body + + +class APIResponseValidationError(APIError): + response: httpx.Response + status_code: int + + def __init__(self, response: httpx.Response, body: object | None, *, message: str | None = None) -> None: + super().__init__(message or "Data returned by API invalid for expected schema.", response.request, body=body) + self.response = response + self.status_code = response.status_code + + +class APIStatusError(APIError): + """Raised when an API response has a status code of 4xx or 5xx.""" + + response: httpx.Response + status_code: int + + def __init__(self, message: str, *, response: httpx.Response, body: object | None) -> None: + super().__init__(message, response.request, body=body) + self.response = response + self.status_code = response.status_code + + +class APIConnectionError(APIError): + def __init__(self, *, message: str = "Connection error.", request: httpx.Request) -> None: + super().__init__(message, request, body=None) + + +class APITimeoutError(APIConnectionError): + def __init__(self, request: httpx.Request) -> None: + super().__init__(message="Request timed out.", request=request) + + +class BadRequestError(APIStatusError): + status_code: Literal[400] = 400 # pyright: ignore[reportIncompatibleVariableOverride] + + +class AuthenticationError(APIStatusError): + status_code: Literal[401] = 401 # pyright: ignore[reportIncompatibleVariableOverride] + + +class PermissionDeniedError(APIStatusError): + status_code: Literal[403] = 403 # pyright: ignore[reportIncompatibleVariableOverride] + + +class NotFoundError(APIStatusError): + status_code: Literal[404] = 404 # pyright: ignore[reportIncompatibleVariableOverride] + + +class ConflictError(APIStatusError): + status_code: Literal[409] = 409 # pyright: ignore[reportIncompatibleVariableOverride] + + +class UnprocessableEntityError(APIStatusError): + status_code: Literal[422] = 422 # pyright: ignore[reportIncompatibleVariableOverride] + + +class RateLimitError(APIStatusError): + status_code: Literal[429] = 429 # pyright: ignore[reportIncompatibleVariableOverride] + + +class InternalServerError(APIStatusError): + pass diff --git a/src/agentex/_files.py b/src/agentex/_files.py new file mode 100644 index 000000000..76da9e085 --- /dev/null +++ b/src/agentex/_files.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import io +import os +import pathlib +from typing import Sequence, cast, overload +from typing_extensions import TypeVar, TypeGuard + +import anyio + +from ._types import ( + FileTypes, + FileContent, + RequestFiles, + HttpxFileTypes, + Base64FileInput, + HttpxFileContent, + HttpxRequestFiles, +) +from ._utils import is_list, is_mapping, is_tuple_t, is_mapping_t, is_sequence_t + +_T = TypeVar("_T") + + +def is_base64_file_input(obj: object) -> TypeGuard[Base64FileInput]: + return isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike) + + +def is_file_content(obj: object) -> TypeGuard[FileContent]: + return ( + isinstance(obj, bytes) or isinstance(obj, tuple) or isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike) + ) + + +def assert_is_file_content(obj: object, *, key: str | None = None) -> None: + if not is_file_content(obj): + prefix = f"Expected entry at `{key}`" if key is not None else f"Expected file input `{obj!r}`" + raise RuntimeError( + f"{prefix} to be bytes, an io.IOBase instance, PathLike or a tuple but received {type(obj)} instead." + ) from None + + +@overload +def to_httpx_files(files: None) -> None: ... + + +@overload +def to_httpx_files(files: RequestFiles) -> HttpxRequestFiles: ... + + +def to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles | None: + if files is None: + return None + + if is_mapping_t(files): + files = {key: _transform_file(file) for key, file in files.items()} + elif is_sequence_t(files): + files = [(key, _transform_file(file)) for key, file in files] + else: + raise TypeError(f"Unexpected file type input {type(files)}, expected mapping or sequence") + + return files + + +def _transform_file(file: FileTypes) -> HttpxFileTypes: + if is_file_content(file): + if isinstance(file, os.PathLike): + path = pathlib.Path(file) + return (path.name, path.read_bytes()) + + return file + + if is_tuple_t(file): + return (file[0], read_file_content(file[1]), *file[2:]) + + raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple") + + +def read_file_content(file: FileContent) -> HttpxFileContent: + if isinstance(file, os.PathLike): + return pathlib.Path(file).read_bytes() + return file + + +@overload +async def async_to_httpx_files(files: None) -> None: ... + + +@overload +async def async_to_httpx_files(files: RequestFiles) -> HttpxRequestFiles: ... + + +async def async_to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles | None: + if files is None: + return None + + if is_mapping_t(files): + files = {key: await _async_transform_file(file) for key, file in files.items()} + elif is_sequence_t(files): + files = [(key, await _async_transform_file(file)) for key, file in files] + else: + raise TypeError(f"Unexpected file type input {type(files)}, expected mapping or sequence") + + return files + + +async def _async_transform_file(file: FileTypes) -> HttpxFileTypes: + if is_file_content(file): + if isinstance(file, os.PathLike): + path = anyio.Path(file) + return (path.name, await path.read_bytes()) + + return file + + if is_tuple_t(file): + return (file[0], await async_read_file_content(file[1]), *file[2:]) + + raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple") + + +async def async_read_file_content(file: FileContent) -> HttpxFileContent: + if isinstance(file, os.PathLike): + return await anyio.Path(file).read_bytes() + + return file + + +def deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]]) -> _T: + """Copy only the containers along the given paths. + + Used to guard against mutation by extract_files without copying the entire structure. + Only dicts and lists that lie on a path are copied; everything else + is returned by reference. + + For example, given paths=[["foo", "files", "file"]] and the structure: + { + "foo": { + "bar": {"baz": {}}, + "files": {"file": } + } + } + The root dict, "foo", and "files" are copied (they lie on the path). + "bar" and "baz" are returned by reference (off the path). + """ + return _deepcopy_with_paths(item, paths, 0) + + +def _deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]], index: int) -> _T: + if not paths: + return item + if is_mapping(item): + key_to_paths: dict[str, list[Sequence[str]]] = {} + for path in paths: + if index < len(path): + key_to_paths.setdefault(path[index], []).append(path) + + # if no path continues through this mapping, it won't be mutated and copying it is redundant + if not key_to_paths: + return item + + result = dict(item) + for key, subpaths in key_to_paths.items(): + if key in result: + result[key] = _deepcopy_with_paths(result[key], subpaths, index + 1) + return cast(_T, result) + if is_list(item): + array_paths = [path for path in paths if index < len(path) and path[index] == ""] + + # if no path expects a list here, nothing will be mutated inside it - return by reference + if not array_paths: + return cast(_T, item) + return cast(_T, [_deepcopy_with_paths(entry, array_paths, index + 1) for entry in item]) + return item diff --git a/src/agentex/_models.py b/src/agentex/_models.py new file mode 100644 index 000000000..8c5ab2602 --- /dev/null +++ b/src/agentex/_models.py @@ -0,0 +1,952 @@ +from __future__ import annotations + +import os +import inspect +import weakref +from typing import ( + IO, + TYPE_CHECKING, + Any, + Type, + Union, + Generic, + TypeVar, + Callable, + Iterable, + Optional, + AsyncIterable, + cast, +) +from datetime import date, datetime +from typing_extensions import ( + List, + Unpack, + Literal, + ClassVar, + Protocol, + Required, + Annotated, + ParamSpec, + TypeAlias, + TypedDict, + TypeGuard, + final, + override, + runtime_checkable, +) + +import pydantic +from pydantic.fields import FieldInfo + +from ._types import ( + Body, + IncEx, + Query, + ModelT, + Headers, + Timeout, + NotGiven, + AnyMapping, + HttpxRequestFiles, +) +from ._utils import ( + PropertyInfo, + is_list, + is_given, + json_safe, + lru_cache, + is_mapping, + parse_date, + coerce_boolean, + parse_datetime, + strip_not_given, + extract_type_arg, + is_annotated_type, + is_type_alias_type, + strip_annotated_type, +) +from ._compat import ( + PYDANTIC_V1, + ConfigDict, + GenericModel as BaseGenericModel, + get_args, + is_union, + parse_obj, + get_origin, + is_literal_type, + get_model_config, + get_model_fields, + field_get_default, +) +from ._constants import RAW_RESPONSE_HEADER + +if TYPE_CHECKING: + from pydantic import GetCoreSchemaHandler, ValidatorFunctionWrapHandler + from pydantic_core import CoreSchema, core_schema + from pydantic_core.core_schema import ModelField, ModelSchema, LiteralSchema, ModelFieldsSchema +else: + try: + from pydantic_core import CoreSchema, core_schema + except ImportError: + CoreSchema = None + core_schema = None + +__all__ = ["BaseModel", "GenericModel"] + +_T = TypeVar("_T") +_BaseModelT = TypeVar("_BaseModelT", bound="BaseModel") + +P = ParamSpec("P") + + +@runtime_checkable +class _ConfigProtocol(Protocol): + allow_population_by_field_name: bool + + +class BaseModel(pydantic.BaseModel): + if PYDANTIC_V1: + + @property + @override + def model_fields_set(self) -> set[str]: + # a forwards-compat shim for pydantic v2 + return self.__fields_set__ # type: ignore + + class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated] + extra: Any = pydantic.Extra.allow # type: ignore + else: + model_config: ClassVar[ConfigDict] = ConfigDict( + extra="allow", defer_build=coerce_boolean(os.environ.get("DEFER_PYDANTIC_BUILD", "true")) + ) + + def to_dict( + self, + *, + mode: Literal["json", "python"] = "python", + use_api_names: bool = True, + exclude_unset: bool = True, + exclude_defaults: bool = False, + exclude_none: bool = False, + warnings: bool = True, + ) -> dict[str, object]: + """Recursively generate a dictionary representation of the model, optionally specifying which fields to include or exclude. + + By default, fields that were not set by the API will not be included, + and keys will match the API response, *not* the property names from the model. + + For example, if the API responds with `"fooBar": true` but we've defined a `foo_bar: bool` property, + the output will use the `"fooBar"` key (unless `use_api_names=False` is passed). + + Args: + mode: + If mode is 'json', the dictionary will only contain JSON serializable types. e.g. `datetime` will be turned into a string, `"2024-3-22T18:11:19.117000Z"`. + If mode is 'python', the dictionary may contain any Python objects. e.g. `datetime(2024, 3, 22)` + + use_api_names: Whether to use the key that the API responded with or the property name. Defaults to `True`. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that are set to their default value from the output. + exclude_none: Whether to exclude fields that have a value of `None` from the output. + warnings: Whether to log warnings when invalid fields are encountered. This is only supported in Pydantic v2. + """ + return self.model_dump( + mode=mode, + by_alias=use_api_names, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + warnings=warnings, + ) + + def to_json( + self, + *, + indent: int | None = 2, + use_api_names: bool = True, + exclude_unset: bool = True, + exclude_defaults: bool = False, + exclude_none: bool = False, + warnings: bool = True, + ) -> str: + """Generates a JSON string representing this model as it would be received from or sent to the API (but with indentation). + + By default, fields that were not set by the API will not be included, + and keys will match the API response, *not* the property names from the model. + + For example, if the API responds with `"fooBar": true` but we've defined a `foo_bar: bool` property, + the output will use the `"fooBar"` key (unless `use_api_names=False` is passed). + + Args: + indent: Indentation to use in the JSON output. If `None` is passed, the output will be compact. Defaults to `2` + use_api_names: Whether to use the key that the API responded with or the property name. Defaults to `True`. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that have the default value. + exclude_none: Whether to exclude fields that have a value of `None`. + warnings: Whether to show any warnings that occurred during serialization. This is only supported in Pydantic v2. + """ + return self.model_dump_json( + indent=indent, + by_alias=use_api_names, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + warnings=warnings, + ) + + @override + def __str__(self) -> str: + # mypy complains about an invalid self arg + return f"{self.__repr_name__()}({self.__repr_str__(', ')})" # type: ignore[misc] + + # Override the 'construct' method in a way that supports recursive parsing without validation. + # Based on https://github.com/samuelcolvin/pydantic/issues/1168#issuecomment-817742836. + @classmethod + @override + def construct( # pyright: ignore[reportIncompatibleMethodOverride] + __cls: Type[ModelT], + _fields_set: set[str] | None = None, + **values: object, + ) -> ModelT: + m = __cls.__new__(__cls) + fields_values: dict[str, object] = {} + + config = get_model_config(__cls) + populate_by_name = ( + config.allow_population_by_field_name + if isinstance(config, _ConfigProtocol) + else config.get("populate_by_name") + ) + + if _fields_set is None: + _fields_set = set() + + model_fields = get_model_fields(__cls) + for name, field in model_fields.items(): + key = field.alias + if key is None or (key not in values and populate_by_name): + key = name + + if key in values: + fields_values[name] = _construct_field(value=values[key], field=field, key=key) + _fields_set.add(name) + else: + fields_values[name] = field_get_default(field) + + extra_field_type = _get_extra_fields_type(__cls) + + _extra = {} + for key, value in values.items(): + if key not in model_fields: + parsed = construct_type(value=value, type_=extra_field_type) if extra_field_type is not None else value + + if PYDANTIC_V1: + _fields_set.add(key) + fields_values[key] = parsed + else: + _extra[key] = parsed + + object.__setattr__(m, "__dict__", fields_values) + + if PYDANTIC_V1: + # init_private_attributes() does not exist in v2 + m._init_private_attributes() # type: ignore + + # copied from Pydantic v1's `construct()` method + object.__setattr__(m, "__fields_set__", _fields_set) + else: + # these properties are copied from Pydantic's `model_construct()` method + object.__setattr__(m, "__pydantic_private__", None) + object.__setattr__(m, "__pydantic_extra__", _extra) + object.__setattr__(m, "__pydantic_fields_set__", _fields_set) + + return m + + if not TYPE_CHECKING: + # type checkers incorrectly complain about this assignment + # because the type signatures are technically different + # although not in practice + model_construct = construct + + if PYDANTIC_V1: + # we define aliases for some of the new pydantic v2 methods so + # that we can just document these methods without having to specify + # a specific pydantic version as some users may not know which + # pydantic version they are currently using + + @override + def model_dump( + self, + *, + mode: Literal["json", "python"] | str = "python", + include: IncEx | None = None, + exclude: IncEx | None = None, + context: Any | None = None, + by_alias: bool | None = None, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + exclude_computed_fields: bool = False, + round_trip: bool = False, + warnings: bool | Literal["none", "warn", "error"] = True, + fallback: Callable[[Any], Any] | None = None, + serialize_as_any: bool = False, + ) -> dict[str, Any]: + """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump + + Generate a dictionary representation of the model, optionally specifying which fields to include or exclude. + + Args: + mode: The mode in which `to_python` should run. + If mode is 'json', the output will only contain JSON serializable types. + If mode is 'python', the output may contain non-JSON-serializable Python objects. + include: A set of fields to include in the output. + exclude: A set of fields to exclude from the output. + context: Additional context to pass to the serializer. + by_alias: Whether to use the field's alias in the dictionary key if defined. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that are set to their default value. + exclude_none: Whether to exclude fields that have a value of `None`. + exclude_computed_fields: Whether to exclude computed fields. + While this can be useful for round-tripping, it is usually recommended to use the dedicated + `round_trip` parameter instead. + round_trip: If True, dumped values should be valid as input for non-idempotent types such as Json[T]. + warnings: How to handle serialization errors. False/"none" ignores them, True/"warn" logs errors, + "error" raises a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError]. + fallback: A function to call when an unknown value is encountered. If not provided, + a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError] error is raised. + serialize_as_any: Whether to serialize fields with duck-typing serialization behavior. + + Returns: + A dictionary representation of the model. + """ + if mode not in {"json", "python"}: + raise ValueError("mode must be either 'json' or 'python'") + if round_trip != False: + raise ValueError("round_trip is only supported in Pydantic v2") + if warnings != True: + raise ValueError("warnings is only supported in Pydantic v2") + if context is not None: + raise ValueError("context is only supported in Pydantic v2") + if serialize_as_any != False: + raise ValueError("serialize_as_any is only supported in Pydantic v2") + if fallback is not None: + raise ValueError("fallback is only supported in Pydantic v2") + if exclude_computed_fields != False: + raise ValueError("exclude_computed_fields is only supported in Pydantic v2") + dumped = super().dict( # pyright: ignore[reportDeprecated] + include=include, + exclude=exclude, + by_alias=by_alias if by_alias is not None else False, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) + + return cast("dict[str, Any]", json_safe(dumped)) if mode == "json" else dumped + + @override + def model_dump_json( + self, + *, + indent: int | None = None, + ensure_ascii: bool = False, + include: IncEx | None = None, + exclude: IncEx | None = None, + context: Any | None = None, + by_alias: bool | None = None, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + exclude_computed_fields: bool = False, + round_trip: bool = False, + warnings: bool | Literal["none", "warn", "error"] = True, + fallback: Callable[[Any], Any] | None = None, + serialize_as_any: bool = False, + ) -> str: + """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump_json + + Generates a JSON representation of the model using Pydantic's `to_json` method. + + Args: + indent: Indentation to use in the JSON output. If None is passed, the output will be compact. + include: Field(s) to include in the JSON output. Can take either a string or set of strings. + exclude: Field(s) to exclude from the JSON output. Can take either a string or set of strings. + by_alias: Whether to serialize using field aliases. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that have the default value. + exclude_none: Whether to exclude fields that have a value of `None`. + round_trip: Whether to use serialization/deserialization between JSON and class instance. + warnings: Whether to show any warnings that occurred during serialization. + + Returns: + A JSON string representation of the model. + """ + if round_trip != False: + raise ValueError("round_trip is only supported in Pydantic v2") + if warnings != True: + raise ValueError("warnings is only supported in Pydantic v2") + if context is not None: + raise ValueError("context is only supported in Pydantic v2") + if serialize_as_any != False: + raise ValueError("serialize_as_any is only supported in Pydantic v2") + if fallback is not None: + raise ValueError("fallback is only supported in Pydantic v2") + if ensure_ascii != False: + raise ValueError("ensure_ascii is only supported in Pydantic v2") + if exclude_computed_fields != False: + raise ValueError("exclude_computed_fields is only supported in Pydantic v2") + return super().json( # type: ignore[reportDeprecated] + indent=indent, + include=include, + exclude=exclude, + by_alias=by_alias if by_alias is not None else False, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) + + +class _EagerIterable(list[_T], Generic[_T]): + """ + Accepts any Iterable[T] input (including generators), consumes it + eagerly, and validates all items upfront. + + Validation preserves the original container type where possible + (e.g. a set[T] stays a set[T]). Serialization (model_dump / JSON) + always emits a list — round-tripping through model_dump() will not + restore the original container type. + """ + + @classmethod + def __get_pydantic_core_schema__( + cls, + source_type: Any, + handler: GetCoreSchemaHandler, + ) -> CoreSchema: + (item_type,) = get_args(source_type) or (Any,) + item_schema: CoreSchema = handler.generate_schema(item_type) + list_of_items_schema: CoreSchema = core_schema.list_schema(item_schema) + + return core_schema.no_info_wrap_validator_function( + cls._validate, + list_of_items_schema, + serialization=core_schema.plain_serializer_function_ser_schema( + cls._serialize, + info_arg=False, + ), + ) + + @staticmethod + def _validate(v: Iterable[_T], handler: "ValidatorFunctionWrapHandler") -> Any: + original_type: type[Any] = type(v) + + # Normalize to list so list_schema can validate each item + if isinstance(v, list): + items: list[_T] = v + else: + try: + items = list(v) + except TypeError as e: + raise TypeError("Value is not iterable") from e + + # Validate items against the inner schema + validated: list[_T] = handler(items) + + # Reconstruct original container type + if original_type is list: + return validated + # str(list) produces the list's repr, not a string built from items, + # so skip reconstruction for str and its subclasses. + if issubclass(original_type, str): + return validated + try: + return original_type(validated) + except (TypeError, ValueError): + # If the type cannot be reconstructed, just return the validated list + return validated + + @staticmethod + def _serialize(v: Iterable[_T]) -> list[_T]: + """Always serialize as a list so Pydantic's JSON encoder is happy.""" + if isinstance(v, list): + return v + return list(v) + + +EagerIterable: TypeAlias = Annotated[Iterable[_T], _EagerIterable] + + +def _construct_field(value: object, field: FieldInfo, key: str) -> object: + if value is None: + return field_get_default(field) + + if PYDANTIC_V1: + type_ = cast(type, field.outer_type_) # type: ignore + else: + type_ = field.annotation # type: ignore + + if type_ is None: + raise RuntimeError(f"Unexpected field type is None for {key}") + + return construct_type(value=value, type_=type_, metadata=getattr(field, "metadata", None)) + + +def _get_extra_fields_type(cls: type[pydantic.BaseModel]) -> type | None: + if PYDANTIC_V1: + # TODO + return None + + schema = cls.__pydantic_core_schema__ + if schema["type"] == "model": + fields = schema["schema"] + if fields["type"] == "model-fields": + extras = fields.get("extras_schema") + if extras and "cls" in extras: + # mypy can't narrow the type + return extras["cls"] # type: ignore[no-any-return] + + return None + + +def is_basemodel(type_: type) -> bool: + """Returns whether or not the given type is either a `BaseModel` or a union of `BaseModel`""" + if is_union(type_): + for variant in get_args(type_): + if is_basemodel(variant): + return True + + return False + + return is_basemodel_type(type_) + + +def is_basemodel_type(type_: type) -> TypeGuard[type[BaseModel] | type[GenericModel]]: + origin = get_origin(type_) or type_ + if not inspect.isclass(origin): + return False + return issubclass(origin, BaseModel) or issubclass(origin, GenericModel) + + +def build( + base_model_cls: Callable[P, _BaseModelT], + *args: P.args, + **kwargs: P.kwargs, +) -> _BaseModelT: + """Construct a BaseModel class without validation. + + This is useful for cases where you need to instantiate a `BaseModel` + from an API response as this provides type-safe params which isn't supported + by helpers like `construct_type()`. + + ```py + build(MyModel, my_field_a="foo", my_field_b=123) + ``` + """ + if args: + raise TypeError( + "Received positional arguments which are not supported; Keyword arguments must be used instead", + ) + + return cast(_BaseModelT, construct_type(type_=base_model_cls, value=kwargs)) + + +def construct_type_unchecked(*, value: object, type_: type[_T]) -> _T: + """Loose coercion to the expected type with construction of nested values. + + Note: the returned value from this function is not guaranteed to match the + given type. + """ + return cast(_T, construct_type(value=value, type_=type_)) + + +def construct_type(*, value: object, type_: object, metadata: Optional[List[Any]] = None) -> object: + """Loose coercion to the expected type with construction of nested values. + + If the given value does not match the expected type then it is returned as-is. + """ + + # store a reference to the original type we were given before we extract any inner + # types so that we can properly resolve forward references in `TypeAliasType` annotations + original_type = None + + # we allow `object` as the input type because otherwise, passing things like + # `Literal['value']` will be reported as a type error by type checkers + type_ = cast("type[object]", type_) + if is_type_alias_type(type_): + original_type = type_ # type: ignore[unreachable] + type_ = type_.__value__ # type: ignore[unreachable] + + # unwrap `Annotated[T, ...]` -> `T` + if metadata is not None and len(metadata) > 0: + meta: tuple[Any, ...] = tuple(metadata) + elif is_annotated_type(type_): + meta = get_args(type_)[1:] + type_ = extract_type_arg(type_, 0) + else: + meta = tuple() + + # we need to use the origin class for any types that are subscripted generics + # e.g. Dict[str, object] + origin = get_origin(type_) or type_ + args = get_args(type_) + + if is_union(origin): + try: + return validate_type(type_=cast("type[object]", original_type or type_), value=value) + except Exception: + pass + + # if the type is a discriminated union then we want to construct the right variant + # in the union, even if the data doesn't match exactly, otherwise we'd break code + # that relies on the constructed class types, e.g. + # + # class FooType: + # kind: Literal['foo'] + # value: str + # + # class BarType: + # kind: Literal['bar'] + # value: int + # + # without this block, if the data we get is something like `{'kind': 'bar', 'value': 'foo'}` then + # we'd end up constructing `FooType` when it should be `BarType`. + discriminator = _build_discriminated_union_meta(union=type_, meta_annotations=meta) + if discriminator and is_mapping(value): + variant_value = value.get(discriminator.field_alias_from or discriminator.field_name) + if variant_value and isinstance(variant_value, str): + variant_type = discriminator.mapping.get(variant_value) + if variant_type: + return construct_type(type_=variant_type, value=value) + + # if the data is not valid, use the first variant that doesn't fail while deserializing + for variant in args: + try: + return construct_type(value=value, type_=variant) + except Exception: + continue + + raise RuntimeError(f"Could not convert data into a valid instance of {type_}") + + if origin == dict: + if not is_mapping(value): + return value + + _, items_type = get_args(type_) # Dict[_, items_type] + return {key: construct_type(value=item, type_=items_type) for key, item in value.items()} + + if ( + not is_literal_type(type_) + and inspect.isclass(origin) + and (issubclass(origin, BaseModel) or issubclass(origin, GenericModel)) + ): + if is_list(value): + return [cast(Any, type_).construct(**entry) if is_mapping(entry) else entry for entry in value] + + if is_mapping(value): + if issubclass(type_, BaseModel): + return type_.construct(**value) # type: ignore[arg-type] + + return cast(Any, type_).construct(**value) + + if origin == list: + if not is_list(value): + return value + + inner_type = args[0] # List[inner_type] + return [construct_type(value=entry, type_=inner_type) for entry in value] + + if origin == float: + if isinstance(value, int): + coerced = float(value) + if coerced != value: + return value + return coerced + + return value + + if type_ == datetime: + try: + return parse_datetime(value) # type: ignore + except Exception: + return value + + if type_ == date: + try: + return parse_date(value) # type: ignore + except Exception: + return value + + return value + + +@runtime_checkable +class CachedDiscriminatorType(Protocol): + __discriminator__: DiscriminatorDetails + + +DISCRIMINATOR_CACHE: weakref.WeakKeyDictionary[type, DiscriminatorDetails] = weakref.WeakKeyDictionary() + + +class DiscriminatorDetails: + field_name: str + """The name of the discriminator field in the variant class, e.g. + + ```py + class Foo(BaseModel): + type: Literal['foo'] + ``` + + Will result in field_name='type' + """ + + field_alias_from: str | None + """The name of the discriminator field in the API response, e.g. + + ```py + class Foo(BaseModel): + type: Literal['foo'] = Field(alias='type_from_api') + ``` + + Will result in field_alias_from='type_from_api' + """ + + mapping: dict[str, type] + """Mapping of discriminator value to variant type, e.g. + + {'foo': FooVariant, 'bar': BarVariant} + """ + + def __init__( + self, + *, + mapping: dict[str, type], + discriminator_field: str, + discriminator_alias: str | None, + ) -> None: + self.mapping = mapping + self.field_name = discriminator_field + self.field_alias_from = discriminator_alias + + +def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, ...]) -> DiscriminatorDetails | None: + cached = DISCRIMINATOR_CACHE.get(union) + if cached is not None: + return cached + + discriminator_field_name: str | None = None + + for annotation in meta_annotations: + if isinstance(annotation, PropertyInfo) and annotation.discriminator is not None: + discriminator_field_name = annotation.discriminator + break + + if not discriminator_field_name: + return None + + mapping: dict[str, type] = {} + discriminator_alias: str | None = None + + for variant in get_args(union): + variant = strip_annotated_type(variant) + if is_basemodel_type(variant): + if PYDANTIC_V1: + field_info = cast("dict[str, FieldInfo]", variant.__fields__).get(discriminator_field_name) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] + if not field_info: + continue + + # Note: if one variant defines an alias then they all should + discriminator_alias = field_info.alias + + if (annotation := getattr(field_info, "annotation", None)) and is_literal_type(annotation): + for entry in get_args(annotation): + if isinstance(entry, str): + mapping[entry] = variant + else: + field = _extract_field_schema_pv2(variant, discriminator_field_name) + if not field: + continue + + # Note: if one variant defines an alias then they all should + discriminator_alias = field.get("serialization_alias") + + field_schema = field["schema"] + + if field_schema["type"] == "literal": + for entry in cast("LiteralSchema", field_schema)["expected"]: + if isinstance(entry, str): + mapping[entry] = variant + + if not mapping: + return None + + details = DiscriminatorDetails( + mapping=mapping, + discriminator_field=discriminator_field_name, + discriminator_alias=discriminator_alias, + ) + DISCRIMINATOR_CACHE.setdefault(union, details) + return details + + +def _extract_field_schema_pv2(model: type[BaseModel], field_name: str) -> ModelField | None: + schema = model.__pydantic_core_schema__ + if schema["type"] == "definitions": + schema = schema["schema"] + + if schema["type"] != "model": + return None + + schema = cast("ModelSchema", schema) + fields_schema = schema["schema"] + if fields_schema["type"] != "model-fields": + return None + + fields_schema = cast("ModelFieldsSchema", fields_schema) + field = fields_schema["fields"].get(field_name) + if not field: + return None + + return cast("ModelField", field) # pyright: ignore[reportUnnecessaryCast] + + +def validate_type(*, type_: type[_T], value: object) -> _T: + """Strict validation that the given value matches the expected type""" + if inspect.isclass(type_) and issubclass(type_, pydantic.BaseModel): + return cast(_T, parse_obj(type_, value)) + + return cast(_T, _validate_non_model_type(type_=type_, value=value)) + + +def set_pydantic_config(typ: Any, config: pydantic.ConfigDict) -> None: + """Add a pydantic config for the given type. + + Note: this is a no-op on Pydantic v1. + """ + setattr(typ, "__pydantic_config__", config) # noqa: B010 + + +# our use of subclassing here causes weirdness for type checkers, +# so we just pretend that we don't subclass +if TYPE_CHECKING: + GenericModel = BaseModel +else: + + class GenericModel(BaseGenericModel, BaseModel): + pass + + +if not PYDANTIC_V1: + from pydantic import TypeAdapter as _TypeAdapter + + _CachedTypeAdapter = cast("TypeAdapter[object]", lru_cache(maxsize=None)(_TypeAdapter)) + + if TYPE_CHECKING: + from pydantic import TypeAdapter + else: + TypeAdapter = _CachedTypeAdapter + + def _validate_non_model_type(*, type_: type[_T], value: object) -> _T: + return TypeAdapter(type_).validate_python(value) + +elif not TYPE_CHECKING: # TODO: condition is weird + + class RootModel(GenericModel, Generic[_T]): + """Used as a placeholder to easily convert runtime types to a Pydantic format + to provide validation. + + For example: + ```py + validated = RootModel[int](__root__="5").__root__ + # validated: 5 + ``` + """ + + __root__: _T + + def _validate_non_model_type(*, type_: type[_T], value: object) -> _T: + model = _create_pydantic_model(type_).validate(value) + return cast(_T, model.__root__) + + def _create_pydantic_model(type_: _T) -> Type[RootModel[_T]]: + return RootModel[type_] # type: ignore + + +class FinalRequestOptionsInput(TypedDict, total=False): + method: Required[str] + url: Required[str] + params: Query + headers: Headers + max_retries: int + timeout: float | Timeout | None + files: HttpxRequestFiles | None + idempotency_key: str + content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] + json_data: Body + extra_json: AnyMapping + follow_redirects: bool + + +@final +class FinalRequestOptions(pydantic.BaseModel): + method: str + url: str + params: Query = {} + headers: Union[Headers, NotGiven] = NotGiven() + max_retries: Union[int, NotGiven] = NotGiven() + timeout: Union[float, Timeout, None, NotGiven] = NotGiven() + files: Union[HttpxRequestFiles, None] = None + idempotency_key: Union[str, None] = None + post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven() + follow_redirects: Union[bool, None] = None + + content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] = None + # It should be noted that we cannot use `json` here as that would override + # a BaseModel method in an incompatible fashion. + json_data: Union[Body, None] = None + extra_json: Union[AnyMapping, None] = None + + if PYDANTIC_V1: + + class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated] + arbitrary_types_allowed: bool = True + else: + model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True) + + def get_max_retries(self, max_retries: int) -> int: + if isinstance(self.max_retries, NotGiven): + return max_retries + return self.max_retries + + def _strip_raw_response_header(self) -> None: + if not is_given(self.headers): + return + + if self.headers.get(RAW_RESPONSE_HEADER): + self.headers = {**self.headers} + self.headers.pop(RAW_RESPONSE_HEADER) + + # override the `construct` method so that we can run custom transformations. + # this is necessary as we don't want to do any actual runtime type checking + # (which means we can't use validators) but we do want to ensure that `NotGiven` + # values are not present + # + # type ignore required because we're adding explicit types to `**values` + @classmethod + def construct( # type: ignore + cls, + _fields_set: set[str] | None = None, + **values: Unpack[FinalRequestOptionsInput], + ) -> FinalRequestOptions: + kwargs: dict[str, Any] = { + # we unconditionally call `strip_not_given` on any value + # as it will just ignore any non-mapping types + key: strip_not_given(value) + for key, value in values.items() + } + if PYDANTIC_V1: + return cast(FinalRequestOptions, super().construct(_fields_set, **kwargs)) # pyright: ignore[reportDeprecated] + return super().model_construct(_fields_set, **kwargs) + + if not TYPE_CHECKING: + # type checkers incorrectly complain about this assignment + model_construct = construct diff --git a/src/agentex/_qs.py b/src/agentex/_qs.py new file mode 100644 index 000000000..4127c19c6 --- /dev/null +++ b/src/agentex/_qs.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from typing import Any, List, Tuple, Union, Mapping, TypeVar +from urllib.parse import parse_qs, urlencode +from typing_extensions import get_args + +from ._types import NotGiven, ArrayFormat, NestedFormat, not_given +from ._utils import flatten + +_T = TypeVar("_T") + +PrimitiveData = Union[str, int, float, bool, None] +# this should be Data = Union[PrimitiveData, "List[Data]", "Tuple[Data]", "Mapping[str, Data]"] +# https://github.com/microsoft/pyright/issues/3555 +Data = Union[PrimitiveData, List[Any], Tuple[Any], "Mapping[str, Any]"] +Params = Mapping[str, Data] + + +class Querystring: + array_format: ArrayFormat + nested_format: NestedFormat + + def __init__( + self, + *, + array_format: ArrayFormat = "repeat", + nested_format: NestedFormat = "brackets", + ) -> None: + self.array_format = array_format + self.nested_format = nested_format + + def parse(self, query: str) -> Mapping[str, object]: + # Note: custom format syntax is not supported yet + return parse_qs(query) + + def stringify( + self, + params: Params, + *, + array_format: ArrayFormat | NotGiven = not_given, + nested_format: NestedFormat | NotGiven = not_given, + ) -> str: + return urlencode( + self.stringify_items( + params, + array_format=array_format, + nested_format=nested_format, + ) + ) + + def stringify_items( + self, + params: Params, + *, + array_format: ArrayFormat | NotGiven = not_given, + nested_format: NestedFormat | NotGiven = not_given, + ) -> list[tuple[str, str]]: + opts = Options( + qs=self, + array_format=array_format, + nested_format=nested_format, + ) + return flatten([self._stringify_item(key, value, opts) for key, value in params.items()]) + + def _stringify_item( + self, + key: str, + value: Data, + opts: Options, + ) -> list[tuple[str, str]]: + if isinstance(value, Mapping): + items: list[tuple[str, str]] = [] + nested_format = opts.nested_format + for subkey, subvalue in value.items(): + items.extend( + self._stringify_item( + # TODO: error if unknown format + f"{key}.{subkey}" if nested_format == "dots" else f"{key}[{subkey}]", + subvalue, + opts, + ) + ) + return items + + if isinstance(value, (list, tuple)): + array_format = opts.array_format + if array_format == "comma": + return [ + ( + key, + ",".join(self._primitive_value_to_str(item) for item in value if item is not None), + ), + ] + elif array_format == "repeat": + items = [] + for item in value: + items.extend(self._stringify_item(key, item, opts)) + return items + elif array_format == "indices": + items = [] + for i, item in enumerate(value): + items.extend(self._stringify_item(f"{key}[{i}]", item, opts)) + return items + elif array_format == "brackets": + items = [] + key = key + "[]" + for item in value: + items.extend(self._stringify_item(key, item, opts)) + return items + else: + raise NotImplementedError( + f"Unknown array_format value: {array_format}, choose from {', '.join(get_args(ArrayFormat))}" + ) + + serialised = self._primitive_value_to_str(value) + if not serialised: + return [] + return [(key, serialised)] + + def _primitive_value_to_str(self, value: PrimitiveData) -> str: + # copied from httpx + if value is True: + return "true" + elif value is False: + return "false" + elif value is None: + return "" + return str(value) + + +_qs = Querystring() +parse = _qs.parse +stringify = _qs.stringify +stringify_items = _qs.stringify_items + + +class Options: + array_format: ArrayFormat + nested_format: NestedFormat + + def __init__( + self, + qs: Querystring = _qs, + *, + array_format: ArrayFormat | NotGiven = not_given, + nested_format: NestedFormat | NotGiven = not_given, + ) -> None: + self.array_format = qs.array_format if isinstance(array_format, NotGiven) else array_format + self.nested_format = qs.nested_format if isinstance(nested_format, NotGiven) else nested_format diff --git a/src/agentex/_resource.py b/src/agentex/_resource.py new file mode 100644 index 000000000..246bd65d0 --- /dev/null +++ b/src/agentex/_resource.py @@ -0,0 +1,43 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +import anyio + +if TYPE_CHECKING: + from ._client import Agentex, AsyncAgentex + + +class SyncAPIResource: + _client: Agentex + + def __init__(self, client: Agentex) -> None: + self._client = client + self._get = client.get + self._post = client.post + self._patch = client.patch + self._put = client.put + self._delete = client.delete + self._get_api_list = client.get_api_list + + def _sleep(self, seconds: float) -> None: + time.sleep(seconds) + + +class AsyncAPIResource: + _client: AsyncAgentex + + def __init__(self, client: AsyncAgentex) -> None: + self._client = client + self._get = client.get + self._post = client.post + self._patch = client.patch + self._put = client.put + self._delete = client.delete + self._get_api_list = client.get_api_list + + async def _sleep(self, seconds: float) -> None: + await anyio.sleep(seconds) diff --git a/src/agentex/_response.py b/src/agentex/_response.py new file mode 100644 index 000000000..d8e2464e0 --- /dev/null +++ b/src/agentex/_response.py @@ -0,0 +1,833 @@ +from __future__ import annotations + +import os +import inspect +import logging +import datetime +import functools +from types import TracebackType +from typing import ( + TYPE_CHECKING, + Any, + Union, + Generic, + TypeVar, + Callable, + Iterator, + AsyncIterator, + cast, + overload, +) +from typing_extensions import Awaitable, ParamSpec, override, get_origin + +import anyio +import httpx +import pydantic + +from ._types import NoneType +from ._utils import is_given, extract_type_arg, is_annotated_type, is_type_alias_type, extract_type_var_from_base +from ._models import BaseModel, is_basemodel +from ._constants import RAW_RESPONSE_HEADER, OVERRIDE_CAST_TO_HEADER +from ._streaming import Stream, AsyncStream, is_stream_class_type, extract_stream_chunk_type +from ._exceptions import AgentexError, APIResponseValidationError + +if TYPE_CHECKING: + from ._models import FinalRequestOptions + from ._base_client import BaseClient + + +P = ParamSpec("P") +R = TypeVar("R") +_T = TypeVar("_T") +_APIResponseT = TypeVar("_APIResponseT", bound="APIResponse[Any]") +_AsyncAPIResponseT = TypeVar("_AsyncAPIResponseT", bound="AsyncAPIResponse[Any]") + +log: logging.Logger = logging.getLogger(__name__) + + +class BaseAPIResponse(Generic[R]): + _cast_to: type[R] + _client: BaseClient[Any, Any] + _parsed_by_type: dict[type[Any], Any] + _is_sse_stream: bool + _stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None + _options: FinalRequestOptions + + http_response: httpx.Response + + retries_taken: int + """The number of retries made. If no retries happened this will be `0`""" + + def __init__( + self, + *, + raw: httpx.Response, + cast_to: type[R], + client: BaseClient[Any, Any], + stream: bool, + stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None, + options: FinalRequestOptions, + retries_taken: int = 0, + ) -> None: + self._cast_to = cast_to + self._client = client + self._parsed_by_type = {} + self._is_sse_stream = stream + self._stream_cls = stream_cls + self._options = options + self.http_response = raw + self.retries_taken = retries_taken + + @property + def headers(self) -> httpx.Headers: + return self.http_response.headers + + @property + def http_request(self) -> httpx.Request: + """Returns the httpx Request instance associated with the current response.""" + return self.http_response.request + + @property + def status_code(self) -> int: + return self.http_response.status_code + + @property + def url(self) -> httpx.URL: + """Returns the URL for which the request was made.""" + return self.http_response.url + + @property + def method(self) -> str: + return self.http_request.method + + @property + def http_version(self) -> str: + return self.http_response.http_version + + @property + def elapsed(self) -> datetime.timedelta: + """The time taken for the complete request/response cycle to complete.""" + return self.http_response.elapsed + + @property + def is_closed(self) -> bool: + """Whether or not the response body has been closed. + + If this is False then there is response data that has not been read yet. + You must either fully consume the response body or call `.close()` + before discarding the response to prevent resource leaks. + """ + return self.http_response.is_closed + + @override + def __repr__(self) -> str: + return ( + f"<{self.__class__.__name__} [{self.status_code} {self.http_response.reason_phrase}] type={self._cast_to}>" + ) + + def _parse(self, *, to: type[_T] | None = None) -> R | _T: + cast_to = to if to is not None else self._cast_to + + # unwrap `TypeAlias('Name', T)` -> `T` + if is_type_alias_type(cast_to): + cast_to = cast_to.__value__ # type: ignore[unreachable] + + # unwrap `Annotated[T, ...]` -> `T` + if cast_to and is_annotated_type(cast_to): + cast_to = extract_type_arg(cast_to, 0) + + origin = get_origin(cast_to) or cast_to + + if self._is_sse_stream: + if to: + if not is_stream_class_type(to): + raise TypeError(f"Expected custom parse type to be a subclass of {Stream} or {AsyncStream}") + + return cast( + _T, + to( + cast_to=extract_stream_chunk_type( + to, + failure_message="Expected custom stream type to be passed with a type argument, e.g. Stream[ChunkType]", + ), + response=self.http_response, + client=cast(Any, self._client), + options=self._options, + ), + ) + + if self._stream_cls: + return cast( + R, + self._stream_cls( + cast_to=extract_stream_chunk_type(self._stream_cls), + response=self.http_response, + client=cast(Any, self._client), + options=self._options, + ), + ) + + stream_cls = cast("type[Stream[Any]] | type[AsyncStream[Any]] | None", self._client._default_stream_cls) + if stream_cls is None: + raise MissingStreamClassError() + + return cast( + R, + stream_cls( + cast_to=cast_to, + response=self.http_response, + client=cast(Any, self._client), + options=self._options, + ), + ) + + if cast_to is NoneType: + return cast(R, None) + + response = self.http_response + if cast_to == str: + return cast(R, response.text) + + if cast_to == bytes: + return cast(R, response.content) + + if cast_to == int: + return cast(R, int(response.text)) + + if cast_to == float: + return cast(R, float(response.text)) + + if cast_to == bool: + return cast(R, response.text.lower() == "true") + + if origin == APIResponse: + raise RuntimeError("Unexpected state - cast_to is `APIResponse`") + + if inspect.isclass(origin) and issubclass(origin, httpx.Response): + # Because of the invariance of our ResponseT TypeVar, users can subclass httpx.Response + # and pass that class to our request functions. We cannot change the variance to be either + # covariant or contravariant as that makes our usage of ResponseT illegal. We could construct + # the response class ourselves but that is something that should be supported directly in httpx + # as it would be easy to incorrectly construct the Response object due to the multitude of arguments. + if cast_to != httpx.Response: + raise ValueError(f"Subclasses of httpx.Response cannot be passed to `cast_to`") + return cast(R, response) + + if ( + inspect.isclass( + origin # pyright: ignore[reportUnknownArgumentType] + ) + and not issubclass(origin, BaseModel) + and issubclass(origin, pydantic.BaseModel) + ): + raise TypeError("Pydantic models must subclass our base model type, e.g. `from agentex import BaseModel`") + + if ( + cast_to is not object + and not origin is list + and not origin is dict + and not origin is Union + and not issubclass(origin, BaseModel) + ): + raise RuntimeError( + f"Unsupported type, expected {cast_to} to be a subclass of {BaseModel}, {dict}, {list}, {Union}, {NoneType}, {str} or {httpx.Response}." + ) + + # split is required to handle cases where additional information is included + # in the response, e.g. application/json; charset=utf-8 + content_type, *_ = response.headers.get("content-type", "*").split(";") + if not content_type.endswith("json"): + if is_basemodel(cast_to): + try: + data = response.json() + except Exception as exc: + log.debug("Could not read JSON from response data due to %s - %s", type(exc), exc) + else: + return self._client._process_response_data( + data=data, + cast_to=cast_to, # type: ignore + response=response, + ) + + if self._client._strict_response_validation: + raise APIResponseValidationError( + response=response, + message=f"Expected Content-Type response header to be `application/json` but received `{content_type}` instead.", + body=response.text, + ) + + # If the API responds with content that isn't JSON then we just return + # the (decoded) text without performing any parsing so that you can still + # handle the response however you need to. + return response.text # type: ignore + + data = response.json() + + return self._client._process_response_data( + data=data, + cast_to=cast_to, # type: ignore + response=response, + ) + + +class APIResponse(BaseAPIResponse[R]): + @overload + def parse(self, *, to: type[_T]) -> _T: ... + + @overload + def parse(self) -> R: ... + + def parse(self, *, to: type[_T] | None = None) -> R | _T: + """Returns the rich python representation of this response's data. + + For lower-level control, see `.read()`, `.json()`, `.iter_bytes()`. + + You can customise the type that the response is parsed into through + the `to` argument, e.g. + + ```py + from agentex import BaseModel + + + class MyModel(BaseModel): + foo: str + + + obj = response.parse(to=MyModel) + print(obj.foo) + ``` + + We support parsing: + - `BaseModel` + - `dict` + - `list` + - `Union` + - `str` + - `int` + - `float` + - `httpx.Response` + """ + cache_key = to if to is not None else self._cast_to + cached = self._parsed_by_type.get(cache_key) + if cached is not None: + return cached # type: ignore[no-any-return] + + if not self._is_sse_stream: + self.read() + + parsed = self._parse(to=to) + if is_given(self._options.post_parser): + parsed = self._options.post_parser(parsed) + + self._parsed_by_type[cache_key] = parsed + return parsed + + def read(self) -> bytes: + """Read and return the binary response content.""" + try: + return self.http_response.read() + except httpx.StreamConsumed as exc: + # The default error raised by httpx isn't very + # helpful in our case so we re-raise it with + # a different error message. + raise StreamAlreadyConsumed() from exc + + def text(self) -> str: + """Read and decode the response content into a string.""" + self.read() + return self.http_response.text + + def json(self) -> object: + """Read and decode the JSON response content.""" + self.read() + return self.http_response.json() + + def close(self) -> None: + """Close the response and release the connection. + + Automatically called if the response body is read to completion. + """ + self.http_response.close() + + def iter_bytes(self, chunk_size: int | None = None) -> Iterator[bytes]: + """ + A byte-iterator over the decoded response content. + + This automatically handles gzip, deflate and brotli encoded responses. + """ + for chunk in self.http_response.iter_bytes(chunk_size): + yield chunk + + def iter_text(self, chunk_size: int | None = None) -> Iterator[str]: + """A str-iterator over the decoded response content + that handles both gzip, deflate, etc but also detects the content's + string encoding. + """ + for chunk in self.http_response.iter_text(chunk_size): + yield chunk + + def iter_lines(self) -> Iterator[str]: + """Like `iter_text()` but will only yield chunks for each line""" + for chunk in self.http_response.iter_lines(): + yield chunk + + +class AsyncAPIResponse(BaseAPIResponse[R]): + @overload + async def parse(self, *, to: type[_T]) -> _T: ... + + @overload + async def parse(self) -> R: ... + + async def parse(self, *, to: type[_T] | None = None) -> R | _T: + """Returns the rich python representation of this response's data. + + For lower-level control, see `.read()`, `.json()`, `.iter_bytes()`. + + You can customise the type that the response is parsed into through + the `to` argument, e.g. + + ```py + from agentex import BaseModel + + + class MyModel(BaseModel): + foo: str + + + obj = response.parse(to=MyModel) + print(obj.foo) + ``` + + We support parsing: + - `BaseModel` + - `dict` + - `list` + - `Union` + - `str` + - `httpx.Response` + """ + cache_key = to if to is not None else self._cast_to + cached = self._parsed_by_type.get(cache_key) + if cached is not None: + return cached # type: ignore[no-any-return] + + if not self._is_sse_stream: + await self.read() + + parsed = self._parse(to=to) + if is_given(self._options.post_parser): + parsed = self._options.post_parser(parsed) + + self._parsed_by_type[cache_key] = parsed + return parsed + + async def read(self) -> bytes: + """Read and return the binary response content.""" + try: + return await self.http_response.aread() + except httpx.StreamConsumed as exc: + # the default error raised by httpx isn't very + # helpful in our case so we re-raise it with + # a different error message + raise StreamAlreadyConsumed() from exc + + async def text(self) -> str: + """Read and decode the response content into a string.""" + await self.read() + return self.http_response.text + + async def json(self) -> object: + """Read and decode the JSON response content.""" + await self.read() + return self.http_response.json() + + async def close(self) -> None: + """Close the response and release the connection. + + Automatically called if the response body is read to completion. + """ + await self.http_response.aclose() + + async def iter_bytes(self, chunk_size: int | None = None) -> AsyncIterator[bytes]: + """ + A byte-iterator over the decoded response content. + + This automatically handles gzip, deflate and brotli encoded responses. + """ + async for chunk in self.http_response.aiter_bytes(chunk_size): + yield chunk + + async def iter_text(self, chunk_size: int | None = None) -> AsyncIterator[str]: + """A str-iterator over the decoded response content + that handles both gzip, deflate, etc but also detects the content's + string encoding. + """ + async for chunk in self.http_response.aiter_text(chunk_size): + yield chunk + + async def iter_lines(self) -> AsyncIterator[str]: + """Like `iter_text()` but will only yield chunks for each line""" + async for chunk in self.http_response.aiter_lines(): + yield chunk + + +class BinaryAPIResponse(APIResponse[bytes]): + """Subclass of APIResponse providing helpers for dealing with binary data. + + Note: If you want to stream the response data instead of eagerly reading it + all at once then you should use `.with_streaming_response` when making + the API request, e.g. `.with_streaming_response.get_binary_response()` + """ + + def write_to_file( + self, + file: str | os.PathLike[str], + ) -> None: + """Write the output to the given file. + + Accepts a filename or any path-like object, e.g. pathlib.Path + + Note: if you want to stream the data to the file instead of writing + all at once then you should use `.with_streaming_response` when making + the API request, e.g. `.with_streaming_response.get_binary_response()` + """ + with open(file, mode="wb") as f: + for data in self.iter_bytes(): + f.write(data) + + +class AsyncBinaryAPIResponse(AsyncAPIResponse[bytes]): + """Subclass of APIResponse providing helpers for dealing with binary data. + + Note: If you want to stream the response data instead of eagerly reading it + all at once then you should use `.with_streaming_response` when making + the API request, e.g. `.with_streaming_response.get_binary_response()` + """ + + async def write_to_file( + self, + file: str | os.PathLike[str], + ) -> None: + """Write the output to the given file. + + Accepts a filename or any path-like object, e.g. pathlib.Path + + Note: if you want to stream the data to the file instead of writing + all at once then you should use `.with_streaming_response` when making + the API request, e.g. `.with_streaming_response.get_binary_response()` + """ + path = anyio.Path(file) + async with await path.open(mode="wb") as f: + async for data in self.iter_bytes(): + await f.write(data) + + +class StreamedBinaryAPIResponse(APIResponse[bytes]): + def stream_to_file( + self, + file: str | os.PathLike[str], + *, + chunk_size: int | None = None, + ) -> None: + """Streams the output to the given file. + + Accepts a filename or any path-like object, e.g. pathlib.Path + """ + with open(file, mode="wb") as f: + for data in self.iter_bytes(chunk_size): + f.write(data) + + +class AsyncStreamedBinaryAPIResponse(AsyncAPIResponse[bytes]): + async def stream_to_file( + self, + file: str | os.PathLike[str], + *, + chunk_size: int | None = None, + ) -> None: + """Streams the output to the given file. + + Accepts a filename or any path-like object, e.g. pathlib.Path + """ + path = anyio.Path(file) + async with await path.open(mode="wb") as f: + async for data in self.iter_bytes(chunk_size): + await f.write(data) + + +class MissingStreamClassError(TypeError): + def __init__(self) -> None: + super().__init__( + "The `stream` argument was set to `True` but the `stream_cls` argument was not given. See `agentex._streaming` for reference", + ) + + +class StreamAlreadyConsumed(AgentexError): + """ + Attempted to read or stream content, but the content has already + been streamed. + + This can happen if you use a method like `.iter_lines()` and then attempt + to read th entire response body afterwards, e.g. + + ```py + response = await client.post(...) + async for line in response.iter_lines(): + ... # do something with `line` + + content = await response.read() + # ^ error + ``` + + If you want this behaviour you'll need to either manually accumulate the response + content or call `await response.read()` before iterating over the stream. + """ + + def __init__(self) -> None: + message = ( + "Attempted to read or stream some content, but the content has " + "already been streamed. " + "This could be due to attempting to stream the response " + "content more than once." + "\n\n" + "You can fix this by manually accumulating the response content while streaming " + "or by calling `.read()` before starting to stream." + ) + super().__init__(message) + + +class ResponseContextManager(Generic[_APIResponseT]): + """Context manager for ensuring that a request is not made + until it is entered and that the response will always be closed + when the context manager exits + """ + + def __init__(self, request_func: Callable[[], _APIResponseT]) -> None: + self._request_func = request_func + self.__response: _APIResponseT | None = None + + def __enter__(self) -> _APIResponseT: + self.__response = self._request_func() + return self.__response + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + if self.__response is not None: + self.__response.close() + + +class AsyncResponseContextManager(Generic[_AsyncAPIResponseT]): + """Context manager for ensuring that a request is not made + until it is entered and that the response will always be closed + when the context manager exits + """ + + def __init__(self, api_request: Awaitable[_AsyncAPIResponseT]) -> None: + self._api_request = api_request + self.__response: _AsyncAPIResponseT | None = None + + async def __aenter__(self) -> _AsyncAPIResponseT: + self.__response = await self._api_request + return self.__response + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + if self.__response is not None: + await self.__response.close() + + +def to_streamed_response_wrapper(func: Callable[P, R]) -> Callable[P, ResponseContextManager[APIResponse[R]]]: + """Higher order function that takes one of our bound API methods and wraps it + to support streaming and returning the raw `APIResponse` object directly. + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> ResponseContextManager[APIResponse[R]]: + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "stream" + + kwargs["extra_headers"] = extra_headers + + make_request = functools.partial(func, *args, **kwargs) + + return ResponseContextManager(cast(Callable[[], APIResponse[R]], make_request)) + + return wrapped + + +def async_to_streamed_response_wrapper( + func: Callable[P, Awaitable[R]], +) -> Callable[P, AsyncResponseContextManager[AsyncAPIResponse[R]]]: + """Higher order function that takes one of our bound API methods and wraps it + to support streaming and returning the raw `APIResponse` object directly. + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> AsyncResponseContextManager[AsyncAPIResponse[R]]: + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "stream" + + kwargs["extra_headers"] = extra_headers + + make_request = func(*args, **kwargs) + + return AsyncResponseContextManager(cast(Awaitable[AsyncAPIResponse[R]], make_request)) + + return wrapped + + +def to_custom_streamed_response_wrapper( + func: Callable[P, object], + response_cls: type[_APIResponseT], +) -> Callable[P, ResponseContextManager[_APIResponseT]]: + """Higher order function that takes one of our bound API methods and an `APIResponse` class + and wraps the method to support streaming and returning the given response class directly. + + Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])` + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> ResponseContextManager[_APIResponseT]: + extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "stream" + extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls + + kwargs["extra_headers"] = extra_headers + + make_request = functools.partial(func, *args, **kwargs) + + return ResponseContextManager(cast(Callable[[], _APIResponseT], make_request)) + + return wrapped + + +def async_to_custom_streamed_response_wrapper( + func: Callable[P, Awaitable[object]], + response_cls: type[_AsyncAPIResponseT], +) -> Callable[P, AsyncResponseContextManager[_AsyncAPIResponseT]]: + """Higher order function that takes one of our bound API methods and an `APIResponse` class + and wraps the method to support streaming and returning the given response class directly. + + Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])` + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> AsyncResponseContextManager[_AsyncAPIResponseT]: + extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "stream" + extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls + + kwargs["extra_headers"] = extra_headers + + make_request = func(*args, **kwargs) + + return AsyncResponseContextManager(cast(Awaitable[_AsyncAPIResponseT], make_request)) + + return wrapped + + +def to_raw_response_wrapper(func: Callable[P, R]) -> Callable[P, APIResponse[R]]: + """Higher order function that takes one of our bound API methods and wraps it + to support returning the raw `APIResponse` object directly. + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> APIResponse[R]: + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "raw" + + kwargs["extra_headers"] = extra_headers + + return cast(APIResponse[R], func(*args, **kwargs)) + + return wrapped + + +def async_to_raw_response_wrapper(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[AsyncAPIResponse[R]]]: + """Higher order function that takes one of our bound API methods and wraps it + to support returning the raw `APIResponse` object directly. + """ + + @functools.wraps(func) + async def wrapped(*args: P.args, **kwargs: P.kwargs) -> AsyncAPIResponse[R]: + extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "raw" + + kwargs["extra_headers"] = extra_headers + + return cast(AsyncAPIResponse[R], await func(*args, **kwargs)) + + return wrapped + + +def to_custom_raw_response_wrapper( + func: Callable[P, object], + response_cls: type[_APIResponseT], +) -> Callable[P, _APIResponseT]: + """Higher order function that takes one of our bound API methods and an `APIResponse` class + and wraps the method to support returning the given response class directly. + + Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])` + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> _APIResponseT: + extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "raw" + extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls + + kwargs["extra_headers"] = extra_headers + + return cast(_APIResponseT, func(*args, **kwargs)) + + return wrapped + + +def async_to_custom_raw_response_wrapper( + func: Callable[P, Awaitable[object]], + response_cls: type[_AsyncAPIResponseT], +) -> Callable[P, Awaitable[_AsyncAPIResponseT]]: + """Higher order function that takes one of our bound API methods and an `APIResponse` class + and wraps the method to support returning the given response class directly. + + Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])` + """ + + @functools.wraps(func) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> Awaitable[_AsyncAPIResponseT]: + extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})} + extra_headers[RAW_RESPONSE_HEADER] = "raw" + extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls + + kwargs["extra_headers"] = extra_headers + + return cast(Awaitable[_AsyncAPIResponseT], func(*args, **kwargs)) + + return wrapped + + +def extract_response_type(typ: type[BaseAPIResponse[Any]]) -> type: + """Given a type like `APIResponse[T]`, returns the generic type variable `T`. + + This also handles the case where a concrete subclass is given, e.g. + ```py + class MyResponse(APIResponse[bytes]): + ... + + extract_response_type(MyResponse) -> bytes + ``` + """ + return extract_type_var_from_base( + typ, + generic_bases=cast("tuple[type, ...]", (BaseAPIResponse, APIResponse, AsyncAPIResponse)), + index=0, + ) diff --git a/src/agentex/_streaming.py b/src/agentex/_streaming.py new file mode 100644 index 000000000..96585bde7 --- /dev/null +++ b/src/agentex/_streaming.py @@ -0,0 +1,338 @@ +# Note: initially copied from https://github.com/florimondmanca/httpx-sse/blob/master/src/httpx_sse/_decoders.py +from __future__ import annotations + +import json +import inspect +from types import TracebackType +from typing import TYPE_CHECKING, Any, Generic, TypeVar, Iterator, Optional, AsyncIterator, cast +from typing_extensions import Self, Protocol, TypeGuard, override, get_origin, runtime_checkable + +import httpx + +from ._utils import extract_type_var_from_base + +if TYPE_CHECKING: + from ._client import Agentex, AsyncAgentex + from ._models import FinalRequestOptions + + +_T = TypeVar("_T") + + +class Stream(Generic[_T]): + """Provides the core interface to iterate over a synchronous stream response.""" + + response: httpx.Response + _options: Optional[FinalRequestOptions] = None + _decoder: SSEBytesDecoder + + def __init__( + self, + *, + cast_to: type[_T], + response: httpx.Response, + client: Agentex, + options: Optional[FinalRequestOptions] = None, + ) -> None: + self.response = response + self._cast_to = cast_to + self._client = client + self._options = options + self._decoder = client._make_sse_decoder() + self._iterator = self.__stream__() + + def __next__(self) -> _T: + return self._iterator.__next__() + + def __iter__(self) -> Iterator[_T]: + for item in self._iterator: + yield item + + def _iter_events(self) -> Iterator[ServerSentEvent]: + yield from self._decoder.iter_bytes(self.response.iter_bytes()) + + def __stream__(self) -> Iterator[_T]: + cast_to = cast(Any, self._cast_to) + response = self.response + process_data = self._client._process_response_data + iterator = self._iter_events() + + try: + for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + finally: + # Ensure the response is closed even if the consumer doesn't read all data + response.close() + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.close() + + def close(self) -> None: + """ + Close the response and release the connection. + + Automatically called if the response body is read to completion. + """ + self.response.close() + + +class AsyncStream(Generic[_T]): + """Provides the core interface to iterate over an asynchronous stream response.""" + + response: httpx.Response + _options: Optional[FinalRequestOptions] = None + _decoder: SSEDecoder | SSEBytesDecoder + + def __init__( + self, + *, + cast_to: type[_T], + response: httpx.Response, + client: AsyncAgentex, + options: Optional[FinalRequestOptions] = None, + ) -> None: + self.response = response + self._cast_to = cast_to + self._client = client + self._options = options + self._decoder = client._make_sse_decoder() + self._iterator = self.__stream__() + + async def __anext__(self) -> _T: + return await self._iterator.__anext__() + + async def __aiter__(self) -> AsyncIterator[_T]: + async for item in self._iterator: + yield item + + async def _iter_events(self) -> AsyncIterator[ServerSentEvent]: + async for sse in self._decoder.aiter_bytes(self.response.aiter_bytes()): + yield sse + + async def __stream__(self) -> AsyncIterator[_T]: + cast_to = cast(Any, self._cast_to) + response = self.response + process_data = self._client._process_response_data + iterator = self._iter_events() + + try: + async for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + finally: + # Ensure the response is closed even if the consumer doesn't read all data + await response.aclose() + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self.close() + + async def close(self) -> None: + """ + Close the response and release the connection. + + Automatically called if the response body is read to completion. + """ + await self.response.aclose() + + +class ServerSentEvent: + def __init__( + self, + *, + event: str | None = None, + data: str | None = None, + id: str | None = None, + retry: int | None = None, + ) -> None: + if data is None: + data = "" + + self._id = id + self._data = data + self._event = event or None + self._retry = retry + + @property + def event(self) -> str | None: + return self._event + + @property + def id(self) -> str | None: + return self._id + + @property + def retry(self) -> int | None: + return self._retry + + @property + def data(self) -> str: + return self._data + + def json(self) -> Any: + return json.loads(self.data) + + @override + def __repr__(self) -> str: + return f"ServerSentEvent(event={self.event}, data={self.data}, id={self.id}, retry={self.retry})" + + +class SSEDecoder: + _data: list[str] + _event: str | None + _retry: int | None + _last_event_id: str | None + + def __init__(self) -> None: + self._event = None + self._data = [] + self._last_event_id = None + self._retry = None + + def iter_bytes(self, iterator: Iterator[bytes]) -> Iterator[ServerSentEvent]: + """Given an iterator that yields raw binary data, iterate over it & yield every event encountered""" + for chunk in self._iter_chunks(iterator): + # Split before decoding so splitlines() only uses \r and \n + for raw_line in chunk.splitlines(): + line = raw_line.decode("utf-8") + sse = self.decode(line) + if sse: + yield sse + + def _iter_chunks(self, iterator: Iterator[bytes]) -> Iterator[bytes]: + """Given an iterator that yields raw binary data, iterate over it and yield individual SSE chunks""" + data = b"" + for chunk in iterator: + for line in chunk.splitlines(keepends=True): + data += line + if data.endswith((b"\r\r", b"\n\n", b"\r\n\r\n")): + yield data + data = b"" + if data: + yield data + + async def aiter_bytes(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[ServerSentEvent]: + """Given an iterator that yields raw binary data, iterate over it & yield every event encountered""" + async for chunk in self._aiter_chunks(iterator): + # Split before decoding so splitlines() only uses \r and \n + for raw_line in chunk.splitlines(): + line = raw_line.decode("utf-8") + sse = self.decode(line) + if sse: + yield sse + + async def _aiter_chunks(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[bytes]: + """Given an iterator that yields raw binary data, iterate over it and yield individual SSE chunks""" + data = b"" + async for chunk in iterator: + for line in chunk.splitlines(keepends=True): + data += line + if data.endswith((b"\r\r", b"\n\n", b"\r\n\r\n")): + yield data + data = b"" + if data: + yield data + + def decode(self, line: str) -> ServerSentEvent | None: + # See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation # noqa: E501 + + if not line: + if not self._event and not self._data and not self._last_event_id and self._retry is None: + return None + + sse = ServerSentEvent( + event=self._event, + data="\n".join(self._data), + id=self._last_event_id, + retry=self._retry, + ) + + # NOTE: as per the SSE spec, do not reset last_event_id. + self._event = None + self._data = [] + self._retry = None + + return sse + + if line.startswith(":"): + return None + + fieldname, _, value = line.partition(":") + + if value.startswith(" "): + value = value[1:] + + if fieldname == "event": + self._event = value + elif fieldname == "data": + self._data.append(value) + elif fieldname == "id": + if "\0" in value: + pass + else: + self._last_event_id = value + elif fieldname == "retry": + try: + self._retry = int(value) + except (TypeError, ValueError): + pass + else: + pass # Field is ignored. + + return None + + +@runtime_checkable +class SSEBytesDecoder(Protocol): + def iter_bytes(self, iterator: Iterator[bytes]) -> Iterator[ServerSentEvent]: + """Given an iterator that yields raw binary data, iterate over it & yield every event encountered""" + ... + + def aiter_bytes(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[ServerSentEvent]: + """Given an async iterator that yields raw binary data, iterate over it & yield every event encountered""" + ... + + +def is_stream_class_type(typ: type) -> TypeGuard[type[Stream[object]] | type[AsyncStream[object]]]: + """TypeGuard for determining whether or not the given type is a subclass of `Stream` / `AsyncStream`""" + origin = get_origin(typ) or typ + return inspect.isclass(origin) and issubclass(origin, (Stream, AsyncStream)) + + +def extract_stream_chunk_type( + stream_cls: type, + *, + failure_message: str | None = None, +) -> type: + """Given a type like `Stream[T]`, returns the generic type variable `T`. + + This also handles the case where a concrete subclass is given, e.g. + ```py + class MyStream(Stream[bytes]): + ... + + extract_stream_chunk_type(MyStream) -> bytes + ``` + """ + from ._base_client import Stream, AsyncStream + + return extract_type_var_from_base( + stream_cls, + index=0, + generic_bases=cast("tuple[type, ...]", (Stream, AsyncStream)), + failure_message=failure_message, + ) diff --git a/src/agentex/_types.py b/src/agentex/_types.py new file mode 100644 index 000000000..3b662b594 --- /dev/null +++ b/src/agentex/_types.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +from os import PathLike +from typing import ( + IO, + TYPE_CHECKING, + Any, + Dict, + List, + Type, + Tuple, + Union, + Mapping, + TypeVar, + Callable, + Iterable, + Iterator, + Optional, + Sequence, + AsyncIterable, +) +from typing_extensions import ( + Set, + Literal, + Protocol, + TypeAlias, + TypedDict, + SupportsIndex, + overload, + override, + runtime_checkable, +) + +import httpx +import pydantic +from httpx import URL, Proxy, Timeout, Response, BaseTransport, AsyncBaseTransport + +if TYPE_CHECKING: + from ._models import BaseModel + from ._response import APIResponse, AsyncAPIResponse + +Transport = BaseTransport +AsyncTransport = AsyncBaseTransport +Query = Mapping[str, object] +Body = object +AnyMapping = Mapping[str, object] +ModelT = TypeVar("ModelT", bound=pydantic.BaseModel) +_T = TypeVar("_T") + +ArrayFormat = Literal["comma", "repeat", "indices", "brackets"] +NestedFormat = Literal["dots", "brackets"] + + +# Approximates httpx internal ProxiesTypes and RequestFiles types +# while adding support for `PathLike` instances +ProxiesDict = Dict["str | URL", Union[None, str, URL, Proxy]] +ProxiesTypes = Union[str, Proxy, ProxiesDict] +if TYPE_CHECKING: + Base64FileInput = Union[IO[bytes], PathLike[str]] + FileContent = Union[IO[bytes], bytes, PathLike[str]] +else: + Base64FileInput = Union[IO[bytes], PathLike] + FileContent = Union[IO[bytes], bytes, PathLike] # PathLike is not subscriptable in Python 3.8. + + +# Used for sending raw binary data / streaming data in request bodies +# e.g. for file uploads without multipart encoding +BinaryTypes = Union[bytes, bytearray, IO[bytes], Iterable[bytes]] +AsyncBinaryTypes = Union[bytes, bytearray, IO[bytes], AsyncIterable[bytes]] + +FileTypes = Union[ + # file (or bytes) + FileContent, + # (filename, file (or bytes)) + Tuple[Optional[str], FileContent], + # (filename, file (or bytes), content_type) + Tuple[Optional[str], FileContent, Optional[str]], + # (filename, file (or bytes), content_type, headers) + Tuple[Optional[str], FileContent, Optional[str], Mapping[str, str]], +] +RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]] + +# duplicate of the above but without our custom file support +HttpxFileContent = Union[IO[bytes], bytes] +HttpxFileTypes = Union[ + # file (or bytes) + HttpxFileContent, + # (filename, file (or bytes)) + Tuple[Optional[str], HttpxFileContent], + # (filename, file (or bytes), content_type) + Tuple[Optional[str], HttpxFileContent, Optional[str]], + # (filename, file (or bytes), content_type, headers) + Tuple[Optional[str], HttpxFileContent, Optional[str], Mapping[str, str]], +] +HttpxRequestFiles = Union[Mapping[str, HttpxFileTypes], Sequence[Tuple[str, HttpxFileTypes]]] + +# Workaround to support (cast_to: Type[ResponseT]) -> ResponseT +# where ResponseT includes `None`. In order to support directly +# passing `None`, overloads would have to be defined for every +# method that uses `ResponseT` which would lead to an unacceptable +# amount of code duplication and make it unreadable. See _base_client.py +# for example usage. +# +# This unfortunately means that you will either have +# to import this type and pass it explicitly: +# +# from agentex import NoneType +# client.get('/foo', cast_to=NoneType) +# +# or build it yourself: +# +# client.get('/foo', cast_to=type(None)) +if TYPE_CHECKING: + NoneType: Type[None] +else: + NoneType = type(None) + + +class RequestOptions(TypedDict, total=False): + headers: Headers + max_retries: int + timeout: float | Timeout | None + params: Query + extra_json: AnyMapping + idempotency_key: str + follow_redirects: bool + + +# Sentinel class used until PEP 0661 is accepted +class NotGiven: + """ + For parameters with a meaningful None value, we need to distinguish between + the user explicitly passing None, and the user not passing the parameter at + all. + + User code shouldn't need to use not_given directly. + + For example: + + ```py + def create(timeout: Timeout | None | NotGiven = not_given): ... + + + create(timeout=1) # 1s timeout + create(timeout=None) # No timeout + create() # Default timeout behavior + ``` + """ + + def __bool__(self) -> Literal[False]: + return False + + @override + def __repr__(self) -> str: + return "NOT_GIVEN" + + +not_given = NotGiven() +# for backwards compatibility: +NOT_GIVEN = NotGiven() + + +class Omit: + """ + To explicitly omit something from being sent in a request, use `omit`. + + ```py + # as the default `Content-Type` header is `application/json` that will be sent + client.post("/upload/files", files={"file": b"my raw file content"}) + + # you can't explicitly override the header as it has to be dynamically generated + # to look something like: 'multipart/form-data; boundary=0d8382fcf5f8c3be01ca2e11002d2983' + client.post(..., headers={"Content-Type": "multipart/form-data"}) + + # instead you can remove the default `application/json` header by passing omit + client.post(..., headers={"Content-Type": omit}) + ``` + """ + + def __bool__(self) -> Literal[False]: + return False + + +omit = Omit() + + +@runtime_checkable +class ModelBuilderProtocol(Protocol): + @classmethod + def build( + cls: type[_T], + *, + response: Response, + data: object, + ) -> _T: ... + + +Headers = Mapping[str, Union[str, Omit]] + + +class HeadersLikeProtocol(Protocol): + def get(self, __key: str) -> str | None: ... + + +HeadersLike = Union[Headers, HeadersLikeProtocol] + +ResponseT = TypeVar( + "ResponseT", + bound=Union[ + object, + str, + None, + "BaseModel", + List[Any], + Dict[str, Any], + Response, + ModelBuilderProtocol, + "APIResponse[Any]", + "AsyncAPIResponse[Any]", + ], +) + +StrBytesIntFloat = Union[str, bytes, int, float] + +# Note: copied from Pydantic +# https://github.com/pydantic/pydantic/blob/6f31f8f68ef011f84357330186f603ff295312fd/pydantic/main.py#L79 +IncEx: TypeAlias = Union[Set[int], Set[str], Mapping[int, Union["IncEx", bool]], Mapping[str, Union["IncEx", bool]]] + +PostParser = Callable[[Any], Any] + + +@runtime_checkable +class InheritsGeneric(Protocol): + """Represents a type that has inherited from `Generic` + + The `__orig_bases__` property can be used to determine the resolved + type variable for a given base class. + """ + + __orig_bases__: tuple[_GenericAlias] + + +class _GenericAlias(Protocol): + __origin__: type[object] + + +class HttpxSendArgs(TypedDict, total=False): + auth: httpx.Auth + follow_redirects: bool + + +_T_co = TypeVar("_T_co", covariant=True) + + +if TYPE_CHECKING: + # This works because str.__contains__ does not accept object (either in typeshed or at runtime) + # https://github.com/hauntsaninja/useful_types/blob/5e9710f3875107d068e7679fd7fec9cfab0eff3b/useful_types/__init__.py#L285 + # + # Note: index() and count() methods are intentionally omitted to allow pyright to properly + # infer TypedDict types when dict literals are used in lists assigned to SequenceNotStr. + class SequenceNotStr(Protocol[_T_co]): + @overload + def __getitem__(self, index: SupportsIndex, /) -> _T_co: ... + @overload + def __getitem__(self, index: slice, /) -> Sequence[_T_co]: ... + def __contains__(self, value: object, /) -> bool: ... + def __len__(self) -> int: ... + def __iter__(self) -> Iterator[_T_co]: ... + def __reversed__(self) -> Iterator[_T_co]: ... +else: + # just point this to a normal `Sequence` at runtime to avoid having to special case + # deserializing our custom sequence type + SequenceNotStr = Sequence diff --git a/src/agentex/_utils/__init__.py b/src/agentex/_utils/__init__.py new file mode 100644 index 000000000..1c090e51f --- /dev/null +++ b/src/agentex/_utils/__init__.py @@ -0,0 +1,64 @@ +from ._path import path_template as path_template +from ._sync import asyncify as asyncify +from ._proxy import LazyProxy as LazyProxy +from ._utils import ( + flatten as flatten, + is_dict as is_dict, + is_list as is_list, + is_given as is_given, + is_tuple as is_tuple, + json_safe as json_safe, + lru_cache as lru_cache, + is_mapping as is_mapping, + is_tuple_t as is_tuple_t, + is_iterable as is_iterable, + is_sequence as is_sequence, + coerce_float as coerce_float, + is_mapping_t as is_mapping_t, + removeprefix as removeprefix, + removesuffix as removesuffix, + extract_files as extract_files, + is_sequence_t as is_sequence_t, + required_args as required_args, + coerce_boolean as coerce_boolean, + coerce_integer as coerce_integer, + file_from_path as file_from_path, + strip_not_given as strip_not_given, + get_async_library as get_async_library, + maybe_coerce_float as maybe_coerce_float, + get_required_header as get_required_header, + maybe_coerce_boolean as maybe_coerce_boolean, + maybe_coerce_integer as maybe_coerce_integer, +) +from ._compat import ( + get_args as get_args, + is_union as is_union, + get_origin as get_origin, + is_typeddict as is_typeddict, + is_literal_type as is_literal_type, +) +from ._typing import ( + is_list_type as is_list_type, + is_union_type as is_union_type, + extract_type_arg as extract_type_arg, + is_iterable_type as is_iterable_type, + is_required_type as is_required_type, + is_sequence_type as is_sequence_type, + is_annotated_type as is_annotated_type, + is_type_alias_type as is_type_alias_type, + strip_annotated_type as strip_annotated_type, + extract_type_var_from_base as extract_type_var_from_base, +) +from ._streams import consume_sync_iterator as consume_sync_iterator, consume_async_iterator as consume_async_iterator +from ._transform import ( + PropertyInfo as PropertyInfo, + transform as transform, + async_transform as async_transform, + maybe_transform as maybe_transform, + async_maybe_transform as async_maybe_transform, +) +from ._reflection import ( + function_has_argument as function_has_argument, + assert_signatures_in_sync as assert_signatures_in_sync, +) +from ._datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime diff --git a/src/agentex/_utils/_compat.py b/src/agentex/_utils/_compat.py new file mode 100644 index 000000000..2c70b299c --- /dev/null +++ b/src/agentex/_utils/_compat.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import sys +import typing_extensions +from typing import Any, Type, Union, Literal, Optional +from datetime import date, datetime +from typing_extensions import get_args as _get_args, get_origin as _get_origin + +from .._types import StrBytesIntFloat +from ._datetime_parse import parse_date as _parse_date, parse_datetime as _parse_datetime + +_LITERAL_TYPES = {Literal, typing_extensions.Literal} + + +def get_args(tp: type[Any]) -> tuple[Any, ...]: + return _get_args(tp) + + +def get_origin(tp: type[Any]) -> type[Any] | None: + return _get_origin(tp) + + +def is_union(tp: Optional[Type[Any]]) -> bool: + if sys.version_info < (3, 10): + return tp is Union # type: ignore[comparison-overlap] + else: + import types + + return tp is Union or tp is types.UnionType # type: ignore[comparison-overlap] + + +def is_typeddict(tp: Type[Any]) -> bool: + return typing_extensions.is_typeddict(tp) + + +def is_literal_type(tp: Type[Any]) -> bool: + return get_origin(tp) in _LITERAL_TYPES + + +def parse_date(value: Union[date, StrBytesIntFloat]) -> date: + return _parse_date(value) + + +def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime: + return _parse_datetime(value) diff --git a/src/agentex/_utils/_datetime_parse.py b/src/agentex/_utils/_datetime_parse.py new file mode 100644 index 000000000..7cb9d9e66 --- /dev/null +++ b/src/agentex/_utils/_datetime_parse.py @@ -0,0 +1,136 @@ +""" +This file contains code from https://github.com/pydantic/pydantic/blob/main/pydantic/v1/datetime_parse.py +without the Pydantic v1 specific errors. +""" + +from __future__ import annotations + +import re +from typing import Dict, Union, Optional +from datetime import date, datetime, timezone, timedelta + +from .._types import StrBytesIntFloat + +date_expr = r"(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})" +time_expr = ( + r"(?P\d{1,2}):(?P\d{1,2})" + r"(?::(?P\d{1,2})(?:\.(?P\d{1,6})\d{0,6})?)?" + r"(?PZ|[+-]\d{2}(?::?\d{2})?)?$" +) + +date_re = re.compile(f"{date_expr}$") +datetime_re = re.compile(f"{date_expr}[T ]{time_expr}") + + +EPOCH = datetime(1970, 1, 1) +# if greater than this, the number is in ms, if less than or equal it's in seconds +# (in seconds this is 11th October 2603, in ms it's 20th August 1970) +MS_WATERSHED = int(2e10) +# slightly more than datetime.max in ns - (datetime.max - EPOCH).total_seconds() * 1e9 +MAX_NUMBER = int(3e20) + + +def _get_numeric(value: StrBytesIntFloat, native_expected_type: str) -> Union[None, int, float]: + if isinstance(value, (int, float)): + return value + try: + return float(value) + except ValueError: + return None + except TypeError: + raise TypeError(f"invalid type; expected {native_expected_type}, string, bytes, int or float") from None + + +def _from_unix_seconds(seconds: Union[int, float]) -> datetime: + if seconds > MAX_NUMBER: + return datetime.max + elif seconds < -MAX_NUMBER: + return datetime.min + + while abs(seconds) > MS_WATERSHED: + seconds /= 1000 + dt = EPOCH + timedelta(seconds=seconds) + return dt.replace(tzinfo=timezone.utc) + + +def _parse_timezone(value: Optional[str]) -> Union[None, int, timezone]: + if value == "Z": + return timezone.utc + elif value is not None: + offset_mins = int(value[-2:]) if len(value) > 3 else 0 + offset = 60 * int(value[1:3]) + offset_mins + if value[0] == "-": + offset = -offset + return timezone(timedelta(minutes=offset)) + else: + return None + + +def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime: + """ + Parse a datetime/int/float/string and return a datetime.datetime. + + This function supports time zone offsets. When the input contains one, + the output uses a timezone with a fixed offset from UTC. + + Raise ValueError if the input is well formatted but not a valid datetime. + Raise ValueError if the input isn't well formatted. + """ + if isinstance(value, datetime): + return value + + number = _get_numeric(value, "datetime") + if number is not None: + return _from_unix_seconds(number) + + if isinstance(value, bytes): + value = value.decode() + + assert not isinstance(value, (float, int)) + + match = datetime_re.match(value) + if match is None: + raise ValueError("invalid datetime format") + + kw = match.groupdict() + if kw["microsecond"]: + kw["microsecond"] = kw["microsecond"].ljust(6, "0") + + tzinfo = _parse_timezone(kw.pop("tzinfo")) + kw_: Dict[str, Union[None, int, timezone]] = {k: int(v) for k, v in kw.items() if v is not None} + kw_["tzinfo"] = tzinfo + + return datetime(**kw_) # type: ignore + + +def parse_date(value: Union[date, StrBytesIntFloat]) -> date: + """ + Parse a date/int/float/string and return a datetime.date. + + Raise ValueError if the input is well formatted but not a valid date. + Raise ValueError if the input isn't well formatted. + """ + if isinstance(value, date): + if isinstance(value, datetime): + return value.date() + else: + return value + + number = _get_numeric(value, "date") + if number is not None: + return _from_unix_seconds(number).date() + + if isinstance(value, bytes): + value = value.decode() + + assert not isinstance(value, (float, int)) + match = date_re.match(value) + if match is None: + raise ValueError("invalid date format") + + kw = {k: int(v) for k, v in match.groupdict().items()} + + try: + return date(**kw) + except ValueError: + raise ValueError("invalid date format") from None diff --git a/src/agentex/_utils/_json.py b/src/agentex/_utils/_json.py new file mode 100644 index 000000000..60584214a --- /dev/null +++ b/src/agentex/_utils/_json.py @@ -0,0 +1,35 @@ +import json +from typing import Any +from datetime import datetime +from typing_extensions import override + +import pydantic + +from .._compat import model_dump + + +def openapi_dumps(obj: Any) -> bytes: + """ + Serialize an object to UTF-8 encoded JSON bytes. + + Extends the standard json.dumps with support for additional types + commonly used in the SDK, such as `datetime`, `pydantic.BaseModel`, etc. + """ + return json.dumps( + obj, + cls=_CustomEncoder, + # Uses the same defaults as httpx's JSON serialization + ensure_ascii=False, + separators=(",", ":"), + allow_nan=False, + ).encode() + + +class _CustomEncoder(json.JSONEncoder): + @override + def default(self, o: Any) -> Any: + if isinstance(o, datetime): + return o.isoformat() + if isinstance(o, pydantic.BaseModel): + return model_dump(o, exclude_unset=True, mode="json", by_alias=True) + return super().default(o) diff --git a/src/agentex/_utils/_logs.py b/src/agentex/_utils/_logs.py new file mode 100644 index 000000000..ecfc8eebd --- /dev/null +++ b/src/agentex/_utils/_logs.py @@ -0,0 +1,25 @@ +import os +import logging + +logger: logging.Logger = logging.getLogger("agentex") +httpx_logger: logging.Logger = logging.getLogger("httpx") + + +def _basic_config() -> None: + # e.g. [2023-10-05 14:12:26 - agentex._base_client:818 - DEBUG] HTTP Request: POST http://127.0.0.1:4010/foo/bar "200 OK" + logging.basicConfig( + format="[%(asctime)s - %(name)s:%(lineno)d - %(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +def setup_logging() -> None: + env = os.environ.get("AGENTEX_LOG") + if env == "debug": + _basic_config() + logger.setLevel(logging.DEBUG) + httpx_logger.setLevel(logging.DEBUG) + elif env == "info": + _basic_config() + logger.setLevel(logging.INFO) + httpx_logger.setLevel(logging.INFO) diff --git a/src/agentex/_utils/_path.py b/src/agentex/_utils/_path.py new file mode 100644 index 000000000..4d6e1e4cb --- /dev/null +++ b/src/agentex/_utils/_path.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import re +from typing import ( + Any, + Mapping, + Callable, +) +from urllib.parse import quote + +# Matches '.' or '..' where each dot is either literal or percent-encoded (%2e / %2E). +_DOT_SEGMENT_RE = re.compile(r"^(?:\.|%2[eE]){1,2}$") + +_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}") + + +def _quote_path_segment_part(value: str) -> str: + """Percent-encode `value` for use in a URI path segment. + + Considers characters not in `pchar` set from RFC 3986 §3.3 to be unsafe. + https://datatracker.ietf.org/doc/html/rfc3986#section-3.3 + """ + # quote() already treats unreserved characters (letters, digits, and -._~) + # as safe, so we only need to add sub-delims, ':', and '@'. + # Notably, unlike the default `safe` for quote(), / is unsafe and must be quoted. + return quote(value, safe="!$&'()*+,;=:@") + + +def _quote_query_part(value: str) -> str: + """Percent-encode `value` for use in a URI query string. + + Considers &, = and characters not in `query` set from RFC 3986 §3.4 to be unsafe. + https://datatracker.ietf.org/doc/html/rfc3986#section-3.4 + """ + return quote(value, safe="!$'()*+,;:@/?") + + +def _quote_fragment_part(value: str) -> str: + """Percent-encode `value` for use in a URI fragment. + + Considers characters not in `fragment` set from RFC 3986 §3.5 to be unsafe. + https://datatracker.ietf.org/doc/html/rfc3986#section-3.5 + """ + return quote(value, safe="!$&'()*+,;=:@/?") + + +def _interpolate( + template: str, + values: Mapping[str, Any], + quoter: Callable[[str], str], +) -> str: + """Replace {name} placeholders in `template`, quoting each value with `quoter`. + + Placeholder names are looked up in `values`. + + Raises: + KeyError: If a placeholder is not found in `values`. + """ + # re.split with a capturing group returns alternating + # [text, name, text, name, ..., text] elements. + parts = _PLACEHOLDER_RE.split(template) + + for i in range(1, len(parts), 2): + name = parts[i] + if name not in values: + raise KeyError(f"a value for placeholder {{{name}}} was not provided") + val = values[name] + if val is None: + parts[i] = "null" + elif isinstance(val, bool): + parts[i] = "true" if val else "false" + else: + parts[i] = quoter(str(values[name])) + + return "".join(parts) + + +def path_template(template: str, /, **kwargs: Any) -> str: + """Interpolate {name} placeholders in `template` from keyword arguments. + + Args: + template: The template string containing {name} placeholders. + **kwargs: Keyword arguments to interpolate into the template. + + Returns: + The template with placeholders interpolated and percent-encoded. + + Safe characters for percent-encoding are dependent on the URI component. + Placeholders in path and fragment portions are percent-encoded where the `segment` + and `fragment` sets from RFC 3986 respectively are considered safe. + Placeholders in the query portion are percent-encoded where the `query` set from + RFC 3986 §3.3 is considered safe except for = and & characters. + + Raises: + KeyError: If a placeholder is not found in `kwargs`. + ValueError: If resulting path contains /./ or /../ segments (including percent-encoded dot-segments). + """ + # Split the template into path, query, and fragment portions. + fragment_template: str | None = None + query_template: str | None = None + + rest = template + if "#" in rest: + rest, fragment_template = rest.split("#", 1) + if "?" in rest: + rest, query_template = rest.split("?", 1) + path_template = rest + + # Interpolate each portion with the appropriate quoting rules. + path_result = _interpolate(path_template, kwargs, _quote_path_segment_part) + + # Reject dot-segments (. and ..) in the final assembled path. The check + # runs after interpolation so that adjacent placeholders or a mix of static + # text and placeholders that together form a dot-segment are caught. + # Also reject percent-encoded dot-segments to protect against incorrectly + # implemented normalization in servers/proxies. + for segment in path_result.split("/"): + if _DOT_SEGMENT_RE.match(segment): + raise ValueError(f"Constructed path {path_result!r} contains dot-segment {segment!r} which is not allowed") + + result = path_result + if query_template is not None: + result += "?" + _interpolate(query_template, kwargs, _quote_query_part) + if fragment_template is not None: + result += "#" + _interpolate(fragment_template, kwargs, _quote_fragment_part) + + return result diff --git a/src/agentex/_utils/_proxy.py b/src/agentex/_utils/_proxy.py new file mode 100644 index 000000000..0f239a33c --- /dev/null +++ b/src/agentex/_utils/_proxy.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Generic, TypeVar, Iterable, cast +from typing_extensions import override + +T = TypeVar("T") + + +class LazyProxy(Generic[T], ABC): + """Implements data methods to pretend that an instance is another instance. + + This includes forwarding attribute access and other methods. + """ + + # Note: we have to special case proxies that themselves return proxies + # to support using a proxy as a catch-all for any random access, e.g. `proxy.foo.bar.baz` + + def __getattr__(self, attr: str) -> object: + proxied = self.__get_proxied__() + if isinstance(proxied, LazyProxy): + return proxied # pyright: ignore + return getattr(proxied, attr) + + @override + def __repr__(self) -> str: + proxied = self.__get_proxied__() + if isinstance(proxied, LazyProxy): + return proxied.__class__.__name__ + return repr(self.__get_proxied__()) + + @override + def __str__(self) -> str: + proxied = self.__get_proxied__() + if isinstance(proxied, LazyProxy): + return proxied.__class__.__name__ + return str(proxied) + + @override + def __dir__(self) -> Iterable[str]: + proxied = self.__get_proxied__() + if isinstance(proxied, LazyProxy): + return [] + return proxied.__dir__() + + @property # type: ignore + @override + def __class__(self) -> type: # pyright: ignore + try: + proxied = self.__get_proxied__() + except Exception: + return type(self) + if issubclass(type(proxied), LazyProxy): + return type(proxied) + return proxied.__class__ + + def __get_proxied__(self) -> T: + return self.__load__() + + def __as_proxied__(self) -> T: + """Helper method that returns the current proxy, typed as the loaded object""" + return cast(T, self) + + @abstractmethod + def __load__(self) -> T: ... diff --git a/src/agentex/_utils/_reflection.py b/src/agentex/_utils/_reflection.py new file mode 100644 index 000000000..89aa712ac --- /dev/null +++ b/src/agentex/_utils/_reflection.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import inspect +from typing import Any, Callable + + +def function_has_argument(func: Callable[..., Any], arg_name: str) -> bool: + """Returns whether or not the given function has a specific parameter""" + sig = inspect.signature(func) + return arg_name in sig.parameters + + +def assert_signatures_in_sync( + source_func: Callable[..., Any], + check_func: Callable[..., Any], + *, + exclude_params: set[str] = set(), +) -> None: + """Ensure that the signature of the second function matches the first.""" + + check_sig = inspect.signature(check_func) + source_sig = inspect.signature(source_func) + + errors: list[str] = [] + + for name, source_param in source_sig.parameters.items(): + if name in exclude_params: + continue + + custom_param = check_sig.parameters.get(name) + if not custom_param: + errors.append(f"the `{name}` param is missing") + continue + + if custom_param.annotation != source_param.annotation: + errors.append( + f"types for the `{name}` param are do not match; source={repr(source_param.annotation)} checking={repr(custom_param.annotation)}" + ) + continue + + if errors: + raise AssertionError(f"{len(errors)} errors encountered when comparing signatures:\n\n" + "\n\n".join(errors)) diff --git a/src/agentex/_utils/_resources_proxy.py b/src/agentex/_utils/_resources_proxy.py new file mode 100644 index 000000000..6ab8fc88b --- /dev/null +++ b/src/agentex/_utils/_resources_proxy.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import Any +from typing_extensions import override + +from ._proxy import LazyProxy + + +class ResourcesProxy(LazyProxy[Any]): + """A proxy for the `agentex.resources` module. + + This is used so that we can lazily import `agentex.resources` only when + needed *and* so that users can just import `agentex` and reference `agentex.resources` + """ + + @override + def __load__(self) -> Any: + import importlib + + mod = importlib.import_module("agentex.resources") + return mod + + +resources = ResourcesProxy().__as_proxied__() diff --git a/src/agentex/_utils/_streams.py b/src/agentex/_utils/_streams.py new file mode 100644 index 000000000..f4a0208f0 --- /dev/null +++ b/src/agentex/_utils/_streams.py @@ -0,0 +1,12 @@ +from typing import Any +from typing_extensions import Iterator, AsyncIterator + + +def consume_sync_iterator(iterator: Iterator[Any]) -> None: + for _ in iterator: + ... + + +async def consume_async_iterator(iterator: AsyncIterator[Any]) -> None: + async for _ in iterator: + ... diff --git a/src/agentex/_utils/_sync.py b/src/agentex/_utils/_sync.py new file mode 100644 index 000000000..f6027c183 --- /dev/null +++ b/src/agentex/_utils/_sync.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import asyncio +import functools +from typing import TypeVar, Callable, Awaitable +from typing_extensions import ParamSpec + +import anyio +import sniffio +import anyio.to_thread + +T_Retval = TypeVar("T_Retval") +T_ParamSpec = ParamSpec("T_ParamSpec") + + +async def to_thread( + func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs +) -> T_Retval: + if sniffio.current_async_library() == "asyncio": + return await asyncio.to_thread(func, *args, **kwargs) + + return await anyio.to_thread.run_sync( + functools.partial(func, *args, **kwargs), + ) + + +# inspired by `asyncer`, https://github.com/tiangolo/asyncer +def asyncify(function: Callable[T_ParamSpec, T_Retval]) -> Callable[T_ParamSpec, Awaitable[T_Retval]]: + """ + Take a blocking function and create an async one that receives the same + positional and keyword arguments. + + Usage: + + ```python + def blocking_func(arg1, arg2, kwarg1=None): + # blocking code + return result + + + result = asyncify(blocking_function)(arg1, arg2, kwarg1=value1) + ``` + + ## Arguments + + `function`: a blocking regular callable (e.g. a function) + + ## Return + + An async function that takes the same positional and keyword arguments as the + original one, that when called runs the same original function in a thread worker + and returns the result. + """ + + async def wrapper(*args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs) -> T_Retval: + return await to_thread(function, *args, **kwargs) + + return wrapper diff --git a/src/agentex/_utils/_transform.py b/src/agentex/_utils/_transform.py new file mode 100644 index 000000000..520754920 --- /dev/null +++ b/src/agentex/_utils/_transform.py @@ -0,0 +1,457 @@ +from __future__ import annotations + +import io +import base64 +import pathlib +from typing import Any, Mapping, TypeVar, cast +from datetime import date, datetime +from typing_extensions import Literal, get_args, override, get_type_hints as _get_type_hints + +import anyio +import pydantic + +from ._utils import ( + is_list, + is_given, + lru_cache, + is_mapping, + is_iterable, + is_sequence, +) +from .._files import is_base64_file_input +from ._compat import get_origin, is_typeddict +from ._typing import ( + is_list_type, + is_union_type, + extract_type_arg, + is_iterable_type, + is_required_type, + is_sequence_type, + is_annotated_type, + strip_annotated_type, +) + +_T = TypeVar("_T") + + +# TODO: support for drilling globals() and locals() +# TODO: ensure works correctly with forward references in all cases + + +PropertyFormat = Literal["iso8601", "base64", "custom"] + + +class PropertyInfo: + """Metadata class to be used in Annotated types to provide information about a given type. + + For example: + + class MyParams(TypedDict): + account_holder_name: Annotated[str, PropertyInfo(alias='accountHolderName')] + + This means that {'account_holder_name': 'Robert'} will be transformed to {'accountHolderName': 'Robert'} before being sent to the API. + """ + + alias: str | None + format: PropertyFormat | None + format_template: str | None + discriminator: str | None + + def __init__( + self, + *, + alias: str | None = None, + format: PropertyFormat | None = None, + format_template: str | None = None, + discriminator: str | None = None, + ) -> None: + self.alias = alias + self.format = format + self.format_template = format_template + self.discriminator = discriminator + + @override + def __repr__(self) -> str: + return f"{self.__class__.__name__}(alias='{self.alias}', format={self.format}, format_template='{self.format_template}', discriminator='{self.discriminator}')" + + +def maybe_transform( + data: object, + expected_type: object, +) -> Any | None: + """Wrapper over `transform()` that allows `None` to be passed. + + See `transform()` for more details. + """ + if data is None: + return None + return transform(data, expected_type) + + +# Wrapper over _transform_recursive providing fake types +def transform( + data: _T, + expected_type: object, +) -> _T: + """Transform dictionaries based off of type information from the given type, for example: + + ```py + class Params(TypedDict, total=False): + card_id: Required[Annotated[str, PropertyInfo(alias="cardID")]] + + + transformed = transform({"card_id": ""}, Params) + # {'cardID': ''} + ``` + + Any keys / data that does not have type information given will be included as is. + + It should be noted that the transformations that this function does are not represented in the type system. + """ + transformed = _transform_recursive(data, annotation=cast(type, expected_type)) + return cast(_T, transformed) + + +@lru_cache(maxsize=8096) +def _get_annotated_type(type_: type) -> type | None: + """If the given type is an `Annotated` type then it is returned, if not `None` is returned. + + This also unwraps the type when applicable, e.g. `Required[Annotated[T, ...]]` + """ + if is_required_type(type_): + # Unwrap `Required[Annotated[T, ...]]` to `Annotated[T, ...]` + type_ = get_args(type_)[0] + + if is_annotated_type(type_): + return type_ + + return None + + +def _maybe_transform_key(key: str, type_: type) -> str: + """Transform the given `data` based on the annotations provided in `type_`. + + Note: this function only looks at `Annotated` types that contain `PropertyInfo` metadata. + """ + annotated_type = _get_annotated_type(type_) + if annotated_type is None: + # no `Annotated` definition for this type, no transformation needed + return key + + # ignore the first argument as it is the actual type + annotations = get_args(annotated_type)[1:] + for annotation in annotations: + if isinstance(annotation, PropertyInfo) and annotation.alias is not None: + return annotation.alias + + return key + + +def _no_transform_needed(annotation: type) -> bool: + return annotation == float or annotation == int + + +def _transform_recursive( + data: object, + *, + annotation: type, + inner_type: type | None = None, +) -> object: + """Transform the given data against the expected type. + + Args: + annotation: The direct type annotation given to the particular piece of data. + This may or may not be wrapped in metadata types, e.g. `Required[T]`, `Annotated[T, ...]` etc + + inner_type: If applicable, this is the "inside" type. This is useful in certain cases where the outside type + is a container type such as `List[T]`. In that case `inner_type` should be set to `T` so that each entry in + the list can be transformed using the metadata from the container type. + + Defaults to the same value as the `annotation` argument. + """ + from .._compat import model_dump + + if inner_type is None: + inner_type = annotation + + stripped_type = strip_annotated_type(inner_type) + origin = get_origin(stripped_type) or stripped_type + if is_typeddict(stripped_type) and is_mapping(data): + return _transform_typeddict(data, stripped_type) + + if origin == dict and is_mapping(data): + items_type = get_args(stripped_type)[1] + return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} + + if ( + # List[T] + (is_list_type(stripped_type) and is_list(data)) + # Iterable[T] + or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str)) + # Sequence[T] + or (is_sequence_type(stripped_type) and is_sequence(data) and not isinstance(data, str)) + ): + # dicts are technically iterable, but it is an iterable on the keys of the dict and is not usually + # intended as an iterable, so we don't transform it. + if isinstance(data, dict): + return cast(object, data) + + inner_type = extract_type_arg(stripped_type, 0) + if _no_transform_needed(inner_type): + # for some types there is no need to transform anything, so we can get a small + # perf boost from skipping that work. + # + # but we still need to convert to a list to ensure the data is json-serializable + if is_list(data): + return data + return list(data) + + return [_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data] + + if is_union_type(stripped_type): + # For union types we run the transformation against all subtypes to ensure that everything is transformed. + # + # TODO: there may be edge cases where the same normalized field name will transform to two different names + # in different subtypes. + for subtype in get_args(stripped_type): + data = _transform_recursive(data, annotation=annotation, inner_type=subtype) + return data + + if isinstance(data, pydantic.BaseModel): + return model_dump(data, exclude_unset=True, mode="json") + + annotated_type = _get_annotated_type(annotation) + if annotated_type is None: + return data + + # ignore the first argument as it is the actual type + annotations = get_args(annotated_type)[1:] + for annotation in annotations: + if isinstance(annotation, PropertyInfo) and annotation.format is not None: + return _format_data(data, annotation.format, annotation.format_template) + + return data + + +def _format_data(data: object, format_: PropertyFormat, format_template: str | None) -> object: + if isinstance(data, (date, datetime)): + if format_ == "iso8601": + return data.isoformat() + + if format_ == "custom" and format_template is not None: + return data.strftime(format_template) + + if format_ == "base64" and is_base64_file_input(data): + binary: str | bytes | None = None + + if isinstance(data, pathlib.Path): + binary = data.read_bytes() + elif isinstance(data, io.IOBase): + binary = data.read() + + if isinstance(binary, str): # type: ignore[unreachable] + binary = binary.encode() + + if not isinstance(binary, bytes): + raise RuntimeError(f"Could not read bytes from {data}; Received {type(binary)}") + + return base64.b64encode(binary).decode("ascii") + + return data + + +def _transform_typeddict( + data: Mapping[str, object], + expected_type: type, +) -> Mapping[str, object]: + result: dict[str, object] = {} + annotations = get_type_hints(expected_type, include_extras=True) + for key, value in data.items(): + if not is_given(value): + # we don't need to include omitted values here as they'll + # be stripped out before the request is sent anyway + continue + + type_ = annotations.get(key) + if type_ is None: + # we do not have a type annotation for this field, leave it as is + result[key] = value + else: + result[_maybe_transform_key(key, type_)] = _transform_recursive(value, annotation=type_) + return result + + +async def async_maybe_transform( + data: object, + expected_type: object, +) -> Any | None: + """Wrapper over `async_transform()` that allows `None` to be passed. + + See `async_transform()` for more details. + """ + if data is None: + return None + return await async_transform(data, expected_type) + + +async def async_transform( + data: _T, + expected_type: object, +) -> _T: + """Transform dictionaries based off of type information from the given type, for example: + + ```py + class Params(TypedDict, total=False): + card_id: Required[Annotated[str, PropertyInfo(alias="cardID")]] + + + transformed = transform({"card_id": ""}, Params) + # {'cardID': ''} + ``` + + Any keys / data that does not have type information given will be included as is. + + It should be noted that the transformations that this function does are not represented in the type system. + """ + transformed = await _async_transform_recursive(data, annotation=cast(type, expected_type)) + return cast(_T, transformed) + + +async def _async_transform_recursive( + data: object, + *, + annotation: type, + inner_type: type | None = None, +) -> object: + """Transform the given data against the expected type. + + Args: + annotation: The direct type annotation given to the particular piece of data. + This may or may not be wrapped in metadata types, e.g. `Required[T]`, `Annotated[T, ...]` etc + + inner_type: If applicable, this is the "inside" type. This is useful in certain cases where the outside type + is a container type such as `List[T]`. In that case `inner_type` should be set to `T` so that each entry in + the list can be transformed using the metadata from the container type. + + Defaults to the same value as the `annotation` argument. + """ + from .._compat import model_dump + + if inner_type is None: + inner_type = annotation + + stripped_type = strip_annotated_type(inner_type) + origin = get_origin(stripped_type) or stripped_type + if is_typeddict(stripped_type) and is_mapping(data): + return await _async_transform_typeddict(data, stripped_type) + + if origin == dict and is_mapping(data): + items_type = get_args(stripped_type)[1] + return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} + + if ( + # List[T] + (is_list_type(stripped_type) and is_list(data)) + # Iterable[T] + or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str)) + # Sequence[T] + or (is_sequence_type(stripped_type) and is_sequence(data) and not isinstance(data, str)) + ): + # dicts are technically iterable, but it is an iterable on the keys of the dict and is not usually + # intended as an iterable, so we don't transform it. + if isinstance(data, dict): + return cast(object, data) + + inner_type = extract_type_arg(stripped_type, 0) + if _no_transform_needed(inner_type): + # for some types there is no need to transform anything, so we can get a small + # perf boost from skipping that work. + # + # but we still need to convert to a list to ensure the data is json-serializable + if is_list(data): + return data + return list(data) + + return [await _async_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data] + + if is_union_type(stripped_type): + # For union types we run the transformation against all subtypes to ensure that everything is transformed. + # + # TODO: there may be edge cases where the same normalized field name will transform to two different names + # in different subtypes. + for subtype in get_args(stripped_type): + data = await _async_transform_recursive(data, annotation=annotation, inner_type=subtype) + return data + + if isinstance(data, pydantic.BaseModel): + return model_dump(data, exclude_unset=True, mode="json") + + annotated_type = _get_annotated_type(annotation) + if annotated_type is None: + return data + + # ignore the first argument as it is the actual type + annotations = get_args(annotated_type)[1:] + for annotation in annotations: + if isinstance(annotation, PropertyInfo) and annotation.format is not None: + return await _async_format_data(data, annotation.format, annotation.format_template) + + return data + + +async def _async_format_data(data: object, format_: PropertyFormat, format_template: str | None) -> object: + if isinstance(data, (date, datetime)): + if format_ == "iso8601": + return data.isoformat() + + if format_ == "custom" and format_template is not None: + return data.strftime(format_template) + + if format_ == "base64" and is_base64_file_input(data): + binary: str | bytes | None = None + + if isinstance(data, pathlib.Path): + binary = await anyio.Path(data).read_bytes() + elif isinstance(data, io.IOBase): + binary = data.read() + + if isinstance(binary, str): # type: ignore[unreachable] + binary = binary.encode() + + if not isinstance(binary, bytes): + raise RuntimeError(f"Could not read bytes from {data}; Received {type(binary)}") + + return base64.b64encode(binary).decode("ascii") + + return data + + +async def _async_transform_typeddict( + data: Mapping[str, object], + expected_type: type, +) -> Mapping[str, object]: + result: dict[str, object] = {} + annotations = get_type_hints(expected_type, include_extras=True) + for key, value in data.items(): + if not is_given(value): + # we don't need to include omitted values here as they'll + # be stripped out before the request is sent anyway + continue + + type_ = annotations.get(key) + if type_ is None: + # we do not have a type annotation for this field, leave it as is + result[key] = value + else: + result[_maybe_transform_key(key, type_)] = await _async_transform_recursive(value, annotation=type_) + return result + + +@lru_cache(maxsize=8096) +def get_type_hints( + obj: Any, + globalns: dict[str, Any] | None = None, + localns: Mapping[str, Any] | None = None, + include_extras: bool = False, +) -> dict[str, Any]: + return _get_type_hints(obj, globalns=globalns, localns=localns, include_extras=include_extras) diff --git a/src/agentex/_utils/_typing.py b/src/agentex/_utils/_typing.py new file mode 100644 index 000000000..e548aa2df --- /dev/null +++ b/src/agentex/_utils/_typing.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import sys +import typing +import typing_extensions +from typing import Any, TypeVar, Iterable, cast +from collections import abc as _c_abc +from typing_extensions import ( + TypeIs, + Required, + Annotated, + get_args, + get_origin, +) + +from ._utils import lru_cache +from .._types import InheritsGeneric +from ._compat import is_union as _is_union + + +def is_annotated_type(typ: type) -> bool: + return get_origin(typ) == Annotated + + +def is_list_type(typ: type) -> bool: + return (get_origin(typ) or typ) == list + + +def is_sequence_type(typ: type) -> bool: + origin = get_origin(typ) or typ + return origin == typing_extensions.Sequence or origin == typing.Sequence or origin == _c_abc.Sequence + + +def is_iterable_type(typ: type) -> bool: + """If the given type is `typing.Iterable[T]`""" + origin = get_origin(typ) or typ + return origin == Iterable or origin == _c_abc.Iterable + + +def is_union_type(typ: type) -> bool: + return _is_union(get_origin(typ)) + + +def is_required_type(typ: type) -> bool: + return get_origin(typ) == Required + + +def is_typevar(typ: type) -> bool: + # type ignore is required because type checkers + # think this expression will always return False + return type(typ) == TypeVar # type: ignore + + +_TYPE_ALIAS_TYPES: tuple[type[typing_extensions.TypeAliasType], ...] = (typing_extensions.TypeAliasType,) +if sys.version_info >= (3, 12): + # NOTE: This type ignore will be overwritten by Stainless generator. + # TODO: Update Stainless config to include this type ignore or move to lib/ + _TYPE_ALIAS_TYPES = (*_TYPE_ALIAS_TYPES, typing.TypeAliasType) # type: ignore[assignment] + + +def is_type_alias_type(tp: Any, /) -> TypeIs[typing_extensions.TypeAliasType]: + """Return whether the provided argument is an instance of `TypeAliasType`. + + ```python + type Int = int + is_type_alias_type(Int) + # > True + Str = TypeAliasType("Str", str) + is_type_alias_type(Str) + # > True + ``` + """ + return isinstance(tp, _TYPE_ALIAS_TYPES) + + +# Extracts T from Annotated[T, ...] or from Required[Annotated[T, ...]] +@lru_cache(maxsize=8096) +def strip_annotated_type(typ: type) -> type: + if is_required_type(typ) or is_annotated_type(typ): + return strip_annotated_type(cast(type, get_args(typ)[0])) + + return typ + + +def extract_type_arg(typ: type, index: int) -> type: + args = get_args(typ) + try: + return cast(type, args[index]) + except IndexError as err: + raise RuntimeError(f"Expected type {typ} to have a type argument at index {index} but it did not") from err + + +def extract_type_var_from_base( + typ: type, + *, + generic_bases: tuple[type, ...], + index: int, + failure_message: str | None = None, +) -> type: + """Given a type like `Foo[T]`, returns the generic type variable `T`. + + This also handles the case where a concrete subclass is given, e.g. + ```py + class MyResponse(Foo[bytes]): + ... + + extract_type_var(MyResponse, bases=(Foo,), index=0) -> bytes + ``` + + And where a generic subclass is given: + ```py + _T = TypeVar('_T') + class MyResponse(Foo[_T]): + ... + + extract_type_var(MyResponse[bytes], bases=(Foo,), index=0) -> bytes + ``` + """ + cls = cast(object, get_origin(typ) or typ) + if cls in generic_bases: # pyright: ignore[reportUnnecessaryContains] + # we're given the class directly + return extract_type_arg(typ, index) + + # if a subclass is given + # --- + # this is needed as __orig_bases__ is not present in the typeshed stubs + # because it is intended to be for internal use only, however there does + # not seem to be a way to resolve generic TypeVars for inherited subclasses + # without using it. + if isinstance(cls, InheritsGeneric): + target_base_class: Any | None = None + for base in cls.__orig_bases__: + if base.__origin__ in generic_bases: + target_base_class = base + break + + if target_base_class is None: + raise RuntimeError( + "Could not find the generic base class;\n" + "This should never happen;\n" + f"Does {cls} inherit from one of {generic_bases} ?" + ) + + extracted = extract_type_arg(target_base_class, index) + if is_typevar(extracted): + # If the extracted type argument is itself a type variable + # then that means the subclass itself is generic, so we have + # to resolve the type argument from the class itself, not + # the base class. + # + # Note: if there is more than 1 type argument, the subclass could + # change the ordering of the type arguments, this is not currently + # supported. + return extract_type_arg(typ, index) + + return extracted + + raise RuntimeError(failure_message or f"Could not resolve inner type variable at index {index} for {typ}") diff --git a/src/agentex/_utils/_utils.py b/src/agentex/_utils/_utils.py new file mode 100644 index 000000000..199cd231f --- /dev/null +++ b/src/agentex/_utils/_utils.py @@ -0,0 +1,433 @@ +from __future__ import annotations + +import os +import re +import inspect +import functools +from typing import ( + Any, + Tuple, + Mapping, + TypeVar, + Callable, + Iterable, + Sequence, + cast, + overload, +) +from pathlib import Path +from datetime import date, datetime +from typing_extensions import TypeGuard, get_args + +import sniffio + +from .._types import Omit, NotGiven, FileTypes, ArrayFormat, HeadersLike + +_T = TypeVar("_T") +_TupleT = TypeVar("_TupleT", bound=Tuple[object, ...]) +_MappingT = TypeVar("_MappingT", bound=Mapping[str, object]) +_SequenceT = TypeVar("_SequenceT", bound=Sequence[object]) +CallableT = TypeVar("CallableT", bound=Callable[..., Any]) + + +def flatten(t: Iterable[Iterable[_T]]) -> list[_T]: + return [item for sublist in t for item in sublist] + + +def extract_files( + # TODO: this needs to take Dict but variance issues..... + # create protocol type ? + query: Mapping[str, object], + *, + paths: Sequence[Sequence[str]], + array_format: ArrayFormat = "brackets", +) -> list[tuple[str, FileTypes]]: + """Recursively extract files from the given dictionary based on specified paths. + + A path may look like this ['foo', 'files', '', 'data']. + + ``array_format`` controls how ```` segments contribute to the emitted + field name. Supported values: ``"brackets"`` (``foo[]``), ``"repeat"`` and + ``"comma"`` (``foo``), ``"indices"`` (``foo[0]``, ``foo[1]``). + + Note: this mutates the given dictionary. + """ + files: list[tuple[str, FileTypes]] = [] + for path in paths: + files.extend(_extract_items(query, path, index=0, flattened_key=None, array_format=array_format)) + return files + + +def _array_suffix(array_format: ArrayFormat, array_index: int) -> str: + if array_format == "brackets": + return "[]" + if array_format == "indices": + return f"[{array_index}]" + if array_format == "repeat" or array_format == "comma": + # Both repeat the bare field name for each file part; there is no + # meaningful way to comma-join binary parts. + return "" + raise NotImplementedError( + f"Unknown array_format value: {array_format}, choose from {', '.join(get_args(ArrayFormat))}" + ) + + +def _extract_items( + obj: object, + path: Sequence[str], + *, + index: int, + flattened_key: str | None, + array_format: ArrayFormat, +) -> list[tuple[str, FileTypes]]: + try: + key = path[index] + except IndexError: + if not is_given(obj): + # no value was provided - we can safely ignore + return [] + + # cyclical import + from .._files import assert_is_file_content + + # We have exhausted the path, return the entry we found. + assert flattened_key is not None + + if is_list(obj): + files: list[tuple[str, FileTypes]] = [] + for array_index, entry in enumerate(obj): + suffix = _array_suffix(array_format, array_index) + emitted_key = (flattened_key + suffix) if flattened_key else suffix + assert_is_file_content(entry, key=emitted_key) + files.append((emitted_key, cast(FileTypes, entry))) + return files + + assert_is_file_content(obj, key=flattened_key) + return [(flattened_key, cast(FileTypes, obj))] + + index += 1 + if is_dict(obj): + try: + # Remove the field if there are no more dict keys in the path, + # only "" traversal markers or end. + if all(p == "" for p in path[index:]): + item = obj.pop(key) + else: + item = obj[key] + except KeyError: + # Key was not present in the dictionary, this is not indicative of an error + # as the given path may not point to a required field. We also do not want + # to enforce required fields as the API may differ from the spec in some cases. + return [] + if flattened_key is None: + flattened_key = key + else: + flattened_key += f"[{key}]" + return _extract_items( + item, + path, + index=index, + flattened_key=flattened_key, + array_format=array_format, + ) + elif is_list(obj): + if key != "": + return [] + + return flatten( + [ + _extract_items( + item, + path, + index=index, + flattened_key=( + (flattened_key if flattened_key is not None else "") + _array_suffix(array_format, array_index) + ), + array_format=array_format, + ) + for array_index, item in enumerate(obj) + ] + ) + + # Something unexpected was passed, just ignore it. + return [] + + +def is_given(obj: _T | NotGiven | Omit) -> TypeGuard[_T]: + return not isinstance(obj, NotGiven) and not isinstance(obj, Omit) + + +# Type safe methods for narrowing types with TypeVars. +# The default narrowing for isinstance(obj, dict) is dict[unknown, unknown], +# however this cause Pyright to rightfully report errors. As we know we don't +# care about the contained types we can safely use `object` in its place. +# +# There are two separate functions defined, `is_*` and `is_*_t` for different use cases. +# `is_*` is for when you're dealing with an unknown input +# `is_*_t` is for when you're narrowing a known union type to a specific subset + + +def is_tuple(obj: object) -> TypeGuard[tuple[object, ...]]: + return isinstance(obj, tuple) + + +def is_tuple_t(obj: _TupleT | object) -> TypeGuard[_TupleT]: + return isinstance(obj, tuple) + + +def is_sequence(obj: object) -> TypeGuard[Sequence[object]]: + return isinstance(obj, Sequence) + + +def is_sequence_t(obj: _SequenceT | object) -> TypeGuard[_SequenceT]: + return isinstance(obj, Sequence) + + +def is_mapping(obj: object) -> TypeGuard[Mapping[str, object]]: + return isinstance(obj, Mapping) + + +def is_mapping_t(obj: _MappingT | object) -> TypeGuard[_MappingT]: + return isinstance(obj, Mapping) + + +def is_dict(obj: object) -> TypeGuard[dict[object, object]]: + return isinstance(obj, dict) + + +def is_list(obj: object) -> TypeGuard[list[object]]: + return isinstance(obj, list) + + +def is_iterable(obj: object) -> TypeGuard[Iterable[object]]: + return isinstance(obj, Iterable) + + +# copied from https://github.com/Rapptz/RoboDanny +def human_join(seq: Sequence[str], *, delim: str = ", ", final: str = "or") -> str: + size = len(seq) + if size == 0: + return "" + + if size == 1: + return seq[0] + + if size == 2: + return f"{seq[0]} {final} {seq[1]}" + + return delim.join(seq[:-1]) + f" {final} {seq[-1]}" + + +def quote(string: str) -> str: + """Add single quotation marks around the given string. Does *not* do any escaping.""" + return f"'{string}'" + + +def required_args(*variants: Sequence[str]) -> Callable[[CallableT], CallableT]: + """Decorator to enforce a given set of arguments or variants of arguments are passed to the decorated function. + + Useful for enforcing runtime validation of overloaded functions. + + Example usage: + ```py + @overload + def foo(*, a: str) -> str: ... + + + @overload + def foo(*, b: bool) -> str: ... + + + # This enforces the same constraints that a static type checker would + # i.e. that either a or b must be passed to the function + @required_args(["a"], ["b"]) + def foo(*, a: str | None = None, b: bool | None = None) -> str: ... + ``` + """ + + def inner(func: CallableT) -> CallableT: + params = inspect.signature(func).parameters + positional = [ + name + for name, param in params.items() + if param.kind + in { + param.POSITIONAL_ONLY, + param.POSITIONAL_OR_KEYWORD, + } + ] + + @functools.wraps(func) + def wrapper(*args: object, **kwargs: object) -> object: + given_params: set[str] = set() + for i, _ in enumerate(args): + try: + given_params.add(positional[i]) + except IndexError: + raise TypeError( + f"{func.__name__}() takes {len(positional)} argument(s) but {len(args)} were given" + ) from None + + for key in kwargs.keys(): + given_params.add(key) + + for variant in variants: + matches = all((param in given_params for param in variant)) + if matches: + break + else: # no break + if len(variants) > 1: + variations = human_join( + ["(" + human_join([quote(arg) for arg in variant], final="and") + ")" for variant in variants] + ) + msg = f"Missing required arguments; Expected either {variations} arguments to be given" + else: + assert len(variants) > 0 + + # TODO: this error message is not deterministic + missing = list(set(variants[0]) - given_params) + if len(missing) > 1: + msg = f"Missing required arguments: {human_join([quote(arg) for arg in missing])}" + else: + msg = f"Missing required argument: {quote(missing[0])}" + raise TypeError(msg) + return func(*args, **kwargs) + + return wrapper # type: ignore + + return inner + + +_K = TypeVar("_K") +_V = TypeVar("_V") + + +@overload +def strip_not_given(obj: None) -> None: ... + + +@overload +def strip_not_given(obj: Mapping[_K, _V | NotGiven]) -> dict[_K, _V]: ... + + +@overload +def strip_not_given(obj: object) -> object: ... + + +def strip_not_given(obj: object | None) -> object: + """Remove all top-level keys where their values are instances of `NotGiven`""" + if obj is None: + return None + + if not is_mapping(obj): + return obj + + return {key: value for key, value in obj.items() if not isinstance(value, NotGiven)} + + +def coerce_integer(val: str) -> int: + return int(val, base=10) + + +def coerce_float(val: str) -> float: + return float(val) + + +def coerce_boolean(val: str) -> bool: + return val == "true" or val == "1" or val == "on" + + +def maybe_coerce_integer(val: str | None) -> int | None: + if val is None: + return None + return coerce_integer(val) + + +def maybe_coerce_float(val: str | None) -> float | None: + if val is None: + return None + return coerce_float(val) + + +def maybe_coerce_boolean(val: str | None) -> bool | None: + if val is None: + return None + return coerce_boolean(val) + + +def removeprefix(string: str, prefix: str) -> str: + """Remove a prefix from a string. + + Backport of `str.removeprefix` for Python < 3.9 + """ + if string.startswith(prefix): + return string[len(prefix) :] + return string + + +def removesuffix(string: str, suffix: str) -> str: + """Remove a suffix from a string. + + Backport of `str.removesuffix` for Python < 3.9 + """ + if string.endswith(suffix): + return string[: -len(suffix)] + return string + + +def file_from_path(path: str) -> FileTypes: + contents = Path(path).read_bytes() + file_name = os.path.basename(path) + return (file_name, contents) + + +def get_required_header(headers: HeadersLike, header: str) -> str: + lower_header = header.lower() + if is_mapping_t(headers): + # mypy doesn't understand the type narrowing here + for k, v in headers.items(): # type: ignore + if k.lower() == lower_header and isinstance(v, str): + return v + + # to deal with the case where the header looks like Stainless-Event-Id + intercaps_header = re.sub(r"([^\w])(\w)", lambda pat: pat.group(1) + pat.group(2).upper(), header.capitalize()) + + for normalized_header in [header, lower_header, header.upper(), intercaps_header]: + value = headers.get(normalized_header) + if value: + return value + + raise ValueError(f"Could not find {header} header") + + +def get_async_library() -> str: + try: + return sniffio.current_async_library() + except Exception: + return "false" + + +def lru_cache(*, maxsize: int | None = 128) -> Callable[[CallableT], CallableT]: + """A version of functools.lru_cache that retains the type signature + for the wrapped function arguments. + """ + wrapper = functools.lru_cache( # noqa: TID251 + maxsize=maxsize, + ) + return cast(Any, wrapper) # type: ignore[no-any-return] + + +def json_safe(data: object) -> object: + """Translates a mapping / sequence recursively in the same fashion + as `pydantic` v2's `model_dump(mode="json")`. + """ + if is_mapping(data): + return {json_safe(key): json_safe(value) for key, value in data.items()} + + if is_iterable(data) and not isinstance(data, (str, bytes, bytearray)): + return [json_safe(item) for item in data] + + if isinstance(data, (datetime, date)): + return data.isoformat() + + return data diff --git a/src/agentex/_version.py b/src/agentex/_version.py new file mode 100644 index 000000000..34aa48bd8 --- /dev/null +++ b/src/agentex/_version.py @@ -0,0 +1,4 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +__title__ = "agentex" +__version__ = "0.26.0" # x-release-please-version diff --git a/src/agentex/config/__init__.py b/src/agentex/config/__init__.py new file mode 100644 index 000000000..1174c2fdf --- /dev/null +++ b/src/agentex/config/__init__.py @@ -0,0 +1,14 @@ +"""Deployment & agent configuration shapes for Agentex. + +The modules under `agentex.config.*` are the typed manifest/deployment +configuration models (agent, build, deployment, environment, local-dev) plus +their leaf model deps (credentials, temporal). They depend only on pydantic, +so they are safe to import from a slim REST-only install without the ADK +runtime. + +For back-compat, the same classes are re-exported from their historical +locations under `agentex.lib.sdk.config.*` and +`agentex.lib.types.{agent_configs,credentials}`. The yaml-loading helpers +(`load_environments_config*`) stay in `agentex.lib.sdk.config.environment_config` +so the promoted models remain slim-safe. +""" diff --git a/src/agentex/config/_base.py b/src/agentex/config/_base.py new file mode 100644 index 000000000..74dafd7a1 --- /dev/null +++ b/src/agentex/config/_base.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel, ConfigDict + + +class ConfigBaseModel(BaseModel): + # Preserves the config the former agentex.lib.utils.model_utils.BaseModel + # applied; deployment_config's `global` alias relies on populate_by_name. + model_config = ConfigDict(from_attributes=True, populate_by_name=True) diff --git a/src/agentex/config/agent_config.py b/src/agentex/config/agent_config.py new file mode 100644 index 000000000..e06022c0c --- /dev/null +++ b/src/agentex/config/agent_config.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import Field + +from agentex.config._base import ConfigBaseModel +from agentex.config.credentials import CredentialMapping +from agentex.config.agent_configs import TemporalConfig, TemporalWorkflowConfig + + +class AgentConfig(ConfigBaseModel): + name: str = Field( + ..., + description="The name of the agent.", + pattern=r"^[a-z0-9-]+$", + ) + acp_type: Literal["sync", "async", "agentic"] = Field(..., description="The type of agent.") + agent_input_type: Literal["text", "json"] | None = Field( + default=None, + description="The type of input the agent accepts." + ) + description: str = Field(..., description="The description of the agent.") + env: dict[str, str] | None = Field( + default=None, description="Environment variables to set directly in the agent deployment" + ) + credentials: list[CredentialMapping | dict[str, Any]] | None = Field( + default=None, + description="List of credential mappings to mount to the agent deployment. Supports both legacy format and new typed credentials.", + ) + temporal: TemporalConfig | None = Field( + default=None, description="Temporal workflow configuration for this agent" + ) + + def is_temporal_agent(self) -> bool: + """Check if this agent uses Temporal workflows""" + # Check temporal config with enabled flag + if self.temporal and self.temporal.enabled: + return True + return False + + def get_temporal_workflow_config(self) -> TemporalWorkflowConfig | None: + """Get temporal workflow configuration, checking both new and legacy formats""" + # Check new workflows list first + if self.temporal and self.temporal.enabled and self.temporal.workflows: + return self.temporal.workflows[0] # Return first workflow for backward compatibility + + # Check legacy single workflow + if self.temporal and self.temporal.enabled and self.temporal.workflow: + return self.temporal.workflow + + return None + + def get_temporal_workflows(self) -> list[TemporalWorkflowConfig]: + """Get all temporal workflow configurations""" + # Check new workflows list first + if self.temporal and self.temporal.enabled and self.temporal.workflows: + return self.temporal.workflows + + # Check legacy single workflow + if self.temporal and self.temporal.enabled and self.temporal.workflow: + return [self.temporal.workflow] + + return [] diff --git a/src/agentex/config/agent_configs.py b/src/agentex/config/agent_configs.py new file mode 100644 index 000000000..e815f3a23 --- /dev/null +++ b/src/agentex/config/agent_configs.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from pydantic import Field, BaseModel, field_validator, model_validator + + +class TemporalWorkflowConfig(BaseModel): + """ + Configuration for the temporal workflow that defines the agent. + + Attributes: + name: The name of the temporal workflow that defines the agent. + queue_name: The name of the temporal queue to send tasks to. + """ + + name: str = Field( + ..., description="The name of the temporal workflow that defines the agent." + ) + queue_name: str = Field( + ..., description="The name of the temporal queue to send tasks to." + ) + + +# TODO: Remove this class when we remove the agentex agents create +class TemporalWorkerConfig(BaseModel): + """ + Configuration for temporal worker deployment + + Attributes: + image: The image to use for the temporal worker + workflow: The temporal workflow configuration + """ + + image: str | None = Field( + default=None, description="Image to use for the temporal worker" + ) + workflow: TemporalWorkflowConfig | None = Field( + default=None, + description="Configuration for the temporal workflow that defines the agent. Only required for agents that leverage Temporal.", + ) + + +class TemporalConfig(BaseModel): + """ + Simplified temporal configuration for agents + + Attributes: + enabled: Whether this agent uses Temporal workflows + workflow: The temporal workflow configuration + workflows: The list of temporal workflow configurations + health_check_port: Port for temporal worker health check endpoint + """ + + enabled: bool = Field( + default=False, description="Whether this agent uses Temporal workflows" + ) + workflow: TemporalWorkflowConfig | None = Field( + default=None, + description="Temporal workflow configuration. Required when enabled=True. (deprecated: use workflows instead)", + ) + workflows: list[TemporalWorkflowConfig] | None = Field( + default=None, + description="List of temporal workflow configurations. Used when enabled=true.", + ) + health_check_port: int | None = Field( + default=None, + description="Port for temporal worker health check endpoint. Defaults to 80 if not specified.", + ) + + @field_validator("workflows") + @classmethod + def validate_workflows_not_empty(cls, v): + """Ensure workflows list is not empty when provided""" + if v is not None and len(v) == 0: + raise ValueError("workflows list cannot be empty when provided") + return v + + @model_validator(mode="after") + def validate_temporal_config_when_enabled(self): + """Validate that workflow configuration exists when enabled=true""" + if self.enabled: + # Must have either workflow (legacy) or workflows (new) + if not self.workflow and (not self.workflows or len(self.workflows) == 0): + raise ValueError( + "When temporal.enabled=true, either 'workflow' or 'workflows' must be provided and non-empty" + ) + + return self diff --git a/src/agentex/config/agent_manifest.py b/src/agentex/config/agent_manifest.py new file mode 100644 index 000000000..6d727479b --- /dev/null +++ b/src/agentex/config/agent_manifest.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from pydantic import Field + +from agentex.config._base import ConfigBaseModel +from agentex.config.agent_config import AgentConfig +from agentex.config.build_config import BuildConfig +from agentex.config.deployment_config import DeploymentConfig +from agentex.config.local_development_config import LocalDevelopmentConfig + + +class AgentManifest(ConfigBaseModel): + """ + Represents a manifest file that describes how to build and deploy an agent. + """ + + build: BuildConfig + agent: AgentConfig + local_development: LocalDevelopmentConfig | None = Field( + default=None, description="Configuration for local development" + ) + deployment: DeploymentConfig | None = Field( + default=None, description="Deployment configuration for the agent" + ) diff --git a/src/agentex/config/build_config.py b/src/agentex/config/build_config.py new file mode 100644 index 000000000..bf4a0b309 --- /dev/null +++ b/src/agentex/config/build_config.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from pydantic import Field + +from agentex.config._base import ConfigBaseModel + + +class BuildContext(ConfigBaseModel): + """ + Represents the context in which the Docker image should be built. + """ + + root: str = Field( + ..., + description="The root directory of the build context. Should be specified relative to the location of the " + "build config file.", + ) + include_paths: list[str] = Field( + default_factory=list, + description="The paths to include in the build context. Should be specified relative to the root directory.", + ) + dockerfile: str = Field( + ..., + description="The path to the Dockerfile. Should be specified relative to the root directory.", + ) + dockerignore: str | None = Field( + None, + description="The path to the .dockerignore file. Should be specified relative to the root directory.", + ) + + +class BuildConfig(ConfigBaseModel): + """ + Represents a configuration for building the action as a Docker image. + """ + + context: BuildContext diff --git a/src/agentex/config/credentials.py b/src/agentex/config/credentials.py new file mode 100644 index 000000000..7f4b79d1c --- /dev/null +++ b/src/agentex/config/credentials.py @@ -0,0 +1,34 @@ +from pydantic import Field, BaseModel + + +class CredentialMapping(BaseModel): + """Maps a Kubernetes secret to an environment variable in the agent container. + + This allows agents to securely access credentials stored in Kubernetes secrets + by mapping them to environment variables. For example, you can map a secret + containing an API key to an environment variable that your agent code expects. + + Example: + A mapping of {"env_var_name": "OPENAI_API_KEY", + "secret_name": "ai-credentials", + "secret_key": "openai-key"} + will make the value from the "openai-key" field in the "ai-credentials" + Kubernetes secret available to the agent as OPENAI_API_KEY environment variable. + + Attributes: + env_var_name: The name of the environment variable that will be available to the agent + secret_name: The name of the Kubernetes secret containing the credential + secret_key: The key within the Kubernetes secret that contains the credential value + """ + + env_var_name: str = Field( + ..., + description="Name of the environment variable that will be available to the agent", + ) + secret_name: str = Field( + ..., description="Name of the Kubernetes secret containing the credential" + ) + secret_key: str = Field( + ..., + description="Key within the Kubernetes secret that contains the credential value", + ) diff --git a/src/agentex/config/deployment_config.py b/src/agentex/config/deployment_config.py new file mode 100644 index 000000000..c42a8db97 --- /dev/null +++ b/src/agentex/config/deployment_config.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from typing import Any, Dict + +from pydantic import Field + +from agentex.config._base import ConfigBaseModel + + +class ImageConfig(ConfigBaseModel): + """Configuration for container images""" + + repository: str = Field(..., description="Container image repository URL") + tag: str = Field(default="latest", description="Container image tag") + + +class ImagePullSecretConfig(ConfigBaseModel): + """Configuration for image pull secrets""" + + name: str = Field(..., description="Name of the image pull secret") + + +class ResourceRequirements(ConfigBaseModel): + """Resource requirements for containers""" + + cpu: str = Field( + default="500m", description="CPU request/limit (e.g., '500m', '1')" + ) + memory: str = Field( + default="1Gi", description="Memory request/limit (e.g., '1Gi', '512Mi')" + ) + + +class ResourceConfig(ConfigBaseModel): + """Resource configuration for containers""" + + requests: ResourceRequirements = Field( + default_factory=ResourceRequirements, description="Resource requests" + ) + limits: ResourceRequirements = Field( + default_factory=ResourceRequirements, description="Resource limits" + ) + + +class GlobalDeploymentConfig(ConfigBaseModel): + """Global deployment configuration that applies to all clusters""" + + agent: dict[str, str] = Field( + default_factory=dict, description="Agent metadata (name, description)" + ) + replicaCount: int = Field(default=1, description="Number of replicas to deploy") + resources: ResourceConfig = Field( + default_factory=ResourceConfig, description="Resource requirements" + ) + + +class DeploymentConfig(ConfigBaseModel): + """Main deployment configuration in the manifest""" + + image: ImageConfig = Field(..., description="Container image configuration") + imagePullSecrets: list[ImagePullSecretConfig] | None = Field( + default=None, description="Image pull secrets to use for the deployment" + ) + global_config: GlobalDeploymentConfig = Field( + default_factory=GlobalDeploymentConfig, + description="Global deployment settings", + alias="global", + ) + + +class ClusterConfig(ConfigBaseModel): + """Per-cluster deployment overrides""" + + image: ImageConfig | None = Field( + default=None, description="Cluster-specific image overrides" + ) + replicaCount: int | None = Field( + default=None, description="Cluster-specific replica count" + ) + resources: ResourceConfig | None = Field( + default=None, description="Cluster-specific resource overrides" + ) + env: list[dict[str, str]] | None = Field( + default=None, description="Additional environment variables for this cluster" + ) + # Allow additional arbitrary overrides for advanced users + additional_overrides: dict[str, Any] | None = Field( + default=None, description="Additional helm chart value overrides" + ) + + +class AuthenticationConfig(ConfigBaseModel): + principal: Dict[str, Any] = Field(description="Principal used for authorization on registration") + + +class InjectedImagePullSecretValues(ConfigBaseModel): + """Values for image pull secrets""" + + registry: str = Field(..., description="Registry of the image pull secret") + username: str = Field(..., description="Username of the image pull secret") + password: str = Field(..., description="Password of the image pull secret") + email: str | None = Field( + default=None, description="Email of the image pull secret" + ) + + +class InjectedSecretsValues(ConfigBaseModel): + """Values for injected secrets""" + + # Defined as a dictionary because the names need to be unique + credentials: dict[str, Any] = Field( + default_factory=dict, description="Secrets to inject into the deployment" + ) + imagePullSecrets: dict[str, InjectedImagePullSecretValues] = Field( + default_factory=dict, + description="Image pull secrets to inject into the deployment", + ) diff --git a/src/agentex/config/environment_config.py b/src/agentex/config/environment_config.py new file mode 100644 index 000000000..e3928d283 --- /dev/null +++ b/src/agentex/config/environment_config.py @@ -0,0 +1,217 @@ +""" +Environment-specific configuration models for agent deployments. + +This module provides Pydantic models for managing environment-specific +configurations that are separate from the main manifest.yaml file. The +yaml-loading helpers live in `agentex.lib.sdk.config.environment_config` +so these models stay slim-safe. +""" + +from __future__ import annotations + +from typing import Any, Dict, Literal + +from pydantic import Field, BaseModel, field_validator + +from agentex.config._base import ConfigBaseModel + + +class AgentAuthConfig(BaseModel): + """Authentication configuration for an agent in a specific environment.""" + + principal: Dict[str, Any] = Field( + ..., description="Principal configuration for agent authorization and registration" + ) + + @field_validator("principal") + @classmethod + def validate_principal_required_fields(cls, v: Any) -> Dict[str, Any]: + """Ensure principal has required fields for agent registration.""" + if not isinstance(v, dict): + raise ValueError("Principal must be a dictionary") + return v + + +class AgentKubernetesConfig(BaseModel): + """Kubernetes configuration for an agent in a specific environment.""" + + namespace: str = Field(..., description="Kubernetes namespace where the agent will be deployed") + + @field_validator("namespace") + @classmethod + def validate_namespace_format(cls, v: str) -> str: + """Ensure namespace follows Kubernetes naming conventions.""" + if not v or not v.strip(): + raise ValueError("Namespace cannot be empty") + + # Basic Kubernetes namespace validation + namespace = v.strip().lower() + if not namespace.replace("-", "").replace(".", "").isalnum(): + raise ValueError(f"Namespace '{v}' must contain only lowercase letters, numbers, hyphens, and periods") + + if len(namespace) > 63: + raise ValueError(f"Namespace '{v}' cannot exceed 63 characters") + + return namespace + + +class OciRegistryConfig(BaseModel): + """OCI registry configuration for Helm chart deployments.""" + + url: str = Field( + ..., + description="OCI registry URL for Helm charts (e.g., 'us-west1-docker.pkg.dev/project/repo'). " + "When set, OCI mode is used instead of classic helm repo.", + ) + provider: Literal["gar"] | None = Field( + default=None, + description="OCI registry provider for provider-specific features. " + "Set to 'gar' for Google Artifact Registry to enable auto-authentication via gcloud " + "and latest version fetching. When not set, assumes user has already authenticated.", + ) + chart_version: str | None = Field( + default=None, description="Helm chart version to deploy. If not set, uses the default version from the CLI." + ) + + +class AgentEnvironmentConfig(BaseModel): + """Complete configuration for an agent in a specific environment.""" + + kubernetes: AgentKubernetesConfig | None = Field(default=None, description="Kubernetes deployment configuration") + environment: str | None = Field( + default=None, + description="The environment keyword that this specific environment maps to: either dev, staging, prod", + ) + auth: AgentAuthConfig = Field(..., description="Authentication and authorization configuration") + helm_repository_name: str = Field(default="scale-egp", description="Helm repository name for the environment") + helm_repository_url: str = Field( + default="https://scale-egp-helm-charts-us-west-2.s3.amazonaws.com/charts", + description="Helm repository url for the environment (classic mode)", + ) + oci_registry: OciRegistryConfig | None = Field( + default=None, description="OCI registry configuration. When set, OCI mode is used instead of classic helm repo." + ) + helm_overrides: Dict[str, Any] = Field( + default_factory=dict, description="Helm chart value overrides for environment-specific tuning" + ) + + +class AgentEnvironmentsConfig(ConfigBaseModel): + """All environment configurations for an agent.""" + + schema_version: str = Field(default="v1", description="Schema version for validation and compatibility") + environments: Dict[str, AgentEnvironmentConfig] = Field( + ..., description="Environment-specific configurations (dev, prod, etc.)" + ) + + @field_validator("schema_version") + @classmethod + def validate_schema_version(cls, v: str) -> str: + """Ensure schema version is supported.""" + supported_versions = ["v1"] + if v not in supported_versions: + raise ValueError(f"Schema version '{v}' not supported. Supported versions: {', '.join(supported_versions)}") + return v + + @field_validator("environments") + @classmethod + def validate_environments_not_empty(cls, v: Dict[str, AgentEnvironmentConfig]) -> Dict[str, AgentEnvironmentConfig]: + """Ensure at least one environment is defined.""" + if not v: + raise ValueError("At least one environment must be defined") + return v + + def get_config_for_env(self, env_name: str) -> AgentEnvironmentConfig: + """Get configuration for a specific environment. + + Args: + env_name: Name of the environment (e.g., 'dev', 'prod') + + Returns: + AgentEnvironmentConfig for the specified environment + + Raises: + ValueError: If environment is not found + """ + if env_name not in self.environments: + available_envs = ", ".join(self.environments.keys()) + raise ValueError( + f"Environment '{env_name}' not found in environments.yaml. Available environments: {available_envs}" + ) + return self.environments[env_name] + + def get_configs_for_env(self, env_target: str) -> dict[str, AgentEnvironmentConfig]: + """Get configuration for a specific environment based on the expected mapping. + The environment is either: + 1. explicitly specified like so using a key-map in the environments conifg: + environments: + dev-aws: + environment: "dev" + kubernetes: + namespace: "sgp-000-hello-acp" + auth: + principal: + user_id: 73d0c8bd-4726-434c-9686-eb627d89f078 + account_id: 6887f093600ecd59bbbd3095 + helm_overrides: + + or: it it can be defined at the top level: + dev: + kubernetes: + namespace: "sgp-000-hello-acp" + auth: + principal: + user_id: 73d0c8bd-4726-434c-9686-eb627d89f078 + account_id: 6887f093600ecd59bbbd3095 + helm_overrides: + + The principal must contain exactly one of `user_id` or `service_account_id`. + Use `service_account_id` to register an agent under a service account + instead of a personal user identity: + dev: + kubernetes: + namespace: "sgp-000-hello-acp" + auth: + principal: + service_account_id: a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d + account_id: 6887f093600ecd59bbbd3095 + + if the environment field is not explicitly set, we assume its the same as + the name of the environment + Args: + env_target: Name of the environment target (e.g., 'dev', 'prod') + + Returns: + AgentEnvironmentConfig for the specified environment + + Raises: + ValueError: If environment is not found + """ + envs_to_deploy = {} + if env_target in self.environments: + # this supports if the top-level key is just "dev, staging, etc" and matches + # the environment name exactly without any explicit mapping + envs_to_deploy[env_target] = self.environments[env_target] + + for env_name, config in self.environments.items(): + if config.environment == env_target: + envs_to_deploy[env_name] = config + + if len(envs_to_deploy) == 0: + ## this just finds environments for each target, so "available_envs" refers to each target environment + + available_envs = set() + for env_name, config in self.environments.items(): + if config.environment is not None: + available_envs.add(config.environment) + else: + available_envs.add(env_name) + raise ValueError( + f"Environment '{env_target}' not found in environments.yaml. Available environments: {available_envs}" + ) + + return envs_to_deploy + + def list_environments(self) -> list[str]: + """Get list of all configured environment names.""" + return list(self.environments.keys()) diff --git a/src/agentex/config/local_development_config.py b/src/agentex/config/local_development_config.py new file mode 100644 index 000000000..30b3f59fe --- /dev/null +++ b/src/agentex/config/local_development_config.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from pathlib import Path + +from pydantic import Field, field_validator + +from agentex.config._base import ConfigBaseModel + + +class LocalAgentConfig(ConfigBaseModel): + """Configuration for local agent development""" + + port: int = Field( + ..., + description="The port where the agent's ACP server is running locally", + gt=0, + lt=65536, + ) + host_address: str = Field( + default="host.docker.internal", + description="The host address where the agent's ACP server can be reached (e.g., host.docker.internal for Docker, localhost for direct)", + ) + + +class LocalPathsConfig(ConfigBaseModel): + """Configuration for local file paths""" + + acp: str = Field( + default="project/acp.py", + description="Path to the ACP server file. Can be relative to manifest directory or absolute.", + ) + worker: str | None = Field( + default=None, + description="Path to the temporal worker file. Can be relative to manifest directory or absolute. (only for temporal agents)", + ) + + @field_validator("acp", "worker") + @classmethod + def validate_path_format(cls, v): + """Validate that the path is a reasonable format""" + if v is None: + return v + + # Convert to Path to validate format + try: + Path(v) + except Exception as e: + raise ValueError(f"Invalid path format: {v}") from e + + return v + + +class LocalDevelopmentConfig(ConfigBaseModel): + """Configuration for local development environment""" + + agent: LocalAgentConfig = Field(..., description="Local agent configuration") + paths: LocalPathsConfig | None = Field( + default=None, description="File paths for local development" + ) diff --git a/src/agentex/lib/.keep b/src/agentex/lib/.keep new file mode 100644 index 000000000..5e2c99fdb --- /dev/null +++ b/src/agentex/lib/.keep @@ -0,0 +1,4 @@ +File generated from our OpenAPI spec by Stainless. + +This directory can be used to store custom files to expand the SDK. +It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. \ No newline at end of file diff --git a/src/agentex/lib/__init__.py b/src/agentex/lib/__init__.py new file mode 100644 index 000000000..9d960dfee --- /dev/null +++ b/src/agentex/lib/__init__.py @@ -0,0 +1,4 @@ +from agentex.lib._version_guard import verify_client_compatibility + +# Fail fast + clearly on a skewed/incomplete agentex-client install. +verify_client_compatibility() diff --git a/src/agentex/lib/_version_guard.py b/src/agentex/lib/_version_guard.py new file mode 100644 index 000000000..a05572efc --- /dev/null +++ b/src/agentex/lib/_version_guard.py @@ -0,0 +1,28 @@ +"""Fail fast with a clear error on an incomplete agentex-client install instead +of a cryptic `cannot import name ... from agentex.types`.""" + +from __future__ import annotations + +from importlib.metadata import PackageNotFoundError, version + + +def _installed(package: str) -> str: + try: + return version(package) + except PackageNotFoundError: + return "unknown" + + +def verify_client_compatibility() -> None: + # Canary on the client REST surface, not the version: newer clients are fine + # (additive); we only fail if a symbol/resource the ADK needs is absent. + try: + from agentex.types import Event as _Event # noqa: F401 + from agentex.resources import states as _states # noqa: F401 + except (ImportError, AttributeError) as exc: + raise ImportError( + f"agentex-sdk could not import the agentex-client REST surface it " + f"depends on (agentex-sdk={_installed('agentex-sdk')}, " + f"agentex-client={_installed('agentex-client')}). Reinstall both at a " + f"compatible version, e.g. `pip install --force-reinstall agentex-sdk`." + ) from exc diff --git a/src/agentex/lib/adk/__init__.py b/src/agentex/lib/adk/__init__.py new file mode 100644 index 000000000..c05f8f3ea --- /dev/null +++ b/src/agentex/lib/adk/__init__.py @@ -0,0 +1,118 @@ +# ruff: noqa: I001 +# Import order matters here to avoid circular imports +# The _modules must be imported before providers/utils + +from agentex.lib.adk._modules.acp import ACPModule +from agentex.lib.adk._modules.agents import AgentsModule +from agentex.lib.adk._modules.agent_task_tracker import AgentTaskTrackerModule +from agentex.lib.adk._modules.checkpointer import create_checkpointer +from agentex.lib.adk._modules._langgraph_turn import LangGraphTurn, stream_langgraph_events +from agentex.lib.adk._modules._langgraph_sync import ( + emit_langgraph_messages, + convert_langgraph_to_agentex_events, +) +from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn, stream_pydantic_ai_events +from agentex.lib.adk._modules._pydantic_ai_sync import convert_pydantic_ai_to_agentex_events +from agentex.lib.adk._modules._openai_sync import convert_openai_to_agentex_events +from agentex.lib.adk._modules._openai_turn import OpenAITurn, openai_usage_to_turn_usage +from agentex.lib.adk._modules._claude_code_sync import convert_claude_code_to_agentex_events +from agentex.lib.adk._modules._claude_code_turn import ( + ClaudeCodeTurn, + claude_code_usage_to_turn_usage, +) +from agentex.lib.adk._modules._codex_sync import convert_codex_to_agentex_events +from agentex.lib.adk._modules._codex_turn import CodexTurn, codex_usage_to_turn_usage +from agentex.lib.adk._modules.events import EventsModule +from agentex.lib.adk._modules.messages import MessagesModule +from agentex.lib.adk._modules.state import StateModule +from agentex.lib.adk._modules.streaming import StreamingModule +from agentex.lib.adk._modules.tasks import TasksModule +from agentex.lib.adk._modules.tracing import TracingModule, TurnSpan + +# Data-source refs for lineage (SGP-6513); implementation lives in core.tracing +from agentex.lib.core.tracing import lineage + +# Opt-in commit-SHA stamping (AGX1-969); implementation in core.tracing +from agentex.lib.core.tracing import code_revision +from agentex.lib.core.tracing.lineage import DataSourceRef, data_sources + +# Unified harness surface (AGX1-375) +from agentex.lib.core.harness import ( + UnifiedEmitter, + SpanTracer, + OpenSpan, + CloseSpan, + SpanSignal, + StreamTaskMessage, + TurnUsage, + TurnResult, + HarnessTurn, +) + +from agentex.lib.adk import providers +from agentex.lib.adk import utils + +acp = ACPModule() +agents = AgentsModule() +tasks = TasksModule() +messages = MessagesModule() +state = StateModule() +streaming = StreamingModule() +tracing = TracingModule() +events = EventsModule() +agent_task_tracker = AgentTaskTrackerModule() + +__all__ = [ + # Core + "acp", + "agents", + "tasks", + "messages", + "state", + "streaming", + "tracing", + "events", + "agent_task_tracker", + "TurnSpan", + # Lineage data-source refs (SGP-6513) + "lineage", + "code_revision", + "DataSourceRef", + "data_sources", + # Checkpointing / LangGraph + "create_checkpointer", + "stream_langgraph_events", + "emit_langgraph_messages", + "convert_langgraph_to_agentex_events", + "LangGraphTurn", + # Pydantic AI + "stream_pydantic_ai_events", + "convert_pydantic_ai_to_agentex_events", + "PydanticAITurn", + # OpenAI Agents + "convert_openai_to_agentex_events", + "OpenAITurn", + "openai_usage_to_turn_usage", + # Claude Code + "convert_claude_code_to_agentex_events", + "ClaudeCodeTurn", + "claude_code_usage_to_turn_usage", + # Codex + "convert_codex_to_agentex_events", + "CodexTurn", + "codex_usage_to_turn_usage", + # Unified harness surface (AGX1-375) + "UnifiedEmitter", + "SpanTracer", + "OpenSpan", + "CloseSpan", + "SpanSignal", + "StreamTaskMessage", + "TurnUsage", + "TurnResult", + "HarnessTurn", + # Providers + "providers", + # Utils + "utils", +] diff --git a/src/agentex/lib/adk/_modules/__init__.py b/src/agentex/lib/adk/_modules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/adk/_modules/_claude_code_sync.py b/src/agentex/lib/adk/_modules/_claude_code_sync.py new file mode 100644 index 000000000..e8daa44a3 --- /dev/null +++ b/src/agentex/lib/adk/_modules/_claude_code_sync.py @@ -0,0 +1,417 @@ +"""Claude Code stream-json parser tap for the unified harness surface. + +Converts the newline-delimited JSON envelopes emitted by +``claude -p --output-format stream-json`` into the canonical +``StreamTaskMessage*`` stream consumed by the Agentex harness. + +Envelope → canonical mapping +----------------------------- +system/init + Ignored at this layer (session_id tracking is a provider concern). + +assistant / user (content blocks) + text block → Start(TextContent) + Delta(TextDelta)* + Done + thinking block → Start(ReasoningContent) + Delta(ReasoningContentDelta)* + Done + tool_use block → Start(ToolRequestContent) + Done (Full args in Start content) + tool_result block → Full(ToolResponseContent) + +stream_event / content_block_start + type=text → Start(TextContent, empty) + type=thinking → Start(ReasoningContent, empty) + +stream_event / content_block_delta + type=text_delta → Delta(TextDelta) + type=thinking_delta → Delta(ReasoningContentDelta) + +stream_event / content_block_stop + (text open) → Done + (thinking open) → Done (full text known here; update Full via Full event first) + +result + Fires ``on_result`` with the raw envelope so the caller can capture + usage and cost. No StreamTaskMessage is emitted for the result itself. + +Out of scope +------------ +No deployable test agent is provided. claude-code requires the golden +agent's sandbox/subprocess/secret/MCP orchestration to produce the stream. +Live coverage is the golden agent, which will adopt this tap. Do NOT add an +examples/ agent or CI live-matrix row for claude-code. +""" + +from __future__ import annotations + +import json +from typing import Any, Callable, Awaitable, AsyncIterator + +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.types.reasoning_content import ReasoningContent +from agentex.types.task_message_delta import TextDelta +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.types.reasoning_content_delta import ReasoningContentDelta + +logger = make_logger(__name__) + +_MAX_RESULT_LENGTH = 4000 + + +def _truncate(text: str) -> str: + return str(text)[:_MAX_RESULT_LENGTH] + + +def _extract_summary(text: str, max_len: int = 300) -> str: + return text.strip().split("\n", 1)[0][:max_len] + + +async def convert_claude_code_to_agentex_events( + lines: AsyncIterator[str | dict[str, Any]], + on_result: Callable[[dict[str, Any]], Awaitable[None]] | None = None, + on_init: Callable[[dict[str, Any]], Awaitable[None]] | None = None, +) -> AsyncIterator[StreamTaskMessageStart | StreamTaskMessageDelta | StreamTaskMessageFull | StreamTaskMessageDone]: + """Public tap: convert a claude-code ``stream-json`` line stream to events. + + Thin wrapper over :func:`_convert_claude_code_impl` that owns the + cancellation backstop: a ``finally`` closes the underlying ``lines`` iterator + (when it exposes ``aclose``) whenever this generator is closed — including on + the ``GeneratorExit``/``CancelledError`` raised when a consuming task is + cancelled mid-turn by an interrupt. This terminates the CLI stdout handle / + subprocess instead of leaking it. Kept as a wrapper (rather than a + ``try/finally`` inside the impl) so the large parser body stays untouched. + """ + inner = _convert_claude_code_impl(lines, on_result=on_result, on_init=on_init) + try: + async for event in inner: + yield event + finally: + inner_aclose = getattr(inner, "aclose", None) + if inner_aclose is not None: + await inner_aclose() + aclose = getattr(lines, "aclose", None) + if aclose is not None: + await aclose() + + +async def _convert_claude_code_impl( + lines: AsyncIterator[str | dict[str, Any]], + on_result: Callable[[dict[str, Any]], Awaitable[None]] | None = None, + on_init: Callable[[dict[str, Any]], Awaitable[None]] | None = None, +) -> AsyncIterator[StreamTaskMessageStart | StreamTaskMessageDelta | StreamTaskMessageFull | StreamTaskMessageDone]: + """Convert a claude-code ``stream-json`` line stream into Agentex ``StreamTaskMessage*`` events. + + Each item in ``lines`` is either a raw JSON string (as read from the CLI's + stdout) or an already-parsed dict. Empty strings are skipped; unparseable + JSON is logged and skipped. + + ``on_result`` is called with the ``result`` envelope when it arrives so the + caller can capture usage and cost. It is awaited before the generator + continues. When ``None``, the result envelope is silently dropped. + + ``on_init`` is called with the ``system``/``init`` envelope the moment it + arrives (the FIRST envelope of a claude-code stream), so the caller can + capture ``session_id`` EARLY — before the turn completes. This is what makes + an interrupted-before-completion turn resumable: the terminal ``result`` + envelope (which ``on_result`` reads) never arrives when a turn is cut short, + so relying on it alone loses the session id. Mirrors how the inline + ``claude_agents`` activity captures session_id from its SystemMessage init. + When ``None``, the init envelope's session metadata is not surfaced early. + + Envelope → canonical mapping is documented in this module's docstring. + + A ``finally`` closes the underlying ``lines`` iterator (when it exposes + ``aclose``) as a backstop, so a cancellation mid-turn (e.g. an interrupt that + cancels the consuming task) does not leak the CLI stdout handle / subprocess. + """ + next_index = 0 + tool_call_count = 0 + + # Streaming state for content_block_start / content_block_delta / + # content_block_stop triples. + _thinking_open = False + _thinking_buf = "" + _thinking_index: int | None = None + _text_open = False + _text_buf = "" + _text_index: int | None = None + # Full text of each block already delivered via stream_event deltas, so the + # materialised assistant envelope does not re-emit it. Matched by CONTENT, + # not block index: a single streamed message can arrive as several assistant + # envelopes (e.g. a thinking block, then the text block), and the per-block + # numeric index does not survive that split while the text does. Each match + # is consumed (one entry removed) so a genuinely repeated later block — a new + # turn that happens to emit identical text — is still delivered. + _streamed_texts: list[str] = [] + _streamed_thinkings: list[str] = [] + + async for raw in lines: + if not raw: + continue + + if isinstance(raw, dict): + evt = raw + else: + line = raw.strip() + if not line: + continue + try: + evt = json.loads(line) + except json.JSONDecodeError: + logger.debug("claude-code: skipping non-JSON line: %r", line[:120]) + continue + + evt_type = evt.get("type", "") + + # ----------------------------------------------------------------------- + # assistant / user — materialised content blocks + # ----------------------------------------------------------------------- + if evt_type in ("assistant", "user"): + msg = evt.get("message", {}) + blocks = msg.get("content", []) + if not isinstance(blocks, list): + blocks = [blocks] + + for block in blocks: + if not isinstance(block, dict): + continue + block_type = block.get("type", "") + + if block_type == "text": + text = block.get("text", "") + if not text: + continue + # Skip blocks already delivered via stream_event deltas. Two + # cases: (1) the streamed block already finished — its full + # text is recorded in _streamed_texts; (2) the materialised + # envelope arrives INTERLEAVED, mid-stream, before the streamed + # block's content_block_stop records its buffer — the still-open + # block's partial buffer is a prefix of this full text. + if text in _streamed_texts: + _streamed_texts.remove(text) + continue + if _text_open and _text_buf and text.startswith(_text_buf): + continue + msg_index = next_index + next_index += 1 + yield StreamTaskMessageStart( + type="start", + index=msg_index, + content=TextContent( + type="text", + author="agent", + content="", + ), + ) + yield StreamTaskMessageDelta( + type="delta", + index=msg_index, + delta=TextDelta(type="text", text_delta=text), + ) + yield StreamTaskMessageDone(type="done", index=msg_index) + + elif block_type == "thinking": + thinking_text = block.get("thinking", "") + if thinking_text: + # Skip blocks already delivered via stream_event deltas. + # Same two cases as text above: finished streamed block + # (recorded), or an interleaved materialised envelope whose + # text the still-open streamed buffer is a prefix of. + if thinking_text in _streamed_thinkings: + _streamed_thinkings.remove(thinking_text) + continue + if _thinking_open and _thinking_buf and thinking_text.startswith(_thinking_buf): + continue + summary = _extract_summary(thinking_text) + msg_index = next_index + next_index += 1 + yield StreamTaskMessageStart( + type="start", + index=msg_index, + content=ReasoningContent( + type="reasoning", + author="agent", + summary=[summary], + content=[], + style="active", + ), + ) + yield StreamTaskMessageDelta( + type="delta", + index=msg_index, + delta=ReasoningContentDelta( + type="reasoning_content", + content_index=0, + content_delta=thinking_text, + ), + ) + yield StreamTaskMessageDone(type="done", index=msg_index) + + elif block_type == "tool_use": + tool_call_count += 1 + tool_id = block.get("id", f"tool_{tool_call_count}") + name = block.get("name", "unknown") + arguments = block.get("input", {}) + if not isinstance(arguments, dict): + arguments = {} + msg_index = next_index + next_index += 1 + yield StreamTaskMessageStart( + type="start", + index=msg_index, + content=ToolRequestContent( + type="tool_request", + author="agent", + tool_call_id=tool_id, + name=name, + arguments=arguments, + ), + ) + yield StreamTaskMessageDone(type="done", index=msg_index) + + elif block_type == "tool_result": + tool_id = block.get("tool_use_id", "") + content = block.get("content", "") + is_error = block.get("is_error", False) + if isinstance(content, list): + content = "\n".join(b.get("text", str(b)) if isinstance(b, dict) else str(b) for b in content) + result_str = _truncate(str(content)) + msg_index = next_index + next_index += 1 + yield StreamTaskMessageFull( + type="full", + index=msg_index, + content=ToolResponseContent( + type="tool_response", + author="agent", + tool_call_id=tool_id, + name="", + content={"result": result_str, **({"is_error": True} if is_error else {})}, + ), + ) + + # ----------------------------------------------------------------------- + # stream_event — incremental streaming deltas + # ----------------------------------------------------------------------- + elif evt_type == "stream_event": + se = evt.get("event") or {} + se_type = se.get("type", "") + + if se_type == "content_block_start": + block = se.get("content_block") or {} + btype = block.get("type") + + if btype == "thinking": + _thinking_open = True + _thinking_buf = "" + msg_index = next_index + next_index += 1 + _thinking_index = msg_index + yield StreamTaskMessageStart( + type="start", + index=msg_index, + content=ReasoningContent( + type="reasoning", + author="agent", + summary=[], + content=[], + style="active", + ), + ) + + elif btype == "text": + _text_open = True + _text_buf = "" + msg_index = next_index + next_index += 1 + _text_index = msg_index + yield StreamTaskMessageStart( + type="start", + index=msg_index, + content=TextContent( + type="text", + author="agent", + content="", + ), + ) + + elif se_type == "content_block_delta": + delta = se.get("delta") or {} + dtype = delta.get("type") + + if dtype == "thinking_delta": + chunk = delta.get("thinking", "") + if chunk and _thinking_open: + _thinking_buf += chunk + if _thinking_index is not None: + yield StreamTaskMessageDelta( + type="delta", + index=_thinking_index, + delta=ReasoningContentDelta( + type="reasoning_content", + content_index=0, + content_delta=chunk, + ), + ) + + elif dtype == "text_delta": + chunk = delta.get("text", "") + if chunk and _text_open: + _text_buf += chunk + if _text_index is not None: + yield StreamTaskMessageDelta( + type="delta", + index=_text_index, + delta=TextDelta(type="text", text_delta=chunk), + ) + + elif se_type == "content_block_stop": + if _thinking_open: + _thinking_open = False + # Record the streamed thinking so the materialised assistant + # envelope doesn't re-emit it. Skip empties: a block_start with + # no deltas leaves the assistant envelope free to fill the text. + if _thinking_buf: + _streamed_thinkings.append(_thinking_buf) + _thinking_buf = "" + if _thinking_index is not None: + yield StreamTaskMessageDone(type="done", index=_thinking_index) + _thinking_index = None + elif _text_open: + _text_open = False + # Record the streamed text for content-based dedup against the + # materialised assistant envelope (see _streamed_texts). + if _text_buf: + _streamed_texts.append(_text_buf) + _text_buf = "" + if _text_index is not None: + yield StreamTaskMessageDone(type="done", index=_text_index) + _text_index = None + + # ----------------------------------------------------------------------- + # system / init — session metadata (ignored at this layer) + # ----------------------------------------------------------------------- + elif evt_type == "system": + # Session ID tracking and MCP status logging are provider concerns: + # this pure parser layer emits no StreamTaskMessage for system events. + # It DOES surface the init envelope's session metadata early via + # on_init so a caller can capture session_id before the turn's + # terminal ``result`` arrives — required for resuming a turn that was + # interrupted before completion (no ``result`` is ever emitted then). + if on_init is not None and evt.get("subtype") == "init": + await on_init(evt) + + # ----------------------------------------------------------------------- + # result — carries usage + cost; fired to on_result, not emitted as msgs + # ----------------------------------------------------------------------- + elif evt_type == "result": + if on_result is not None: + await on_result(evt) + + else: + logger.debug("claude-code: unhandled envelope type %r", evt_type) diff --git a/src/agentex/lib/adk/_modules/_claude_code_turn.py b/src/agentex/lib/adk/_modules/_claude_code_turn.py new file mode 100644 index 000000000..d41afafe7 --- /dev/null +++ b/src/agentex/lib/adk/_modules/_claude_code_turn.py @@ -0,0 +1,175 @@ +"""ClaudeCodeTurn — HarnessTurn implementation for the claude-code tap. + +Wraps ``convert_claude_code_to_agentex_events`` to implement the +``HarnessTurn`` protocol: exposes ``events`` (the canonical +``StreamTaskMessage*`` stream) and ``usage()`` (the normalised +``TurnUsage``, populated after the stream is exhausted). + +Usage normalization +------------------- +Claude Code's ``result`` envelope carries usage under several key shapes +depending on the CLI version. We defensive-map all known shapes: + + result.usage.input_tokens -> input_tokens + result.usage.output_tokens -> output_tokens + result.usage.cache_read_input_tokens + result.usage.cache_creation_input_tokens -> cached_input_tokens (sum) + result.cost_usd / result.total_cost_usd -> cost_usd + result.duration_ms -> duration_ms + result.num_turns -> num_llm_calls + +Real zeros are preserved; missing keys default to ``None`` (not zero) so +downstream consumers can distinguish "not reported" from "zero". + +Out of scope: no deployable test agent is provided — see module docstring +in ``_claude_code_sync.py``. +""" + +from __future__ import annotations + +from typing import Any, AsyncIterator + +from agentex.lib.core.harness.types import TurnUsage, HarnessTurn, StreamTaskMessage +from agentex.lib.adk._modules._claude_code_sync import convert_claude_code_to_agentex_events + + +def claude_code_usage_to_turn_usage(result_envelope: dict[str, Any]) -> TurnUsage: + """Map a claude-code ``result`` envelope to a canonical ``TurnUsage``. + + Defensively handles missing / None values. Real zeros are preserved. + ``cost_usd`` checks both ``cost_usd`` and ``total_cost_usd`` keys (the + CLI has used both across versions). + ``cached_input_tokens`` accumulates cache_read and cache_creation counts + since both represent tokens served from the prompt cache. + """ + usage_raw: dict[str, Any] = result_envelope.get("usage") or {} + + def _int(d: dict[str, Any], key: str) -> int | None: + v = d.get(key) + if v is None: + return None + try: + return int(v) + except (TypeError, ValueError): + return None + + def _float(d: dict[str, Any], *keys: str) -> float | None: + for key in keys: + v = d.get(key) + if v is not None: + try: + return float(v) + except (TypeError, ValueError): + continue + return None + + input_tokens = _int(usage_raw, "input_tokens") + output_tokens = _int(usage_raw, "output_tokens") + + # Aggregate both cache_read and cache_creation into cached_input_tokens + cache_read = _int(usage_raw, "cache_read_input_tokens") + cache_creation = _int(usage_raw, "cache_creation_input_tokens") + if cache_read is not None or cache_creation is not None: + cached_input_tokens = (cache_read or 0) + (cache_creation or 0) + else: + cached_input_tokens = None + + total_tokens: int | None = None + if input_tokens is not None and output_tokens is not None: + total_tokens = input_tokens + output_tokens + + cost_usd = _float(result_envelope, "cost_usd", "total_cost_usd") + duration_ms = _int(result_envelope, "duration_ms") + + # num_llm_calls is provider-reported (from num_turns): default None ("not + # reported") rather than 0 so callers can distinguish it from a real zero, + # matching the None convention used for the token fields above. + num_turns = result_envelope.get("num_turns") + num_llm_calls: int | None = None + if num_turns is not None: + try: + num_llm_calls = int(num_turns) + except (TypeError, ValueError): + pass + + return TurnUsage( + input_tokens=input_tokens, + output_tokens=output_tokens, + cached_input_tokens=cached_input_tokens, + total_tokens=total_tokens, + cost_usd=cost_usd, + duration_ms=duration_ms, + num_llm_calls=num_llm_calls, + ) + + +class ClaudeCodeTurn: + """HarnessTurn for a claude-code ``stream-json`` line stream. + + Satisfies the ``HarnessTurn`` protocol: + - ``events`` yields the canonical ``StreamTaskMessage*`` stream. + - ``usage()`` returns the normalised ``TurnUsage`` (only valid after + ``events`` is fully consumed). + + ``lines`` is an async iterator of raw JSON strings or pre-parsed dicts, as + produced by reading the claude-code CLI's stdout line by line. + """ + + def __init__(self, lines: AsyncIterator[str | dict[str, Any]]) -> None: + self._lines = lines + self._result_envelope: dict[str, Any] | None = None + # session_id captured from the early system/init envelope. This is what + # keeps an interrupted-before-completion turn resumable: the terminal + # ``result`` envelope never arrives when a turn is cut short, so we must + # capture the session id up front (from init) rather than only at the end. + self._init_session_id: str | None = None + self._events_stream: AsyncIterator[StreamTaskMessage] | None = None + + async def _on_result(self, envelope: dict[str, Any]) -> None: + self._result_envelope = envelope + + async def _on_init(self, envelope: dict[str, Any]) -> None: + sid = envelope.get("session_id") + if sid: + self._init_session_id = sid + + @property + def events(self) -> AsyncIterator[StreamTaskMessage]: + if self._events_stream is None: + self._events_stream = convert_claude_code_to_agentex_events( + self._lines, + on_result=self._on_result, + on_init=self._on_init, + ) + return self._events_stream + + @property + def session_id(self) -> str | None: + """The Claude Code session id, for resuming a multi-turn session. + + Prefers the id from the terminal ``result`` envelope (fully-complete + turn), then falls back to the id captured from the early ``system/init`` + envelope. The init fallback is what makes a turn that was interrupted + before completion still resumable (no ``result`` is emitted then). + Returns ``None`` only if neither envelope was seen (e.g. the stream was + truncated before init) or Claude Code reported no session id. + """ + if self._result_envelope and self._result_envelope.get("session_id"): + return self._result_envelope.get("session_id") + return self._init_session_id + + def usage(self) -> TurnUsage: + """Return normalised usage for this turn. + + Call only after ``events`` is exhausted. Returns an empty ``TurnUsage`` + if the ``result`` envelope was not received (e.g. stream was truncated). + """ + if self._result_envelope is None: + return TurnUsage() + return claude_code_usage_to_turn_usage(self._result_envelope) + + +# Runtime assert that ClaudeCodeTurn satisfies HarnessTurn protocol +assert isinstance(ClaudeCodeTurn.__new__(ClaudeCodeTurn), HarnessTurn), ( + "ClaudeCodeTurn must satisfy the HarnessTurn protocol" +) diff --git a/src/agentex/lib/adk/_modules/_codex_sync.py b/src/agentex/lib/adk/_modules/_codex_sync.py new file mode 100644 index 000000000..b71ba9dac --- /dev/null +++ b/src/agentex/lib/adk/_modules/_codex_sync.py @@ -0,0 +1,679 @@ +"""Codex event-stream parser tap for the unified harness surface. + +Converts a ``codex exec --json`` newline-delimited event stream (already +produced by the golden agent's sandbox/subprocess orchestration) into the +Agentex canonical ``StreamTaskMessage*`` events. + +SCOPE +----- +This module is a **pure parser**. It receives pre-produced codex events +(``str`` lines or already-decoded ``dict`` objects) and yields canonical +``StreamTaskMessage*`` events. All subprocess management, sandbox +provisioning, secret injection, and MCP orchestration remain in the golden +agent at +``teams/sgp/agents/golden_agent/project/harness/providers/codex.py``. + +No deployable test agent is included here: running codex requires the +golden agent's sandbox environment and is out of scope for this library tap. + +OUT OF SCOPE (document here so future callers are not surprised): +- Subprocess / sandbox management +- OPENAI_API_KEY / secret injection +- MCP server configuration (--config /tmp/codex_config.toml) +- ``codex exec resume`` session tracking +- ``scale_sandbox`` imports + +CANONICAL MAPPING +----------------- +The table below lists every ``type`` field the codex exec JSON stream can +emit (from ``codex-rs/exec/src/exec_events.rs``) and its mapping. + +Top-level event types +~~~~~~~~~~~~~~~~~~~~~ + thread.started -> (no StreamTaskMessage; session_id captured + internally; surfaced via ``on_result`` callback) + turn.started -> (no StreamTaskMessage; turn was started before + codex launched; nothing to emit here) + turn.completed -> on_result(usage_dict, tool_count, reasoning_count) + yields no StreamTaskMessage (turn lifecycle is + managed by the activity layer) + turn.failed -> StreamTaskMessageFull(TextContent, error text) + error -> StreamTaskMessageFull(TextContent, error text) + +Item sub-types (item.started / item.updated / item.completed) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + agent_message -> text deltas: + item.started / item.updated -> StreamTaskMessageDelta(TextDelta) + item.completed -> StreamTaskMessageDone + reasoning -> reasoning: + item.started -> StreamTaskMessageStart(ReasoningContent) + item.updated -> (no-op; final text arrives on completed) + item.completed -> StreamTaskMessageDelta(ReasoningSummaryDelta) + + StreamTaskMessageDelta(ReasoningContentDelta) + + StreamTaskMessageDone + command_execution -> tool request + response: + item.started -> StreamTaskMessageStart(ToolRequestContent) + + StreamTaskMessageDone + item.completed -> StreamTaskMessageFull(ToolResponseContent) + file_change -> same as command_execution + NOTE: file_change may only emit item.completed (no started); + a synthetic ToolRequestContent Full is emitted before the response. + mcp_tool_call -> same as command_execution + web_search -> same as command_execution + todo_list -> same as command_execution, plus: + item.updated -> StreamTaskMessageFull(ToolResponseContent) + Codex ticks one in-place todo_list item through + item.updated; each revision is republished under + the same tool_call_id so consumers can render the + checklist filling in rather than jumping to its + final state at end of turn. + collab_tool_call -> same as command_execution + error (item type) -> StreamTaskMessageFull(TextContent, error text) on completed only + +UNMAPPED / PARTIALLY MAPPED EVENTS +----------------------------------- + thread.started: session_id is extracted but not forwarded as a + StreamTaskMessage (no canonical content type for + session-lifecycle signals; captured in on_result). + turn.started: no-op; intentional (the caller owns turn lifecycle). + turn.completed: no StreamTaskMessage; usage is forwarded via + on_result so the caller can record it in a span + without this module needing to know about spans. + item.updated (reasoning): the intermediate cumulative text is discarded; + only item.completed carries the final text. + item.updated (tool): only todo_list is republished (see above). For the + other tool item types item.started opens the request + and item.completed closes it; any updates in between + carry no state a consumer could act on. +""" + +from __future__ import annotations + +import json +from typing import Any, Callable, AsyncIterator + +from agentex.lib.utils.logging import make_logger +from agentex.types.reasoning_content import ReasoningContent +from agentex.types.task_message_delta import TextDelta +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.types.task_message_content import TextContent +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.types.reasoning_content_delta import ReasoningContentDelta +from agentex.types.reasoning_summary_delta import ReasoningSummaryDelta + +logger = make_logger(__name__) + +# Canonical type alias matching the unified harness surface. +StreamTaskMessage = StreamTaskMessageStart | StreamTaskMessageDelta | StreamTaskMessageFull | StreamTaskMessageDone + +_MAX_RESULT_LENGTH = 4000 + + +def _truncate(text: str, max_len: int = _MAX_RESULT_LENGTH) -> str: + return str(text)[:max_len] + + +# Tool items codex revises in place rather than reopening. Their item.updated +# events carry real intermediate state, so they are forwarded as responses. +_PROGRESSIVE_TOOL_ITEMS = frozenset({"todo_list"}) + + +def _tool_name_for(item_type: str, payload: dict[str, Any]) -> str: + """Derive a canonical tool name from a codex item type.""" + if item_type == "command_execution": + return "bash" + if item_type == "file_change": + return "file_change" + if item_type == "mcp_tool_call": + server = payload.get("server", "") + tool = payload.get("tool", "") + return f"{server}.{tool}" if (server or tool) else "mcp_tool_call" + if item_type == "web_search": + return "web_search" + if item_type == "todo_list": + return "todo_list" + if item_type == "collab_tool_call": + return "collab_tool_call" + return item_type or "unknown" + + +def _tool_args_for(item_type: str, payload: dict[str, Any]) -> dict[str, Any]: + """Extract canonical arguments dict from a codex item payload.""" + if item_type == "command_execution": + return {"command": payload.get("command", "")} + if item_type == "file_change": + return {"changes": payload.get("changes") or []} + if item_type == "mcp_tool_call": + args = payload.get("arguments") + return args if isinstance(args, dict) else {"value": args} + if item_type == "web_search": + return {"query": payload.get("query", "")} + if item_type == "todo_list": + return {"items": payload.get("items") or []} + if item_type == "collab_tool_call": + # Surface an arguments dict if the payload carries one (mirrors + # mcp_tool_call); otherwise no args rather than fabricating a shape. + args = payload.get("arguments") + return args if isinstance(args, dict) else {} + return {} + + +def _tool_output_for(item_type: str, payload: dict[str, Any]) -> tuple[str, bool]: + """Extract (result_text, is_error) from a completed codex tool item.""" + if item_type == "command_execution": + out = payload.get("aggregated_output") or "" + exit_code = payload.get("exit_code") + is_error = exit_code is not None and exit_code != 0 + return _truncate(out), is_error + if item_type in ("mcp_tool_call", "collab_tool_call"): + # collab_tool_call mirrors mcp_tool_call's error/result convention + # (see _tool_args_for); without this branch a failed collab call would + # fall through to the generic path and be reported as a success. + err = payload.get("error") + if err: + msg = err.get("message", "") if isinstance(err, dict) else str(err) + return _truncate(f"Error: {msg}"), True + result = payload.get("result") + if result is None: + return "", False + try: + return _truncate(json.dumps(result)), False + except (TypeError, ValueError): + return _truncate(str(result)), False + if item_type == "file_change": + changes = payload.get("changes") or [] + status = payload.get("status", "") + return f"status={status}, {len(changes)} changes", status == "failed" + try: + return _truncate(json.dumps(payload, default=str)), False + except (TypeError, ValueError): + return _truncate(str(payload)), False + + +def _error_full(message: str, next_index: int) -> StreamTaskMessageFull: + """Emit a one-shot TextContent full message for an error.""" + return StreamTaskMessageFull( + type="full", + index=next_index, + content=TextContent( + type="text", + author="agent", + content=f"Error: {message}", + format="plain", + ), + ) + + +class _CodexStreamProcessor: + """Stateful parser: consumes codex exec events, yields StreamTaskMessage*. + + Ported from the golden agent's ``_CodexEventProcessor`` in + ``project/harness/providers/codex.py``, adapted to yield + ``StreamTaskMessage*`` directly instead of ``HarnessEvent`` objects. + + State tracked: + - ``_next_index``: monotonically increasing message index. + - ``_text_index``: message index of the current open agent_message block. + - ``_text_accumulated``: cumulative text per agent_message item_id. + - ``_reasoning_index``: message index of the current open reasoning block. + - ``_reasoning_text``: latest cumulative reasoning text per item_id. + - ``_tool_open``: item_ids for which a ToolRequestContent Start was emitted + but no ToolResponseContent Full yet. + - ``_tool_item_types``: item_id -> item_type for open tool calls. + """ + + def __init__(self) -> None: + self._next_index: int = 0 + + # agent_message tracking + self._text_index: dict[str, int] = {} + self._text_accumulated: dict[str, str] = {} + + # reasoning tracking + self._reasoning_index: dict[str, int] = {} + self._reasoning_text: dict[str, str] = {} + + # tool tracking + self._tool_open: set[str] = set() + self._tool_item_types: dict[str, str] = {} + # Remember the tool_call_id assigned per item so the request and response + # halves agree even when item_id is empty (a recomputed fallback would + # drift as tool_call_count advances between started and completed). + self._tool_call_ids: dict[str, str] = {} + + # counters for on_result callback + self.tool_call_count: int = 0 + self.reasoning_count: int = 0 + self.session_id: str | None = None + + def _alloc(self) -> int: + idx = self._next_index + self._next_index += 1 + return idx + + def process(self, evt: dict[str, Any]) -> list[StreamTaskMessage]: + evt_type = evt.get("type", "") + + if evt_type == "thread.started": + sid = evt.get("thread_id") or "" + if sid: + self.session_id = sid + return [] + + if evt_type == "turn.started": + # The activity layer owns turn lifecycle; nothing to emit. + return [] + + if evt_type == "turn.completed": + # Usage forwarded via on_result callback (not a StreamTaskMessage). + return [] + + if evt_type == "turn.failed": + err = evt.get("error") or {} + msg = err.get("message", "codex turn failed") if isinstance(err, dict) else str(err) + return [_error_full(f"Codex turn failed: {msg}", self._alloc())] + + if evt_type == "error": + return [_error_full(evt.get("message", "codex error"), self._alloc())] + + if evt_type in ("item.started", "item.updated", "item.completed"): + item = evt.get("item") or {} + return self._handle_item(evt_type, item) + + logger.debug("[codex] unhandled event type=%s", evt_type) + return [] + + def _handle_item(self, evt_type: str, item: dict[str, Any]) -> list[StreamTaskMessage]: + item_id = item.get("id") or "" + item_type = item.get("type") or "" + out: list[StreamTaskMessage] = [] + + if item_type == "agent_message": + current = item.get("text") or "" + previous = self._text_accumulated.get(item_id, "") + + if evt_type in ("item.started", "item.updated"): + if item_id not in self._text_index: + idx = self._alloc() + self._text_index[item_id] = idx + out.append( + StreamTaskMessageStart( + type="start", + index=idx, + content=TextContent( + type="text", + author="agent", + content="", + ), + ) + ) + idx = self._text_index[item_id] + delta = "" + if current.startswith(previous) and len(current) > len(previous): + delta = current[len(previous) :] + elif current and current != previous: + delta = current + if delta: + out.append( + StreamTaskMessageDelta( + type="delta", + index=idx, + delta=TextDelta(type="text", text_delta=delta), + ) + ) + self._text_accumulated[item_id] = current + + elif evt_type == "item.completed": + if item_id not in self._text_index: + idx = self._alloc() + self._text_index[item_id] = idx + out.append( + StreamTaskMessageStart( + type="start", + index=idx, + content=TextContent( + type="text", + author="agent", + content="", + ), + ) + ) + idx = self._text_index[item_id] + delta = "" + if current.startswith(previous) and len(current) > len(previous): + delta = current[len(previous) :] + elif current and current != previous: + delta = current + if delta: + out.append( + StreamTaskMessageDelta( + type="delta", + index=idx, + delta=TextDelta(type="text", text_delta=delta), + ) + ) + out.append(StreamTaskMessageDone(type="done", index=idx)) + self._text_accumulated[item_id] = current + + elif item_type == "reasoning": + current = item.get("text") or "" + + if evt_type == "item.started": + idx = self._alloc() + self._reasoning_index[item_id] = idx + self._reasoning_text[item_id] = current + out.append( + StreamTaskMessageStart( + type="start", + index=idx, + content=ReasoningContent( + type="reasoning", + author="agent", + summary=[], + content=[], + style="active", + ), + ) + ) + + elif evt_type == "item.updated": + # Accumulate silently; final text arrives on item.completed. + self._reasoning_text[item_id] = current + + elif evt_type == "item.completed": + text = current or self._reasoning_text.get(item_id, "") + idx = self._reasoning_index.get(item_id) + if text: + self.reasoning_count += 1 + summary = text.strip().split("\n", 1)[0][:300] + if idx is None: + # No started event was seen; open the message now. + idx = self._alloc() + out.append( + StreamTaskMessageStart( + type="start", + index=idx, + content=ReasoningContent( + type="reasoning", + author="agent", + summary=[], + content=[], + style="active", + ), + ) + ) + # Deliver the reasoning as deltas, then close with a Done. + # Emitting a Full here instead would leave the open Start + # context dangling: auto_send routes Full into its own + # throwaway streaming context (ignoring the index), so the + # Start context survives until end-of-turn teardown and + # persists a second, near-empty reasoning message. Streaming + # the content as deltas lets the open context accumulate the + # final ReasoningContent and close cleanly as one message. + out.append( + StreamTaskMessageDelta( + type="delta", + index=idx, + delta=ReasoningSummaryDelta( + type="reasoning_summary", + summary_index=0, + summary_delta=summary, + ), + ) + ) + out.append( + StreamTaskMessageDelta( + type="delta", + index=idx, + delta=ReasoningContentDelta( + type="reasoning_content", + content_index=0, + content_delta=text, + ), + ) + ) + out.append(StreamTaskMessageDone(type="done", index=idx)) + elif idx is not None: + # Empty reasoning block — still need to close with a Done. + out.append(StreamTaskMessageDone(type="done", index=idx)) + + elif item_type in ( + "command_execution", + "file_change", + "mcp_tool_call", + "web_search", + "todo_list", + "collab_tool_call", + ): + # Resolve a stable id once per item; reuse it for both halves. + tool_call_id = self._tool_call_ids.get(item_id) + if tool_call_id is None: + tool_call_id = item_id or f"codex_tool_{self.tool_call_count + 1}" + self._tool_call_ids[item_id] = tool_call_id + + if evt_type == "item.started": + self.tool_call_count += 1 + self._tool_open.add(item_id) + self._tool_item_types[item_id] = item_type + name = _tool_name_for(item_type, item) + args = _tool_args_for(item_type, item) + req_idx = self._alloc() + out.append( + StreamTaskMessageStart( + type="start", + index=req_idx, + content=ToolRequestContent( + type="tool_request", + author="agent", + tool_call_id=tool_call_id, + name=name, + arguments=args, + ), + ) + ) + out.append(StreamTaskMessageDone(type="done", index=req_idx)) + + elif evt_type == "item.updated" and item_type in _PROGRESSIVE_TOOL_ITEMS: + # Codex revises its plan in place: one todo_list item is opened + # at the start of the turn and ticked off through item.updated, + # with item.completed only arriving at the very end. Forwarding + # each revision as a response for the SAME tool_call_id lets a + # consumer show the checklist filling in as the turn runs; the + # last response received is the current state. + if item_id in self._tool_open: + actual_type = self._tool_item_types.get(item_id, item_type) + result_text, is_error = _tool_output_for(actual_type, item) + resp_content: dict[str, Any] = {"result": result_text} + if is_error: + resp_content["is_error"] = True + out.append( + StreamTaskMessageFull( + type="full", + index=self._alloc(), + content=ToolResponseContent( + type="tool_response", + author="agent", + tool_call_id=tool_call_id, + name=_tool_name_for(actual_type, item), + content=resp_content, + ), + ) + ) + + elif evt_type == "item.completed": + # file_change items may only emit item.completed (no started). + if item_id not in self._tool_open: + self.tool_call_count += 1 + self._tool_open.add(item_id) + self._tool_item_types[item_id] = item_type + name = _tool_name_for(item_type, item) + args = _tool_args_for(item_type, item) + req_idx = self._alloc() + out.append( + StreamTaskMessageFull( + type="full", + index=req_idx, + content=ToolRequestContent( + type="tool_request", + author="agent", + tool_call_id=tool_call_id, + name=name, + arguments=args, + ), + ) + ) + + actual_type = self._tool_item_types.get(item_id, item_type) + result_text, is_error = _tool_output_for(actual_type, item) + name = _tool_name_for(actual_type, item) + resp_content: dict[str, Any] = {"result": result_text} + if is_error: + resp_content["is_error"] = True + out.append( + StreamTaskMessageFull( + type="full", + index=self._alloc(), + content=ToolResponseContent( + type="tool_response", + author="agent", + tool_call_id=tool_call_id, + name=name, + content=resp_content, + ), + ) + ) + self._tool_open.discard(item_id) + # Free the id mapping so a later item reusing an empty id gets a + # fresh fallback rather than colliding with this one. + self._tool_call_ids.pop(item_id, None) + + elif item_type == "error": + if evt_type == "item.completed": + out.append(_error_full(item.get("message", "codex item error"), self._alloc())) + + else: + logger.debug("[codex] unhandled item type=%s evt=%s", item_type, evt_type) + + return out + + +async def convert_codex_to_agentex_events( + events: AsyncIterator[str | dict[str, Any]], + on_result: Callable[[dict[str, Any]], None] | None = None, + on_init: Callable[[dict[str, Any]], None] | None = None, +) -> AsyncIterator[StreamTaskMessage]: + """Public tap: convert a ``codex exec --json`` event stream to events. + + Thin wrapper over :func:`_convert_codex_impl` that owns the cancellation + backstop: a ``finally`` closes the underlying ``events`` iterator (when it + exposes ``aclose``) whenever this generator is closed — including on the + ``GeneratorExit``/``CancelledError`` raised when a consuming task is + cancelled mid-turn by an interrupt. This terminates the CLI stdout handle / + subprocess instead of leaking it. + """ + inner = _convert_codex_impl(events, on_result=on_result, on_init=on_init) + try: + async for event in inner: + yield event + finally: + inner_aclose = getattr(inner, "aclose", None) + if inner_aclose is not None: + await inner_aclose() + aclose = getattr(events, "aclose", None) + if aclose is not None: + await aclose() + + +async def _convert_codex_impl( + events: AsyncIterator[str | dict[str, Any]], + on_result: Callable[[dict[str, Any]], None] | None = None, + on_init: Callable[[dict[str, Any]], None] | None = None, +) -> AsyncIterator[StreamTaskMessage]: + """Convert a ``codex exec --json`` event stream into Agentex stream events. + + This is a pure parser tap. The caller must supply ``events`` as an async + iterator of either raw newline-delimited JSON strings or pre-decoded dicts. + No subprocess or sandbox management is done here. + + Args: + events: Async iterator of ``str`` (newline-delimited JSON lines) or + ``dict`` (pre-decoded event objects) as produced by the codex CLI's + ``--json`` flag via sandbox stdout. + on_result: Optional callback invoked once when a ``turn.completed`` + event is seen. Receives a dict with keys: + ``usage`` — the raw codex usage dict (or None) + ``session_id`` — the codex thread_id (or None) + ``tool_call_count`` — int + ``reasoning_count`` — int + Use this to record turn-level metrics / usage in the caller's span + without coupling this module to span/tracing APIs. + on_init: Optional callback invoked once when the ``thread.started`` event + is seen (the first event of a codex stream). Receives ``{"session_id": + }``. This surfaces the session id EARLY — before + the turn completes — so a turn interrupted before completion is still + resumable (``turn.completed`` / ``on_result`` never fires then). The + codex counterpart of the claude-code ``system/init`` early capture. + + Yields: + Canonical ``StreamTaskMessage*`` events (Start/Delta/Full/Done) with + ``TextContent``, ``ReasoningContent``, ``ToolRequestContent``, or + ``ToolResponseContent`` payloads. + + MAPPING (abbreviated — see module docstring for the full table) + thread.started -> no event; session_id captured for on_result + turn.started -> no event + turn.completed -> no event; triggers on_result callback + turn.failed / error -> StreamTaskMessageFull(TextContent, error) + agent_message -> Start + Deltas + Done + reasoning -> Start + Full(ReasoningContent) + command_execution -> Start(ToolRequest)+Done + Full(ToolResponse) + file_change -> Full(ToolRequest) + Full(ToolResponse) + mcp_tool_call -> Start(ToolRequest)+Done + Full(ToolResponse) + web_search / todo_list -> Start(ToolRequest)+Done + Full(ToolResponse) + collab_tool_call -> Start(ToolRequest)+Done + Full(ToolResponse) + """ + processor = _CodexStreamProcessor() + _pending_usage: dict[str, Any] | None = None + + async for raw in events: + if isinstance(raw, dict): + evt = raw + else: + line = raw.strip() if isinstance(raw, str) else "" + if not line: + continue + try: + evt = json.loads(line) + except json.JSONDecodeError: + logger.debug("[codex] non-JSON line: %s", line[:100]) + continue + + # Capture usage before processing so on_result can fire after flush. + if evt.get("type") == "turn.completed": + usage = evt.get("usage") + _pending_usage = usage if isinstance(usage, dict) else None + + messages = processor.process(evt) + for msg in messages: + yield msg + + # Surface session_id early (processor sets it while handling + # thread.started) so an interrupted turn is still resumable. + if on_init is not None and evt.get("type") == "thread.started": + on_init({"session_id": processor.session_id}) + + if on_result is not None: + on_result( + { + "usage": _pending_usage, + "session_id": processor.session_id, + "tool_call_count": processor.tool_call_count, + "reasoning_count": processor.reasoning_count, + } + ) diff --git a/src/agentex/lib/adk/_modules/_codex_turn.py b/src/agentex/lib/adk/_modules/_codex_turn.py new file mode 100644 index 000000000..05429bce5 --- /dev/null +++ b/src/agentex/lib/adk/_modules/_codex_turn.py @@ -0,0 +1,228 @@ +"""CodexTurn: HarnessTurn implementation for the codex event-stream tap. + +Wraps ``convert_codex_to_agentex_events`` so callers can pass a ``CodexTurn`` +directly to ``UnifiedEmitter.yield_turn`` or ``UnifiedEmitter.auto_send_turn``. + +Usage:: + + from agentex.lib.adk import convert_codex_to_agentex_events + from agentex.lib.adk._modules._codex_turn import CodexTurn, codex_usage_to_turn_usage + + turn = CodexTurn(events=codex_event_stream, model="o4-mini") + async for msg in emitter.yield_turn(turn): + yield msg + turn_usage = turn.usage() + +OUT OF SCOPE +------------ +Like ``_codex_sync``, this module is a pure library tap. Subprocess +provisioning, sandbox setup, secret injection, and MCP configuration remain +in the golden agent (``teams/sgp/agents/golden_agent/project/harness/``). +""" + +from __future__ import annotations + +from typing import Any, AsyncIterator + +from agentex.lib.core.harness.types import TurnUsage +from agentex.lib.adk._modules._codex_sync import ( + StreamTaskMessage, + convert_codex_to_agentex_events, +) + + +def codex_usage_to_turn_usage( + raw: dict[str, Any] | None, + *, + model: str | None = None, + tool_call_count: int = 0, + reasoning_count: int = 0, + duration_ms: int | None = None, + cost_usd: float | None = None, +) -> TurnUsage: + """Map a raw codex ``turn.completed`` usage dict to a canonical ``TurnUsage``. + + Codex reports token usage under the ``usage`` key of the + ``turn.completed`` event. The shape follows the OpenAI completion_tokens + convention because codex is built on OpenAI models: + + .. code-block:: json + + { + "input_tokens": 1234, + "output_tokens": 456, + "total_tokens": 1690 + } + + Additionally, codex may report ``reasoning_tokens`` for o-series models: + + .. code-block:: json + + { + "input_tokens": 1234, + "output_tokens": 456, + "reasoning_tokens": 200, + "total_tokens": 1690 + } + + Defensive rules: + - Missing ``raw`` or missing sub-keys default to ``None`` (not zero) so + downstream callers can distinguish "not reported" from "reported as 0". + - Real zeros (``0`` explicitly present in ``raw``) are preserved as ``0``. + - ``total_tokens`` is accepted from the payload or left as ``None``; + callers should not recompute it because codex may use cached tokens. + - ``cost_usd`` is passed through when codex reports it (not yet common); + defaults to ``None`` if absent. + + Args: + raw: The raw codex usage dict from ``turn.completed``, or ``None``. + model: Model string (e.g. "o4-mini") to attach to the usage record. + tool_call_count: Number of tool calls in the turn (from processor). + reasoning_count: Number of reasoning blocks (from processor). + duration_ms: Wall-clock duration of the turn in milliseconds. + cost_usd: Cost in USD if the caller can derive it; ``None`` otherwise. + + Returns: + A populated ``TurnUsage`` instance. + """ + if not isinstance(raw, dict): + raw = {} + + def _int_or_none(key: str) -> int | None: + val = raw.get(key) + if val is None: + return None + try: + return int(val) + except (TypeError, ValueError): + return None + + def _float_or_none(key: str) -> float | None: + val = raw.get(key) + if val is None: + return None + try: + return float(val) + except (TypeError, ValueError): + return None + + # cost_usd: prefer explicitly passed value, then fall back to raw payload. + effective_cost = cost_usd if cost_usd is not None else _float_or_none("cost_usd") + + return TurnUsage( + model=model or None, + input_tokens=_int_or_none("input_tokens"), + output_tokens=_int_or_none("output_tokens"), + cached_input_tokens=_int_or_none("cached_input_tokens"), + reasoning_tokens=_int_or_none("reasoning_tokens"), + total_tokens=_int_or_none("total_tokens"), + cost_usd=effective_cost, + duration_ms=duration_ms, + num_llm_calls=1, + num_tool_calls=tool_call_count, + num_reasoning_blocks=reasoning_count, + ) + + +class CodexTurn: + """A single codex turn as a ``HarnessTurn``. + + Implements the ``HarnessTurn`` protocol so it can be passed to + ``UnifiedEmitter.yield_turn`` and ``UnifiedEmitter.auto_send_turn``. + + ``usage()`` is valid only after ``events`` has been fully consumed (i.e. + the async generator has been exhausted). Calling ``usage()`` before + exhaustion returns a zero-value ``TurnUsage`` with only ``model`` set. + + Args: + events: An async iterator of ``str | dict`` codex events, as + produced by reading ``codex exec --json`` stdout line by line. + model: Model string to attach to the ``TurnUsage``. + duration_ms: Optional turn wall-clock duration in milliseconds. + cost_usd: Optional cost in USD; ``None`` if not known. + """ + + def __init__( + self, + events: AsyncIterator[str | dict[str, Any]], + *, + model: str | None = None, + duration_ms: int | None = None, + cost_usd: float | None = None, + ) -> None: + self._raw_events = events + self._model = model + # Public + mutable: the true wall-clock duration (and cost) is usually + # only known after the stream is consumed, so callers may set these + # after construction and before calling usage(). + self.duration_ms = duration_ms + self.cost_usd = cost_usd + + # Populated by the on_result callback once the stream is exhausted. + self._result: dict[str, Any] | None = None + # Populated by the on_init callback when thread.started arrives (early), + # so session_id is available even if the turn is interrupted before + # completion (turn.completed / on_result never fires then). + self._init_session_id: str | None = None + # The events generator is created at most once: ``_raw_events`` is a + # single-consumption AsyncIterator, so re-wrapping it would yield an + # already-exhausted stream that fires on_result with zeros and clobbers + # ``_result``. Cache the generator and hand back the same instance. + self._events_gen: AsyncIterator[StreamTaskMessage] | None = None + + @property + def events(self) -> AsyncIterator[StreamTaskMessage]: + """Async iterator of canonical ``StreamTaskMessage*`` events. + + The ``on_result`` callback populates ``_result`` when the underlying + codex stream ends, so ``usage()`` returns meaningful data after + exhaustion. Returns the same generator on every access so the underlying + stream is consumed (and ``on_result`` fires) exactly once. + """ + if self._events_gen is None: + self._events_gen = convert_codex_to_agentex_events( + self._raw_events, + on_result=self._on_result, + on_init=self._on_init, + ) + return self._events_gen + + def _on_result(self, result: dict[str, Any]) -> None: + self._result = result + + def _on_init(self, init: dict[str, Any]) -> None: + sid = init.get("session_id") + if sid: + self._init_session_id = sid + + @property + def session_id(self) -> str | None: + """The codex session id, for resuming a multi-turn session. + + Prefers the id from the terminal ``turn.completed`` result (fully-complete + turn), then falls back to the id captured early from ``thread.started``. + The early fallback keeps a turn interrupted before completion resumable + (no ``turn.completed`` / ``on_result`` fires then). Returns ``None`` only + if neither was seen or codex reported no session id. + """ + if self._result and self._result.get("session_id"): + return self._result.get("session_id") + return self._init_session_id + + def usage(self) -> TurnUsage: + """Return normalized ``TurnUsage`` for this turn. + + Valid only after ``events`` has been fully consumed. Returns a + zero-value ``TurnUsage`` (model set, counts zero, tokens None) if + called before the stream ends. + """ + if self._result is None: + return TurnUsage(model=self._model) + return codex_usage_to_turn_usage( + self._result.get("usage"), + model=self._model, + tool_call_count=self._result.get("tool_call_count", 0), + reasoning_count=self._result.get("reasoning_count", 0), + duration_ms=self.duration_ms, + cost_usd=self.cost_usd, + ) diff --git a/src/agentex/lib/adk/_modules/_http_checkpointer.py b/src/agentex/lib/adk/_modules/_http_checkpointer.py new file mode 100644 index 000000000..ce37cc5f2 --- /dev/null +++ b/src/agentex/lib/adk/_modules/_http_checkpointer.py @@ -0,0 +1,380 @@ +"""HTTP-proxy LangGraph checkpointer. + +Proxies all checkpoint operations through the agentex backend API +instead of connecting directly to PostgreSQL. The backend handles DB +operations through its own connection pool. +""" + +from __future__ import annotations + +import base64 +import random +from typing import Any, cast, override +from collections.abc import Iterator, Sequence, AsyncIterator + +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.base import ( + WRITES_IDX_MAP, + Checkpoint, + ChannelVersions, + CheckpointTuple, + CheckpointMetadata, + BaseCheckpointSaver, + get_checkpoint_id, + get_serializable_checkpoint_metadata, +) +from langgraph.checkpoint.serde.types import TASKS + +from agentex import AsyncAgentex +from agentex.lib.utils.logging import make_logger + +logger = make_logger(__name__) + + +def _bytes_to_b64(data: bytes | None) -> str | None: + if data is None: + return None + return base64.b64encode(data).decode("ascii") + + +def _b64_to_bytes(data: str | None) -> bytes | None: + if data is None: + return None + return base64.b64decode(data) + + +class HttpCheckpointSaver(BaseCheckpointSaver[str]): + """Checkpoint saver that proxies operations through the agentex HTTP API.""" + + def __init__(self, client: AsyncAgentex) -> None: + super().__init__() + self._http = client._client # noqa: SLF001 # raw httpx.AsyncClient for direct HTTP calls + + async def _post(self, path: str, body: dict[str, Any]) -> Any: + """POST JSON to the backend and return parsed response.""" + response = await self._http.post( + f"/checkpoints{path}", + json=body, + ) + response.raise_for_status() + # put-writes and delete-thread return 204 No Content (no JSON body) + if response.status_code == 204: + return None + return response.json() + + # ── get_next_version (same as BasePostgresSaver) ── + + @override + def get_next_version(self, current: str | None, channel: None) -> str: # type: ignore[override] # noqa: ARG002 + if current is None: + current_v = 0 + elif isinstance(current, int): + current_v = current + else: + current_v = int(current.split(".")[0]) + next_v = current_v + 1 + next_h = random.random() # noqa: S311 + return f"{next_v:032}.{next_h:016}" + + # ── async interface ── + + @override + async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: + configurable = config["configurable"] # type: ignore[reportTypedDictNotRequiredAccess] + thread_id = configurable["thread_id"] + checkpoint_ns = configurable.get("checkpoint_ns", "") + checkpoint_id = get_checkpoint_id(config) + + data = await self._post( + "/get-tuple", + { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": checkpoint_id, + }, + ) + + if data is None: + return None + + # Reconstruct channel_values from blobs + inline values + checkpoint = data["checkpoint"] + channel_values: dict[str, Any] = {} + + # Inline primitive values already in the checkpoint + if "channel_values" in checkpoint and checkpoint["channel_values"]: + channel_values.update(checkpoint["channel_values"]) + + # Deserialize blobs + for blob in data.get("blobs", []): + blob_type = blob["type"] + if blob_type == "empty": + continue + blob_bytes = _b64_to_bytes(blob.get("blob")) + channel_values[blob["channel"]] = self.serde.loads_typed((blob_type, blob_bytes)) + + checkpoint["channel_values"] = channel_values + + # Handle pending_sends migration for v < 4 + if checkpoint.get("v", 0) < 4 and data.get("parent_checkpoint_id"): + # The backend already returns all writes; filter for TASKS channel sends + pending_sends_raw = [w for w in data.get("pending_writes", []) if w["channel"] == TASKS] + if pending_sends_raw: + sends = [ + self.serde.loads_typed((w["type"], _b64_to_bytes(w["blob"]))) + for w in pending_sends_raw + if w.get("type") + ] + if sends: + enc, blob_data = self.serde.dumps_typed(sends) + channel_values[TASKS] = self.serde.loads_typed((enc, blob_data)) + if checkpoint.get("channel_versions") is None: + checkpoint["channel_versions"] = {} + checkpoint["channel_versions"][TASKS] = ( + max(checkpoint["channel_versions"].values()) + if checkpoint["channel_versions"] + else self.get_next_version(None, None) + ) + + # Reconstruct pending writes + pending_writes: list[tuple[str, str, Any]] = [] + for w in data.get("pending_writes", []): + w_type = w.get("type") + w_bytes = _b64_to_bytes(w.get("blob")) + pending_writes.append( + ( + w["task_id"], + w["channel"], + self.serde.loads_typed((w_type, w_bytes)) if w_type else w_bytes, + ) + ) + + parent_config: RunnableConfig | None = None + if data.get("parent_checkpoint_id"): + parent_config = { + "configurable": { + "thread_id": data["thread_id"], + "checkpoint_ns": data["checkpoint_ns"], + "checkpoint_id": data["parent_checkpoint_id"], + } + } + + return CheckpointTuple( + config={ + "configurable": { + "thread_id": data["thread_id"], + "checkpoint_ns": data["checkpoint_ns"], + "checkpoint_id": data["checkpoint_id"], + } + }, + checkpoint=checkpoint, + metadata=data["metadata"], + parent_config=parent_config, + pending_writes=pending_writes, + ) + + @override + async def aput( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + configurable = config["configurable"].copy() # type: ignore[reportTypedDictNotRequiredAccess] + thread_id = configurable.pop("thread_id") + checkpoint_ns = configurable.pop("checkpoint_ns") + checkpoint_id = configurable.pop("checkpoint_id", None) + + # Separate inline values from blobs (same logic as AsyncPostgresSaver) + copy = checkpoint.copy() + copy["channel_values"] = copy["channel_values"].copy() + blob_values: dict[str, Any] = {} + for k, v in checkpoint["channel_values"].items(): + if v is None or isinstance(v, (str, int, float, bool)): + pass + else: + blob_values[k] = copy["channel_values"].pop(k) + + # Serialize blob values + blobs: list[dict[str, Any]] = [] + for k, ver in new_versions.items(): + if k in blob_values: + enc, data = self.serde.dumps_typed(blob_values[k]) + blobs.append( + { + "channel": k, + "version": cast(str, ver), + "type": enc, + "blob": _bytes_to_b64(data), + } + ) + else: + blobs.append( + { + "channel": k, + "version": cast(str, ver), + "type": "empty", + "blob": None, + } + ) + + await self._post( + "/put", + { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": checkpoint["id"], + "parent_checkpoint_id": checkpoint_id, + "checkpoint": copy, + "metadata": get_serializable_checkpoint_metadata(config, metadata), + "blobs": blobs, + }, + ) + + return { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": checkpoint["id"], + } + } + + @override + async def aput_writes( + self, + config: RunnableConfig, + writes: Sequence[tuple[str, Any]], + task_id: str, + task_path: str = "", + ) -> None: + configurable = config["configurable"] # type: ignore[reportTypedDictNotRequiredAccess] + thread_id = configurable["thread_id"] + checkpoint_ns = configurable["checkpoint_ns"] + checkpoint_id = configurable["checkpoint_id"] + + upsert = all(w[0] in WRITES_IDX_MAP for w in writes) + + serialized_writes: list[dict[str, Any]] = [] + for idx, (channel, value) in enumerate(writes): + enc, data = self.serde.dumps_typed(value) + serialized_writes.append( + { + "task_id": task_id, + "idx": WRITES_IDX_MAP.get(channel, idx), + "channel": channel, + "type": enc, + "blob": _bytes_to_b64(data), + "task_path": task_path, + } + ) + + await self._post( + "/put-writes", + { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": checkpoint_id, + "writes": serialized_writes, + "upsert": upsert, + }, + ) + + @override + async def alist( + self, + config: RunnableConfig | None, + *, + filter: dict[str, Any] | None = None, + before: RunnableConfig | None = None, + limit: int | None = None, + ) -> AsyncIterator[CheckpointTuple]: + body: dict[str, Any] = {} + if config: + configurable = config["configurable"] # type: ignore[reportTypedDictNotRequiredAccess] + body["thread_id"] = configurable["thread_id"] + checkpoint_ns = configurable.get("checkpoint_ns") + if checkpoint_ns is not None: + body["checkpoint_ns"] = checkpoint_ns + if filter: + body["filter_metadata"] = filter + if before: + body["before_checkpoint_id"] = get_checkpoint_id(before) + if limit is not None: + body["limit"] = limit + + results = await self._post("/list", body) + + for item in results or []: + # For each listed checkpoint, reconstruct a CheckpointTuple + # with inline channel_values only (blobs not included in list) + checkpoint = item["checkpoint"] + parent_config: RunnableConfig | None = None + if item.get("parent_checkpoint_id"): + parent_config = { + "configurable": { + "thread_id": item["thread_id"], + "checkpoint_ns": item["checkpoint_ns"], + "checkpoint_id": item["parent_checkpoint_id"], + } + } + yield CheckpointTuple( + config={ + "configurable": { + "thread_id": item["thread_id"], + "checkpoint_ns": item["checkpoint_ns"], + "checkpoint_id": item["checkpoint_id"], + } + }, + checkpoint=checkpoint, + metadata=item["metadata"], + parent_config=parent_config, + pending_writes=None, + ) + + @override + async def adelete_thread(self, thread_id: str) -> None: + await self._post("/delete-thread", {"thread_id": thread_id}) + + # ── sync stubs (required by BaseCheckpointSaver) ── + # LangGraph always calls the async methods (aget_tuple, aput, etc.). + # Sync methods are only required by the abstract base class. + + @override + def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: + raise NotImplementedError("Use aget_tuple() instead.") + + @override + def list( + self, + config: RunnableConfig | None, + *, + filter: dict[str, Any] | None = None, + before: RunnableConfig | None = None, + limit: int | None = None, + ) -> Iterator[CheckpointTuple]: + raise NotImplementedError("Use alist() instead.") + + @override + def put( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + raise NotImplementedError("Use aput() instead.") + + @override + def put_writes( + self, + config: RunnableConfig, + writes: Sequence[tuple[str, Any]], + task_id: str, + task_path: str = "", + ) -> None: + raise NotImplementedError("Use aput_writes() instead.") + + @override + def delete_thread(self, thread_id: str) -> None: + raise NotImplementedError("Use adelete_thread() instead.") diff --git a/src/agentex/lib/adk/_modules/_langgraph_sync.py b/src/agentex/lib/adk/_modules/_langgraph_sync.py new file mode 100644 index 000000000..02f2ba416 --- /dev/null +++ b/src/agentex/lib/adk/_modules/_langgraph_sync.py @@ -0,0 +1,373 @@ +"""Sync LangGraph streaming helper for Agentex. + +Converts LangGraph graph.astream() events into Agentex TaskMessageUpdate +events that are yielded back over the HTTP response. For use with sync ACP +agents that stream via HTTP yields rather than Redis. + +Unified sync path +----------------- +Prefer using ``LangGraphTurn`` with ``UnifiedEmitter.yield_turn`` for new +agents, which adds usage capture and optional tracing via the shared harness +surface:: + + from agentex.lib.core.harness.emitter import UnifiedEmitter + from agentex.lib.adk._modules._langgraph_turn import LangGraphTurn + + turn = LangGraphTurn(stream) + emitter = UnifiedEmitter(task_id=task_id, trace_id=trace_id, parent_span_id=span_id) + async for event in emitter.yield_turn(turn): + yield event + +``convert_langgraph_to_agentex_events`` remains available as a lower-level +primitive (e.g. for callers that need the raw event stream without the +harness envelope). +""" + +from __future__ import annotations + +from typing import Any, Callable, Optional +from collections.abc import AsyncGenerator + + +async def convert_langgraph_to_agentex_events( + stream: Any, + on_final_ai_message: Optional[Callable[..., None]] = None, +) -> AsyncGenerator[Any, None]: + """Public LangGraph tap: convert events, closing the source stream on exit. + + Thin wrapper over ``_convert_langgraph_impl`` that adds a cancellation-safe + ``finally`` so an interrupted turn tears down the LangGraph ``astream`` source + instead of leaking it. + """ + inner = _convert_langgraph_impl(stream, on_final_ai_message=on_final_ai_message) + try: + async for event in inner: + yield event + finally: + for _src in (inner, stream): + _aclose = getattr(_src, "aclose", None) + if _aclose is not None: + await _aclose() + + +async def _convert_langgraph_impl( + stream: Any, + on_final_ai_message: Optional[Callable[..., None]] = None, +) -> AsyncGenerator[Any, None]: + """Convert LangGraph streaming events to Agentex TaskMessageUpdate events. + + Expects the stream from graph.astream() called with + stream_mode=["messages", "updates"]. This produces two event types: + + ("messages", (message_chunk, metadata)) — token-by-token LLM output + ("updates", {node_name: state_update}) — complete node outputs + + Text tokens are streamed as Start/Delta/Done sequences. + Reasoning tokens are streamed as Start/Delta/Done sequences with ReasoningContentDelta. + Tool calls and tool results are emitted as Full messages. + + Supports both regular models (chunk.content is a str) and reasoning models + like gpt-5/o1/o3 (chunk.content is a list of typed content blocks). + + LangGraph emits tool requests as ``StreamTaskMessageFull`` (from "updates" + events), NOT Start+Delta+Done like pydantic-ai. No coalesce_tool_requests + option is needed for LangGraph. + + Args: + stream: Async iterator from graph.astream(..., stream_mode=["messages", "updates"]) + on_final_ai_message: Optional callback ``(msg: AIMessage) -> None`` called for + each ``AIMessage`` in an "agent" node update. Use this to capture + ``usage_metadata`` for token accounting without re-traversing the stream. + The callback fires *after* all events for that message are yielded. + No-op when ``None`` (default). + + Yields: + TaskMessageUpdate events (Start, Delta, Done, Full) + """ + # Lazy imports so langgraph/langchain aren't required at module load time + from langchain_core.messages import ToolMessage, AIMessageChunk + + from agentex.types.text_content import TextContent + from agentex.types.reasoning_content import ReasoningContent + from agentex.types.task_message_delta import TextDelta + from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, + ) + from agentex.types.tool_request_content import ToolRequestContent + from agentex.types.tool_response_content import ToolResponseContent + from agentex.types.reasoning_content_delta import ReasoningContentDelta + from agentex.types.reasoning_summary_delta import ReasoningSummaryDelta + + message_index = 0 + text_streaming = False + reasoning_streaming = False + reasoning_content_index = 0 + + async for event_type, event_data in stream: + if event_type == "messages": + chunk, metadata = event_data + + if not isinstance(chunk, AIMessageChunk) or not chunk.content: + continue + + # ---------------------------------------------------------- + # Case 1: content is a plain string (regular models) + # ---------------------------------------------------------- + if isinstance(chunk.content, str): + # Close reasoning stream if we're transitioning to text + if reasoning_streaming: + yield StreamTaskMessageDone(type="done", index=message_index) + reasoning_streaming = False + message_index += 1 + + if not text_streaming: + yield StreamTaskMessageStart( + type="start", + index=message_index, + content=TextContent(type="text", author="agent", content=""), + ) + text_streaming = True + + yield StreamTaskMessageDelta( + type="delta", + index=message_index, + delta=TextDelta(type="text", text_delta=chunk.content), + ) + + # ---------------------------------------------------------- + # Case 2: content is a list of typed blocks (reasoning models) + # Responses API (responses/v1) format: + # {"type": "reasoning", "summary": [{"type": "summary_text", "text": "..."}]} + # {"type": "text", "text": "..."} + # ---------------------------------------------------------- + elif isinstance(chunk.content, list): + for block in chunk.content: + if not isinstance(block, dict): + continue + + block_type = block.get("type") + + if block_type == "reasoning": + # Responses API: reasoning text is inside summary list + reasoning_text = "" + summaries = block.get("summary", []) + for s in summaries: + if isinstance(s, dict) and s.get("type") == "summary_text": + reasoning_text += s.get("text", "") + if not reasoning_text: + continue + + # Close text stream if transitioning to reasoning + if text_streaming: + yield StreamTaskMessageDone(type="done", index=message_index) + text_streaming = False + message_index += 1 + + if not reasoning_streaming: + yield StreamTaskMessageStart( + type="start", + index=message_index, + content=ReasoningContent( + type="reasoning", author="agent", summary=[], content=[], style="active" + ), + ) + reasoning_streaming = True + reasoning_content_index = 0 + + yield StreamTaskMessageDelta( + type="delta", + index=message_index, + delta=ReasoningContentDelta( + type="reasoning_content", + content_index=reasoning_content_index, + content_delta=reasoning_text, + ), + ) + + elif block_type == "text": + text_delta = block.get("text", "") + if not text_delta: + continue + + # Close reasoning stream if transitioning to text + if reasoning_streaming: + yield StreamTaskMessageDone(type="done", index=message_index) + reasoning_streaming = False + reasoning_content_index += 1 + message_index += 1 + + if not text_streaming: + yield StreamTaskMessageStart( + type="start", + index=message_index, + content=TextContent(type="text", author="agent", content=""), + ) + text_streaming = True + + yield StreamTaskMessageDelta( + type="delta", + index=message_index, + delta=TextDelta(type="text", text_delta=text_delta), + ) + + # ---------------------------------------------------------- + # Reasoning summaries via additional_kwargs (OpenAI v0.3 format) + # ---------------------------------------------------------- + additional_kwargs = getattr(chunk, "additional_kwargs", {}) + reasoning_kw = additional_kwargs.get("reasoning") + if isinstance(reasoning_kw, dict): + summaries = reasoning_kw.get("summary", []) + for si, summary_item in enumerate(summaries): + if isinstance(summary_item, dict) and summary_item.get("type") == "summary_text": + summary_text = summary_item.get("text", "") + if summary_text: + yield StreamTaskMessageDelta( + type="delta", + index=message_index, + delta=ReasoningSummaryDelta( + type="reasoning_summary", + summary_index=si, + summary_delta=summary_text, + ), + ) + + elif event_type == "updates": + for node_name, state_update in event_data.items(): + if node_name == "agent": + messages = state_update.get("messages", []) + for msg in messages: + # Close any open streams + if text_streaming: + yield StreamTaskMessageDone(type="done", index=message_index) + text_streaming = False + message_index += 1 + if reasoning_streaming: + yield StreamTaskMessageDone(type="done", index=message_index) + reasoning_streaming = False + message_index += 1 + + # Emit tool requests if the agent decided to call tools + if hasattr(msg, "tool_calls") and msg.tool_calls: + for tc in msg.tool_calls: + yield StreamTaskMessageFull( + type="full", + index=message_index, + content=ToolRequestContent( + tool_call_id=tc["id"], + name=tc["name"], + arguments=tc["args"], + author="agent", + ), + ) + message_index += 1 + + # Notify caller of the final AIMessage (e.g. for usage capture) + if on_final_ai_message is not None: + from langchain_core.messages import AIMessage as _AIMessage + + if isinstance(msg, _AIMessage): + on_final_ai_message(msg) + + elif node_name == "tools": + messages = state_update.get("messages", []) + for msg in messages: + if isinstance(msg, ToolMessage): + yield StreamTaskMessageFull( + type="full", + index=message_index, + content=ToolResponseContent( + tool_call_id=msg.tool_call_id, + name=msg.name or "unknown", + content=msg.content if isinstance(msg.content, str) else str(msg.content), + author="agent", + ), + ) + message_index += 1 + + # Close any remaining open streams + if text_streaming: + yield StreamTaskMessageDone(type="done", index=message_index) + if reasoning_streaming: + yield StreamTaskMessageDone(type="done", index=message_index) + + +async def emit_langgraph_messages(messages: list[Any], task_id: str) -> str: + """Create Agentex messages for a list of LangGraph messages. + + This is the non-streaming counterpart to ``stream_langgraph_events``. Use it + when you run a LangGraph graph with ``ainvoke`` (for example a Temporal-backed + agent using the LangGraph plugin, where streaming deltas aren't available) and + want to surface the resulting messages to the Agentex UI after the fact. + + It maps LangGraph/LangChain message objects to Agentex content types: + + - ``AIMessage`` tool calls -> ``ToolRequestContent`` (one per call) + - ``AIMessage`` text content -> ``TextContent`` + - ``ToolMessage`` -> ``ToolResponseContent`` + + Pass only the messages produced this turn (e.g. ``messages[already_emitted:]``) + so each message is surfaced exactly once across a multi-turn conversation. + + Args: + messages: LangGraph/LangChain message objects to surface — typically + the new messages a turn produced. + task_id: The Agentex task to create messages on. + + Returns: + The last assistant text emitted (useful as a span/turn output), or "". + """ + # Lazy imports so langchain isn't required at module load time. + from langchain_core.messages import AIMessage, ToolMessage + + from agentex.lib import adk + from agentex.types.text_content import TextContent + from agentex.types.tool_request_content import ToolRequestContent + from agentex.types.tool_response_content import ToolResponseContent + + final_text = "" + for message in messages: + if isinstance(message, AIMessage): + for tool_call in message.tool_calls or []: + await adk.messages.create( + task_id=task_id, + content=ToolRequestContent( + author="agent", + tool_call_id=tool_call["id"], + name=tool_call["name"], + arguments=tool_call["args"], + ), + ) + # ``content`` may be a plain string (OpenAI) or a list of content + # blocks (Anthropic/Claude via LangChain, e.g. + # ``[{"type": "text", "text": "..."}]``). Extract and join the text + # so the response is visible regardless of the underlying model. + if isinstance(message.content, str): + text = message.content + else: + text = "".join( + block.get("text", "") if isinstance(block, dict) else str(block) + for block in message.content + if not isinstance(block, dict) or block.get("type") == "text" + ) + if text: + final_text = text + await adk.messages.create( + task_id=task_id, + content=TextContent(author="agent", content=text, format="markdown"), + ) + elif isinstance(message, ToolMessage): + await adk.messages.create( + task_id=task_id, + content=ToolResponseContent( + author="agent", + tool_call_id=message.tool_call_id, + name=message.name or "unknown", + content=message.content + if isinstance(message.content, str) + else str(message.content), + ), + ) + return final_text diff --git a/src/agentex/lib/adk/_modules/_langgraph_turn.py b/src/agentex/lib/adk/_modules/_langgraph_turn.py new file mode 100644 index 000000000..a6e290e1b --- /dev/null +++ b/src/agentex/lib/adk/_modules/_langgraph_turn.py @@ -0,0 +1,200 @@ +"""HarnessTurn adapter for LangGraph astream() event streams. + +Provides ``LangGraphTurn`` (a ``HarnessTurn`` implementation) and the +``langgraph_usage_to_turn_usage`` helper that maps LangGraph's +``AIMessage.usage_metadata`` onto the framework-agnostic ``TurnUsage`` model. + +LangGraph emits tool requests as ``StreamTaskMessageFull`` events (from +"updates" events), NOT Start+Delta+Done like pydantic-ai. ``auto_send`` handles +Full events correctly; no coalescing wrapper is needed. +""" + +from __future__ import annotations + +from typing import Any, AsyncIterator +from collections.abc import AsyncGenerator + +from agentex.lib.utils.temporal import workflow_now_if_in_workflow +from agentex.lib.core.harness.types import TurnUsage, StreamTaskMessage +from agentex.lib.adk._modules._langgraph_sync import convert_langgraph_to_agentex_events + + +def langgraph_usage_to_turn_usage(usage_metadata: Any, model: str | None) -> TurnUsage: + """Map LangGraph ``AIMessage.usage_metadata`` onto ``TurnUsage``. + + ``usage_metadata`` may be ``None`` (model doesn't report usage). + Real zero token counts (e.g. 0 output tokens) are preserved as 0, NOT + coerced to ``None``. + + Mapping:: + + input_tokens -> input_tokens + output_tokens -> output_tokens + total_tokens -> total_tokens + input_token_details.cache_read -> cached_input_tokens + output_token_details.reasoning -> reasoning_tokens + + Args: + usage_metadata: The ``usage_metadata`` dict from an ``AIMessage``, + or ``None`` if the model did not report usage. + model: The model name string to attach to the ``TurnUsage``, or ``None``. + + Returns: + A populated ``TurnUsage`` instance. + """ + if usage_metadata is None: + return TurnUsage(model=model) + + raw_input = (usage_metadata or {}).get("input_tokens") + raw_output = (usage_metadata or {}).get("output_tokens") + raw_total = (usage_metadata or {}).get("total_tokens") + input_details = (usage_metadata or {}).get("input_token_details") or {} + output_details = (usage_metadata or {}).get("output_token_details") or {} + raw_cache_read = input_details.get("cache_read") + raw_reasoning = output_details.get("reasoning") + + return TurnUsage( + model=model, + input_tokens=raw_input, + output_tokens=raw_output, + total_tokens=raw_total, + cached_input_tokens=raw_cache_read, + reasoning_tokens=raw_reasoning, + ) + + +def _add_optional(a: int | None, b: int | None) -> int | None: + """Sum two optional token counts; ``None`` means 'not reported' on that side. + + ``None + None`` stays ``None`` (model never reported usage), while a real 0 + contributes 0 (preserving zero counts rather than coercing them away). + """ + if a is None and b is None: + return None + return (a or 0) + (b or 0) + + +def _accumulate_turn_usage(acc: TurnUsage, call: TurnUsage, model: str | None) -> TurnUsage: + """Add a single LLM call's usage into the running per-turn total. + + A LangGraph turn can make multiple LLM calls (e.g. text -> tool decision -> + final text); summing them avoids silently dropping all but the last call. + """ + return TurnUsage( + model=model, + input_tokens=_add_optional(acc.input_tokens, call.input_tokens), + output_tokens=_add_optional(acc.output_tokens, call.output_tokens), + total_tokens=_add_optional(acc.total_tokens, call.total_tokens), + cached_input_tokens=_add_optional(acc.cached_input_tokens, call.cached_input_tokens), + reasoning_tokens=_add_optional(acc.reasoning_tokens, call.reasoning_tokens), + ) + + +class LangGraphTurn: + """HarnessTurn wrapping a LangGraph ``astream()`` event stream. + + Implements the ``HarnessTurn`` Protocol so it can be passed to either + ``UnifiedEmitter.yield_turn`` (sync HTTP ACP) or + ``UnifiedEmitter.auto_send_turn`` (async / temporal). + + Usage:: + + stream = graph.astream( + {"messages": [{"role": "user", "content": user_message}]}, + stream_mode=["messages", "updates"], + ) + turn = LangGraphTurn(stream, model=model_name) + + # Sync HTTP ACP + async for event in emitter.yield_turn(turn): + yield event + + # Async / temporal + result = await emitter.auto_send_turn(turn) + + LangGraph tool requests are ``StreamTaskMessageFull`` (from "updates"), NOT + Start+Delta+Done like pydantic-ai. No ``coalesce_tool_requests`` option is + needed. + + Usage data is captured lazily via the ``on_final_ai_message`` callback and + is only valid after ``events`` has been fully consumed. Multi-step turns + (more than one LLM call) accumulate usage additively across calls. + """ + + def __init__(self, stream: Any, model: str | None = None) -> None: + self._stream = stream + self._model = model + self._usage: TurnUsage = TurnUsage(model=model) + + @property + def events(self) -> AsyncIterator[StreamTaskMessage]: + return self._generate_events() + + async def _generate_events(self) -> AsyncGenerator[StreamTaskMessage, None]: + def _capture(ai_msg: Any) -> None: + usage_metadata = getattr(ai_msg, "usage_metadata", None) + if usage_metadata is not None: + call_usage = langgraph_usage_to_turn_usage(usage_metadata, self._model) + # Accumulate across LLM calls — the callback fires once per agent + # node invocation, so a multi-step turn reports usage more than + # once; overwriting would drop all but the last call. + self._usage = _accumulate_turn_usage(self._usage, call_usage, self._model) + + async for ev in convert_langgraph_to_agentex_events(self._stream, on_final_ai_message=_capture): + yield ev + + def usage(self) -> TurnUsage: + """Return the usage accumulated across all AIMessages in the stream. + + Multi-step turns sum each LLM call's usage. Valid only after ``events`` + has been fully consumed. Returns a zero-usage ``TurnUsage`` if the model + did not report usage. + """ + return self._usage + + +async def stream_langgraph_events(stream, task_id: str) -> str: + """Stream LangGraph events to Agentex via Redis. + + Converts LangGraph ``graph.astream()`` events into Agentex streaming + updates and pushes them to Redis via ``adk.streaming`` contexts. For use + with async ACP agents that stream via Redis rather than HTTP yields. + + Processes the stream from graph.astream() called with + stream_mode=["messages", "updates"] and pushes text, reasoning, + tool request, and tool response messages through Redis streaming + contexts. + + Supports both regular models (chunk.content is a str) and reasoning + models like gpt-5/o1/o3 (chunk.content is a list of typed content blocks + in the Responses API responses/v1 format). + + Implemented on ``UnifiedEmitter.auto_send_turn(LangGraphTurn(...))`` for + cross-harness consistency, the same surface used by every other harness + adapter (pydantic-ai, openai-agents, etc.). The public signature and + return type are preserved identically. + + LangGraph emits tool requests as ``Full`` events (from "updates"), NOT + Start+Delta+Done like pydantic-ai. ``auto_send`` handles Full events + correctly; no coalescing wrapper is needed. + + ``created_at`` is set from ``workflow.now()`` when called inside a + Temporal workflow, matching the pattern used by the openai/litellm providers. + Outside a workflow (plain async activities, sync agents) it is ``None`` and the + server's wall clock is used. + + Args: + stream: Async iterator from graph.astream(..., stream_mode=["messages", "updates"]) + task_id: The Agentex task ID to stream messages to. + + Returns: + The accumulated final text output from the agent. + """ + from agentex.lib.core.harness.emitter import UnifiedEmitter + + # Stamp messages with workflow.now() inside Temporal for deterministic + # created_at ordering; falls back to None (server wall clock) outside a workflow. + turn = LangGraphTurn(stream, model=None) + emitter = UnifiedEmitter(task_id=task_id, trace_id=None, parent_span_id=None) + result = await emitter.auto_send_turn(turn, created_at=workflow_now_if_in_workflow()) + return result.final_text diff --git a/src/agentex/lib/adk/_modules/_openai_sync.py b/src/agentex/lib/adk/_modules/_openai_sync.py new file mode 100644 index 000000000..b857a5be6 --- /dev/null +++ b/src/agentex/lib/adk/_modules/_openai_sync.py @@ -0,0 +1,395 @@ +"""Sync OpenAI Agents SDK streaming tap for Agentex. + +Converts an OpenAI Agents SDK streamed run (``Runner.run_streamed(...)`` +``stream_events()``) into Agentex ``StreamTaskMessage*`` events, including +reasoning content and reasoning summary deltas for reasoning models (o1/o3/gpt-5). + +This is the lower-level primitive used by ``OpenAITurn`` (in +``_openai_turn.py``). New OpenAI Agents integrations should prefer wrapping a +``Runner.run_streamed`` result in ``OpenAITurn`` and driving delivery + tracing +through ``UnifiedEmitter``. +""" + +from __future__ import annotations + +import json +from typing import Any + +from openai.types.responses import ( + ResponseTextDeltaEvent, + ResponseFunctionToolCall, + ResponseFunctionWebSearch, + ResponseOutputItemDoneEvent, + ResponseOutputItemAddedEvent, + ResponseCodeInterpreterToolCall, + ResponseReasoningSummaryPartAddedEvent, + ResponseReasoningSummaryTextDeltaEvent, +) +from openai.types.responses.response_reasoning_text_done_event import ResponseReasoningTextDoneEvent +from openai.types.responses.response_reasoning_text_delta_event import ResponseReasoningTextDeltaEvent +from openai.types.responses.response_reasoning_summary_text_done_event import ResponseReasoningSummaryTextDoneEvent + +from agentex.types.reasoning_content import ReasoningContent +from agentex.types.task_message_delta import TextDelta +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.types.task_message_content import TextContent +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.types.reasoning_content_delta import ReasoningContentDelta +from agentex.types.reasoning_summary_delta import ReasoningSummaryDelta + + +def _safe_parse_arguments(arguments: Any) -> dict[str, Any]: + """Coerce a tool call's ``arguments`` into a dict, tolerating bad JSON. + + ``ToolRequestContent.arguments`` is typed ``Dict[str, object]``, so the + result is ALWAYS a dict — a non-dict payload must not abort the turn. + Mirroring the Temporal streaming model: malformed/truncated strings are + preserved under ``raw``, and any other non-dict value (a list, scalar, or + SDK object) is serialized if possible, otherwise wrapped under ``value``. + """ + if not arguments: + return {} + if isinstance(arguments, dict): + return arguments + if isinstance(arguments, str): + try: + parsed = json.loads(arguments) + except (json.JSONDecodeError, ValueError): + return {"raw": arguments} + return parsed if isinstance(parsed, dict) else {"value": parsed} + # Non-string, non-dict (e.g. a provider tool passing a list / scalar / SDK + # object). Prefer the object's own dict form; fall back to wrapping it. + dumped = arguments.model_dump() if hasattr(arguments, "model_dump") else None + if isinstance(dumped, dict): + return dumped + return {"value": arguments} + + +def _extract_tool_call_info(tool_call_item: Any) -> tuple[str, str, dict[str, Any]]: + """ + Extract call_id, tool_name, and tool_arguments from a tool call item. + Args: + tool_call_item: The tool call item to process + Returns: + A tuple of (call_id, tool_name, tool_arguments) + """ + # Generic handling for different tool call types + # Try 'call_id' first, then 'id', then generate placeholder + if hasattr(tool_call_item, "call_id"): + call_id = tool_call_item.call_id + elif hasattr(tool_call_item, "id"): + call_id = tool_call_item.id + else: + call_id = f"unknown_call_{id(tool_call_item)}" + + if isinstance(tool_call_item, ResponseFunctionWebSearch): + tool_name = "web_search" + tool_arguments = {"action": tool_call_item.action.model_dump(), "status": tool_call_item.status} + elif isinstance(tool_call_item, ResponseCodeInterpreterToolCall): + tool_name = "code_interpreter" + tool_arguments = {"code": tool_call_item.code, "status": tool_call_item.status} + elif isinstance(tool_call_item, ResponseFunctionToolCall): + # Handle standard function tool calls + tool_name = tool_call_item.name + tool_arguments = _safe_parse_arguments(tool_call_item.arguments) + else: + # Generic handling for any tool call type + tool_name = getattr(tool_call_item, "name", type(tool_call_item).__name__) + if hasattr(tool_call_item, "arguments"): + tool_arguments = _safe_parse_arguments(tool_call_item.arguments) + else: + tool_arguments = tool_call_item.model_dump() + + return call_id, tool_name, tool_arguments + + +def _extract_tool_response_info(tool_map: dict[str, Any], tool_output_item: Any) -> tuple[str, str, str]: + """ + Extract call_id, tool_name, and content from a tool output item. + Args: + tool_map: Dictionary mapping call_ids to tool names + tool_output_item: The tool output item to process + Returns: + A tuple of (call_id, tool_name, content) + """ + + # Handle different formats of tool_output_item + if isinstance(tool_output_item, dict): + call_id = tool_output_item.get("call_id", tool_output_item.get("id", f"unknown_call_{id(tool_output_item)}")) + content = tool_output_item.get("output", str(tool_output_item)) + else: + # Try to get call_id from attributes + if hasattr(tool_output_item, "call_id"): + call_id = tool_output_item.call_id + elif hasattr(tool_output_item, "id"): + call_id = tool_output_item.id + else: + call_id = f"unknown_call_{id(tool_output_item)}" + + # Get content + if hasattr(tool_output_item, "output"): + content = tool_output_item.output + else: + content = str(tool_output_item) + + # Get tool name from map + tool_name = tool_map.get(call_id, "unknown_tool") + + return call_id, tool_name, content + + +async def convert_openai_to_agentex_events(stream_response): + """Public OpenAI tap: parse the event stream, closing the source on exit. + + Thin wrapper over ``_convert_openai_impl`` that adds a cancellation-safe + ``finally`` so an interrupted turn tears down the source event stream instead + of leaking it. (Resume state is carried by the OpenAI Agents SDK input list, + not a session id, so there is no early-session_id capture like the CLI taps.) + """ + inner = _convert_openai_impl(stream_response) + try: + async for event in inner: + yield event + finally: + for _src in (inner, stream_response): + _aclose = getattr(_src, "aclose", None) + if _aclose is not None: + await _aclose() + + +async def _convert_openai_impl(stream_response): + """Convert OpenAI streaming events to AgentEx TaskMessageUpdate events with reasoning support. + + This is an enhanced version of the base converter that includes support for: + - Reasoning content deltas (for o1 models) + - Reasoning summary deltas (for o1 models) + + Args: + stream_response: An async iterator of OpenAI streaming events + Yields: + TaskMessageUpdate: AgentEx streaming events (StreamTaskMessageDelta, StreamTaskMessageFull, or StreamTaskMessageDone) + """ + + tool_map = {} + event_count = 0 + message_index = 0 # Track message index for proper sequencing + item_id_to_index = {} # Map item_id to message index + item_id_to_type = {} # Map item_id to content type (text, reasoning_content, reasoning_summary) + + async for event in stream_response: + event_count += 1 + + # Check for raw response events which contain the actual OpenAI streaming events + if hasattr(event, "type") and event.type == "raw_response_event": + if hasattr(event, "data"): + raw_event = event.data + + # Check for ResponseOutputItemAddedEvent which signals a new message starting + if isinstance(raw_event, ResponseOutputItemAddedEvent): + # Don't increment here - we'll increment when we see the actual text delta + # This is just a signal that a new message is starting + pass + + # Handle item completion - send done event to close the message + elif isinstance(raw_event, ResponseOutputItemDoneEvent): + item_id = raw_event.item.id + if item_id in item_id_to_index: + # Close every streamed message — text AND reasoning — with a + # matching Done. UnifiedEmitter.auto_send only releases a + # context on StreamTaskMessageDone; skipping it for reasoning + # left those messages hanging and their spans incomplete. The + # accumulator rebuilds ReasoningContent from the deltas, so the + # Done carries no payload. + yield StreamTaskMessageDone( + type="done", + index=item_id_to_index[item_id], + ) + + # Skip reasoning summary part added events - we handle them on delta + elif isinstance(raw_event, ResponseReasoningSummaryPartAddedEvent): + pass + + # Handle reasoning summary text delta events + elif isinstance(raw_event, ResponseReasoningSummaryTextDeltaEvent): + item_id = raw_event.item_id + summary_index = raw_event.summary_index + + # If this is a new item_id we haven't seen, create a new message + if item_id and item_id not in item_id_to_index: + message_index += 1 + item_id_to_index[item_id] = message_index + item_id_to_type[item_id] = "reasoning_summary" + + # Send a start event for this new reasoning summary message. + # The start content must be ReasoningContent (not TextContent) + # so consumers that branch on the start event's content type + # render a reasoning/thinking indicator; the final persisted + # content is rebuilt from the reasoning deltas regardless. + yield StreamTaskMessageStart( + type="start", + index=item_id_to_index[item_id], + content=ReasoningContent( + type="reasoning", + author="agent", + summary=[], + content=[], + style="active", + ), + ) + + # Use the index for this item_id + current_index = item_id_to_index.get(item_id, message_index) + + # Yield reasoning summary delta + yield StreamTaskMessageDelta( + type="delta", + index=current_index, + delta=ReasoningSummaryDelta( + type="reasoning_summary", + summary_index=summary_index, + summary_delta=raw_event.delta, + ), + ) + + # Handle reasoning summary text done events + elif isinstance(raw_event, ResponseReasoningSummaryTextDoneEvent): + # We do NOT close the streaming context here + # as there can be multiple reasoning summaries. + # The context will be closed when the entire + # output item is done (ResponseOutputItemDoneEvent) + pass + + # Handle reasoning content text delta events + elif isinstance(raw_event, ResponseReasoningTextDeltaEvent): + item_id = raw_event.item_id + content_index = raw_event.content_index + + # If this is a new item_id we haven't seen, create a new message + if item_id and item_id not in item_id_to_index: + message_index += 1 + item_id_to_index[item_id] = message_index + item_id_to_type[item_id] = "reasoning_content" + + # Send a start event for this new reasoning content message. + # The start content must be ReasoningContent (not TextContent) + # so consumers that branch on the start event's content type + # render a reasoning/thinking indicator; the final persisted + # content is rebuilt from the reasoning deltas regardless. + yield StreamTaskMessageStart( + type="start", + index=item_id_to_index[item_id], + content=ReasoningContent( + type="reasoning", + author="agent", + summary=[], + content=[], + style="active", + ), + ) + + # Use the index for this item_id + current_index = item_id_to_index.get(item_id, message_index) + + # Yield reasoning content delta + yield StreamTaskMessageDelta( + type="delta", + index=current_index, + delta=ReasoningContentDelta( + type="reasoning_content", + content_index=content_index, + content_delta=raw_event.delta, + ), + ) + + # Handle reasoning content text done events + elif isinstance(raw_event, ResponseReasoningTextDoneEvent): + # We do NOT close the streaming context here + # as there can be multiple reasoning content texts. + # The context will be closed when the entire + # output item is done (ResponseOutputItemDoneEvent) + pass + + # Check if this is a text delta event from OpenAI + elif isinstance(raw_event, ResponseTextDeltaEvent): + # Check if this event has an item_id + item_id = getattr(raw_event, "item_id", None) + + # If this is a new item_id we haven't seen, it's a new message. + # Reserve a fresh index for every text item_id (matching the + # increment-then-use convention of the reasoning/tool paths). + # Reusing the current index let a final answer collide with the + # preceding reasoning message on reasoning-model streams. + if item_id and item_id not in item_id_to_index: + message_index += 1 + item_id_to_index[item_id] = message_index + item_id_to_type[item_id] = "text" + + # Send a start event with empty content for this new text message + yield StreamTaskMessageStart( + type="start", + index=item_id_to_index[item_id], + content=TextContent( + type="text", + author="agent", + content="", # Start with empty content, deltas will fill it + ), + ) + + # Use the index for this item_id + current_index = item_id_to_index.get(item_id, message_index) + + delta_message = StreamTaskMessageDelta( + type="delta", + index=current_index, + delta=TextDelta( + type="text", + text_delta=raw_event.delta, + ), + ) + yield delta_message + + elif hasattr(event, "type") and event.type == "run_item_stream_event": + # Skip reasoning_item events - they're handled via raw_response_event above + if hasattr(event, "item") and event.item.type == "reasoning_item": + continue + + # Check for tool_call_item type (this is when a tool is being called) + elif hasattr(event, "item") and event.item.type == "tool_call_item": + # Extract tool call information using the helper method + call_id, tool_name, tool_arguments = _extract_tool_call_info(event.item.raw_item) + tool_map[call_id] = tool_name + tool_request_content = ToolRequestContent( + tool_call_id=call_id, + name=tool_name, + arguments=tool_arguments, + author="agent", + ) + message_index += 1 # Increment for new message + yield StreamTaskMessageFull( + index=message_index, + type="full", + content=tool_request_content, + ) + + # Check for tool_call_output_item type (this is when a tool returns output) + elif hasattr(event, "item") and event.item.type == "tool_call_output_item": + # Extract tool response information using the helper method + call_id, tool_name, content = _extract_tool_response_info(tool_map, event.item.raw_item) + tool_response_content = ToolResponseContent( + tool_call_id=call_id, + name=tool_name, + content=content, + author="agent", + ) + message_index += 1 # Increment for new message + yield StreamTaskMessageFull( + type="full", + index=message_index, + content=tool_response_content, + ) diff --git a/src/agentex/lib/adk/_modules/_openai_turn.py b/src/agentex/lib/adk/_modules/_openai_turn.py new file mode 100644 index 000000000..cfb1ce22d --- /dev/null +++ b/src/agentex/lib/adk/_modules/_openai_turn.py @@ -0,0 +1,134 @@ +"""OpenAITurn: adapt an OpenAI Agents SDK streamed run onto the harness surface. + +A ``HarnessTurn`` exposes a single canonical ``StreamTaskMessage*`` stream plus +normalized usage. ``OpenAITurn`` wraps a ``RunResultStreaming`` (from +``Runner.run_streamed``), converts its native OpenAI events into the canonical +stream via ``convert_openai_to_agentex_events``, and after exhaustion reads the +run's ``raw_responses`` to aggregate usage into a provider-independent +``TurnUsage``. + +Delivery (yield vs auto-send) and tracing are owned by ``UnifiedEmitter``; this +module is purely the provider->canonical adapter. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, AsyncIterator + +from agents.usage import Usage + +from agentex.lib.utils.logging import make_logger +from agentex.lib.core.harness.types import TurnUsage, StreamTaskMessage +from agentex.lib.adk._modules._openai_sync import ( + convert_openai_to_agentex_events, +) + +if TYPE_CHECKING: + from agents import ModelResponse, RunResultStreaming + +logger = make_logger(__name__) + + +def openai_usage_to_turn_usage(usage: Usage | None, model: str | None) -> TurnUsage: + """Map an ``agents.Usage`` to a harness-independent ``TurnUsage``. + + All field access is defensive (``getattr(..., None)``): different model + backends populate different subsets of the usage object, and real zeros are + valid values (e.g. 0 output tokens on a pure cache hit), so we never coerce + a present-but-zero value into ``None``. + """ + if usage is None: + return TurnUsage(model=model) + + input_details = getattr(usage, "input_tokens_details", None) + output_details = getattr(usage, "output_tokens_details", None) + + return TurnUsage( + model=model, + num_llm_calls=getattr(usage, "requests", None) or 0, + input_tokens=getattr(usage, "input_tokens", None), + cached_input_tokens=getattr(input_details, "cached_tokens", None), + output_tokens=getattr(usage, "output_tokens", None), + reasoning_tokens=getattr(output_details, "reasoning_tokens", None), + total_tokens=getattr(usage, "total_tokens", None), + ) + + +def _aggregate_usage(raw_responses: list[ModelResponse]) -> Usage | None: + """Sum the per-response ``Usage`` across a run's ``ModelResponse`` list. + + Returns ``None`` when no response carries usage so the caller can emit a + usage object with only the model name set. ``Usage.add`` accumulates + requests/tokens (including cached/reasoning detail fields). + """ + total: Usage | None = None + for response in raw_responses: + resp_usage = getattr(response, "usage", None) + if resp_usage is None: + continue + if total is None: + total = Usage() + total.add(resp_usage) + return total + + +class OpenAITurn: + """A single OpenAI Agents SDK turn adapted to the ``HarnessTurn`` protocol. + + Construct with exactly one of: + - ``result``: a ``RunResultStreaming`` from ``Runner.run_streamed``. Its + ``stream_events()`` is converted to the canonical stream, and after the + stream is exhausted ``raw_responses`` is read to compute usage. + - ``stream``: a pre-built async iterator of canonical ``StreamTaskMessage`` + events (bypasses ``convert_openai_to_agentex_events``). Useful for tests + and for callers that have already produced canonical events. Usage stays + at ``TurnUsage(model=...)`` because there is no run to read usage from. + + ``coalesce_tool_requests`` is accepted for API parity with other provider + turns but is a no-op for OpenAI: the OpenAI converter already emits a single + ``Full(ToolRequestContent)`` per tool call rather than streamed argument + deltas, so there is nothing to coalesce. + """ + + def __init__( + self, + result: RunResultStreaming | None = None, + model: str | None = None, + stream: AsyncIterator[StreamTaskMessage] | None = None, + coalesce_tool_requests: bool = False, # noqa: ARG002 - API parity, no-op for OpenAI + ) -> None: + if result is None and stream is None: + raise ValueError("OpenAITurn requires either `result` or `stream`") + self._result = result + self._model = model + self._stream = stream + self._usage: TurnUsage = TurnUsage(model=model) + + @property + def events(self) -> AsyncIterator[StreamTaskMessage]: + return self._iter_events() + + async def _iter_events(self) -> AsyncIterator[StreamTaskMessage]: + if self._stream is not None: + async for event in self._stream: + yield event + return + + result = self._result + assert result is not None # guaranteed by __init__ + async for event in convert_openai_to_agentex_events(result.stream_events()): + yield event + + # Stream is exhausted: the run has finished and raw_responses is now + # populated, so usage can be aggregated and normalized. + try: + raw_responses: list[Any] = list(getattr(result, "raw_responses", None) or []) + aggregated = _aggregate_usage(raw_responses) + self._usage = openai_usage_to_turn_usage(aggregated, self._model) + except Exception as exc: # pragma: no cover - defensive: never break delivery on usage + logger.warning(f"Failed to aggregate OpenAI usage: {exc}") + self._usage = TurnUsage(model=self._model) + + def usage(self) -> TurnUsage: + """Normalized turn usage. Valid only after ``events`` is exhausted.""" + return self._usage diff --git a/src/agentex/lib/adk/_modules/_pydantic_ai_sync.py b/src/agentex/lib/adk/_modules/_pydantic_ai_sync.py new file mode 100644 index 000000000..75bfb64db --- /dev/null +++ b/src/agentex/lib/adk/_modules/_pydantic_ai_sync.py @@ -0,0 +1,350 @@ +"""Pydantic AI streaming integration for Agentex. + +Converts a Pydantic AI ``AgentStreamEvent`` stream (as yielded by +``agent.run_stream_events(...)`` or via an ``event_stream_handler``) into the +Agentex ``StreamTaskMessage*`` events that the Agentex server understands. + +Typical sync usage: + + from pydantic_ai import Agent + from agentex.lib.adk import convert_pydantic_ai_to_agentex_events + + agent = Agent("openai:gpt-4o", system_prompt="...") + + @acp.on_message_send + async def handle_message_send(params): + async with agent.run_stream_events(params.content.content) as stream: + async for event in convert_pydantic_ai_to_agentex_events(stream): + yield event + +Recommended: unified surface +----------------------------- +For new handlers, prefer ``UnifiedEmitter`` + ``PydanticAITurn`` over the +bare converter. The unified surface wires tracing automatically when a +``trace_id`` is provided, so tool and reasoning spans are derived from the +same event stream with no extra setup: + + from agentex.lib.core.harness import UnifiedEmitter + from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn + + emitter = UnifiedEmitter(task_id=task_id, trace_id=trace_id, parent_span_id=parent_span_id) + turn = PydanticAITurn(agent.run_stream_events(prompt), model="openai:gpt-4o") + async for event in emitter.yield_turn(turn): + yield event # forwarded over the ACP streaming response; spans derived automatically + +``convert_pydantic_ai_to_agentex_events`` remains the low-level tap for +callers that manage their own tracing or need direct access to the raw +converted stream. +""" + +from __future__ import annotations + +import json +import inspect +from typing import Any, Callable, AsyncIterator + +from pydantic_ai.run import AgentRunResultEvent +from pydantic_ai.messages import ( + TextPart, + PartEndEvent, + ThinkingPart, + ToolCallPart, + TextPartDelta, + PartDeltaEvent, + PartStartEvent, + ToolReturnPart, + FinalResultEvent, + ThinkingPartDelta, + ToolCallPartDelta, + FunctionToolCallEvent, + FunctionToolResultEvent, +) + +from agentex.lib.utils.logging import make_logger +from agentex.types.reasoning_content import ReasoningContent +from agentex.types.task_message_delta import TextDelta +from agentex.types.tool_request_delta import ToolRequestDelta +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.types.task_message_content import TextContent +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.types.reasoning_content_delta import ReasoningContentDelta + +logger = make_logger(__name__) + + +def _args_delta_to_str(args_delta: str | dict[str, Any] | None) -> str: + """Normalize a Pydantic AI ``ToolCallPartDelta.args_delta`` to a string fragment. + + Pydantic AI emits string fragments for providers that stream JSON tokens + (OpenAI, Anthropic) and dicts for providers that emit one-shot tool calls. + Agentex's ``ToolRequestDelta.arguments_delta`` is concatenated server-side + and parsed as a single JSON object on completion, so we always produce a + string. For dict deltas this is a one-shot dump; subsequent dict deltas + will not compose correctly, but in practice dict deltas arrive as a single + final fragment. + """ + if args_delta is None: + return "" + if isinstance(args_delta, str): + return args_delta + return json.dumps(args_delta) + + +def _tool_return_content(result: ToolReturnPart | Any) -> Any: + """Best-effort extraction of the user-visible content from a tool result. + + ``FunctionToolResultEvent.part`` is ``ToolReturnPart | RetryPromptPart``. + For ``ToolReturnPart`` we surface ``.content`` directly; for ``RetryPromptPart`` + (a retry signal back to the model) we surface a string description so the + UI sees the failure reason. + """ + content = getattr(result, "content", None) + if content is None: + return str(result) + if isinstance(content, (str, int, float, bool, list, dict)): + return content + if hasattr(content, "model_dump"): + try: + return content.model_dump() + except Exception: + return str(content) + return str(content) + + +async def convert_pydantic_ai_to_agentex_events( + stream_response: AsyncIterator[Any], + on_result: Callable[[AgentRunResultEvent], Any] | None = None, +) -> AsyncIterator[StreamTaskMessageStart | StreamTaskMessageDelta | StreamTaskMessageFull | StreamTaskMessageDone]: + """Public Pydantic AI tap: convert events, closing the source stream on exit. + + Thin wrapper over ``_convert_pydantic_ai_impl`` that adds a cancellation-safe + ``finally`` so an interrupted turn tears down the source stream instead of + leaking it. + """ + inner = _convert_pydantic_ai_impl(stream_response, on_result=on_result) + try: + async for event in inner: + yield event + finally: + for _src in (inner, stream_response): + _aclose = getattr(_src, "aclose", None) + if _aclose is not None: + await _aclose() + + +async def _convert_pydantic_ai_impl( + stream_response: AsyncIterator[Any], + on_result: Callable[[AgentRunResultEvent], Any] | None = None, +) -> AsyncIterator[StreamTaskMessageStart | StreamTaskMessageDelta | StreamTaskMessageFull | StreamTaskMessageDone]: + """Convert a Pydantic AI agent event stream into Agentex stream events. + + Mapping: + PartStartEvent(TextPart) -> StreamTaskMessageStart(TextContent) + PartStartEvent(ThinkingPart) -> StreamTaskMessageStart(ReasoningContent) + PartStartEvent(ToolCallPart) -> StreamTaskMessageStart(ToolRequestContent) + PartDeltaEvent(TextPartDelta) -> StreamTaskMessageDelta(TextDelta) + PartDeltaEvent(ThinkingPart..) -> StreamTaskMessageDelta(ReasoningContentDelta) + PartDeltaEvent(ToolCallPart..) -> StreamTaskMessageDelta(ToolRequestDelta) + PartEndEvent -> StreamTaskMessageDone + FunctionToolResultEvent -> StreamTaskMessageFull(ToolResponseContent) + FunctionToolCallEvent -> (ignored — already covered by Start/Delta/End) + FinalResultEvent -> (ignored — informational; the run-level + AgentRunResultEvent terminates the stream) + AgentRunResultEvent -> (ignored — Agentex closes the per-message + stream via PartEndEvent already) + + Args: + stream_response: The async iterator yielded by Pydantic AI's + ``agent.run_stream_events(...)`` context manager (or a stream of + ``AgentStreamEvent`` items received in an ``event_stream_handler``). + on_result: Optional callback invoked with the terminal + ``AgentRunResultEvent`` when the run completes. Both sync and + async callables are accepted. No ``StreamTaskMessage*`` events are + yielded for this terminal event; the callback is the only side + effect. Useful for capturing run-level usage without altering the + streaming output. + + Yields: + Agentex ``StreamTaskMessage*`` events suitable for forwarding back over + the ACP streaming response. + """ + next_message_index = 0 + # Maps Pydantic AI's per-response part index to our absolute message index. + # Part indices restart at 0 on each new model response in a multi-step run, + # so we always overwrite the entry on PartStartEvent. + part_to_message_index: dict[int, int] = {} + # Tool-call metadata indexed by Pydantic AI part index (so deltas can + # surface the tool_call_id even when ToolCallPartDelta.tool_call_id is None). + tool_call_meta: dict[int, tuple[str, str]] = {} + + async for event in stream_response: + if isinstance(event, PartStartEvent): + message_index = next_message_index + next_message_index += 1 + part_to_message_index[event.index] = message_index + + if isinstance(event.part, TextPart): + yield StreamTaskMessageStart( + type="start", + index=message_index, + content=TextContent( + type="text", + author="agent", + content="", + ), + ) + if event.part.content: + yield StreamTaskMessageDelta( + type="delta", + index=message_index, + delta=TextDelta(type="text", text_delta=event.part.content), + ) + elif isinstance(event.part, ThinkingPart): + yield StreamTaskMessageStart( + type="start", + index=message_index, + content=ReasoningContent( + type="reasoning", + author="agent", + summary=[], + content=[], + style="active", + ), + ) + if event.part.content: + yield StreamTaskMessageDelta( + type="delta", + index=message_index, + delta=ReasoningContentDelta( + type="reasoning_content", + content_index=0, + content_delta=event.part.content, + ), + ) + elif isinstance(event.part, ToolCallPart): + tool_call_meta[event.index] = (event.part.tool_call_id, event.part.tool_name) + # Pydantic AI may already have a fully-formed args dict at start + # when the provider returns the tool call in one shot; surface it + # directly so clients see the complete arguments without waiting + # for deltas. + initial_args: dict[str, Any] = {} + if isinstance(event.part.args, dict): + # dict(...) materializes a fresh dict[str, Any]; pydantic-ai's + # ToolCallPart.args includes TypedDict-style variants that + # pyright doesn't narrow to plain dict[str, Any] via isinstance. + initial_args = dict(event.part.args) + yield StreamTaskMessageStart( + type="start", + index=message_index, + content=ToolRequestContent( + type="tool_request", + author="agent", + tool_call_id=event.part.tool_call_id, + name=event.part.tool_name, + arguments=initial_args, + ), + ) + if isinstance(event.part.args, str) and event.part.args: + yield StreamTaskMessageDelta( + type="delta", + index=message_index, + delta=ToolRequestDelta( + type="tool_request", + tool_call_id=event.part.tool_call_id, + name=event.part.tool_name, + arguments_delta=event.part.args, + ), + ) + else: + logger.debug("Unhandled PartStartEvent part type: %r", type(event.part).__name__) + + elif isinstance(event, PartDeltaEvent): + message_index = part_to_message_index.get(event.index) + if message_index is None: + logger.debug("PartDeltaEvent for unknown part index %s; skipping", event.index) + continue + + if isinstance(event.delta, TextPartDelta): + yield StreamTaskMessageDelta( + type="delta", + index=message_index, + delta=TextDelta(type="text", text_delta=event.delta.content_delta), + ) + elif isinstance(event.delta, ThinkingPartDelta): + if event.delta.content_delta: + yield StreamTaskMessageDelta( + type="delta", + index=message_index, + delta=ReasoningContentDelta( + type="reasoning_content", + content_index=0, + content_delta=event.delta.content_delta, + ), + ) + elif isinstance(event.delta, ToolCallPartDelta): + meta = tool_call_meta.get(event.index) + if meta is None: + # First time we've seen this part; the provider didn't emit + # a PartStartEvent first. Synthesize one from the delta if + # we have enough information. + tool_call_id = event.delta.tool_call_id or "" + tool_name = event.delta.tool_name_delta or "" + tool_call_meta[event.index] = (tool_call_id, tool_name) + else: + tool_call_id, tool_name = meta + yield StreamTaskMessageDelta( + type="delta", + index=message_index, + delta=ToolRequestDelta( + type="tool_request", + tool_call_id=tool_call_id, + name=tool_name, + arguments_delta=_args_delta_to_str(event.delta.args_delta), + ), + ) + else: + logger.debug("Unhandled PartDeltaEvent delta type: %r", type(event.delta).__name__) + + elif isinstance(event, PartEndEvent): + message_index = part_to_message_index.get(event.index) + if message_index is None: + continue + yield StreamTaskMessageDone(type="done", index=message_index) + + elif isinstance(event, FunctionToolResultEvent): + result = event.part + tool_call_id = result.tool_call_id + tool_name = getattr(result, "tool_name", "") or "" + message_index = next_message_index + next_message_index += 1 + content_payload = _tool_return_content(result) + yield StreamTaskMessageFull( + type="full", + index=message_index, + content=ToolResponseContent( + type="tool_response", + author="agent", + tool_call_id=tool_call_id, + name=tool_name, + content=content_payload, + ), + ) + + elif isinstance(event, (FunctionToolCallEvent, FinalResultEvent, AgentRunResultEvent)): + # Already covered by PartStart/PartDelta/PartEnd events above, or + # informational only (FinalResultEvent / AgentRunResultEvent signal + # run-level state, not new content to surface). + if isinstance(event, AgentRunResultEvent) and on_result is not None: + ret = on_result(event) + if inspect.iscoroutine(ret): + await ret + continue + + else: + logger.debug("Unhandled Pydantic AI event type: %r", type(event).__name__) diff --git a/src/agentex/lib/adk/_modules/_pydantic_ai_turn.py b/src/agentex/lib/adk/_modules/_pydantic_ai_turn.py new file mode 100644 index 000000000..4e9340d7a --- /dev/null +++ b/src/agentex/lib/adk/_modules/_pydantic_ai_turn.py @@ -0,0 +1,173 @@ +"""PydanticAITurn: a HarnessTurn wrapping a pydantic-ai event stream. + +Adapts a pydantic-ai ``AgentStreamEvent`` stream into the canonical +``StreamTaskMessage*`` stream while capturing run-level usage from the +terminal ``AgentRunResultEvent``. + +Typical usage:: + + async with agent.run_stream_events(user_msg) as stream: + turn = PydanticAITurn(stream, model="openai:gpt-4o") + async for event in turn.events: + yield event + span.set_attributes(turn.usage().model_dump()) +""" + +from __future__ import annotations + +from typing import Any, AsyncIterator + +from pydantic_ai.run import AgentRunResultEvent + +from agentex.lib.core.harness.types import TurnUsage +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.lib.adk._modules._pydantic_ai_sync import convert_pydantic_ai_to_agentex_events + +StreamTaskMessage = StreamTaskMessageStart | StreamTaskMessageDelta | StreamTaskMessageFull | StreamTaskMessageDone + + +def pydantic_ai_usage_to_turn_usage(usage: Any, model: str | None) -> TurnUsage: + """Map a pydantic-ai ``RunUsage`` onto ``TurnUsage``. + + Uses defensive ``getattr(..., None)`` so a future field rename in + pydantic-ai degrades to ``None`` rather than raising ``AttributeError``. + + RunUsage fields (verified against pydantic-ai in this repo): + input_tokens, cache_write_tokens, cache_read_tokens, output_tokens, + input_audio_tokens, cache_audio_read_tokens, output_audio_tokens, + details, requests, tool_calls. + ``total_tokens`` is a computed property. + + Mapping: + requests -> num_llm_calls + input_tokens -> input_tokens + output_tokens -> output_tokens + cache_read_tokens -> cached_input_tokens + total_tokens -> total_tokens + + getattr results pass straight through: a MISSING attribute degrades to + None (defensive), while a real 0 stays 0 (a cache-hit with 0 output + tokens is a genuine zero, not "unknown") and a real N stays N. + """ + raw_input = getattr(usage, "input_tokens", None) + raw_output = getattr(usage, "output_tokens", None) + raw_cache_read = getattr(usage, "cache_read_tokens", None) + raw_total = getattr(usage, "total_tokens", None) + raw_requests = getattr(usage, "requests", None) + + return TurnUsage( + model=model, + input_tokens=raw_input, + output_tokens=raw_output, + cached_input_tokens=raw_cache_read, + total_tokens=raw_total, + num_llm_calls=raw_requests if raw_requests is not None else 0, + ) + + +class PydanticAITurn: + """A single harness turn backed by a pydantic-ai event stream. + + Satisfies the ``HarnessTurn`` protocol: ``events`` async-generates the + canonical ``StreamTaskMessage*`` stream; ``usage()`` returns a normalized + ``TurnUsage`` (valid only after ``events`` is exhausted). + + ``events`` is identical to the bare ``convert_pydantic_ai_to_agentex_events`` + output (tool calls stream as ``Start + ToolRequestDelta + Done``, preserving + argument-token streaming on the sync/yield channel). The foundation + ``auto_send`` delivers the streamed tool-request shape natively, so no + coalescing is needed on either channel. + """ + + def __init__( + self, + stream: AsyncIterator[Any], + model: str | None = None, + ) -> None: + self._stream = stream + self._model = model + self._usage = TurnUsage(model=model) + + @property + def events(self) -> AsyncIterator[StreamTaskMessage]: + return self._generate_events() + + async def _generate_events(self) -> AsyncIterator[StreamTaskMessage]: + def _capture(result_event: AgentRunResultEvent) -> None: + run_result = getattr(result_event, "result", None) + if run_result is None: + return + usage_attr = getattr(run_result, "usage", None) + if usage_attr is None: + return + # In newer pydantic-ai, .usage is a DeprecatedCallableRunUsage — + # it's both a property value and callable (emitting a deprecation + # warning when called). Access it as a plain attribute to avoid the + # warning; it already IS the RunUsage instance. + usage_obj = usage_attr + self._usage = pydantic_ai_usage_to_turn_usage(usage_obj, self._model) + + raw_stream = convert_pydantic_ai_to_agentex_events( + self._stream, + on_result=_capture, + ) + async for ev in raw_stream: + yield ev + + def usage(self) -> TurnUsage: + """Return the normalized usage for this turn. + + Valid only after ``events`` is exhausted (single-pass contract). + Before exhaustion the model field is set but token fields are None. + """ + return self._usage + + +async def stream_pydantic_ai_events( + stream, + task_id: str, +) -> str: + """Stream Pydantic AI events to Agentex via Redis. + + Consumes a Pydantic AI ``agent.run_stream_events(...)`` async iterator and + pushes Agentex streaming updates to Redis via the ``adk.streaming`` + contexts. For use with async ACP agents that stream via Redis rather than + HTTP yields. + + Text and thinking tokens stream as deltas inside coalesced streaming + contexts. Tool requests and tool results are posted as open+close pairs + on a streaming context (the unified surface persists ``initial_content`` + when a context is closed without deltas). This matches the ``auto_send`` + convention used by all other async/Temporal harnesses. + + Tracing is derived automatically from the event stream by the emitter when + a ``trace_id`` is provided to the ``UnifiedEmitter``. + + Args: + stream: Async iterator yielded by ``agent.run_stream_events(...)``. + task_id: The Agentex task ID to stream messages to. + + Returns: + The accumulated text content of the **last** text part in the run. + Multi-step runs (where the model emits text, then a tool call, then + more text) return only the final text segment, matching the + ``stream_langgraph_events`` convention. + """ + from agentex.lib.core.harness.emitter import UnifiedEmitter + + turn = PydanticAITurn( + stream, + model=None, + ) + emitter = UnifiedEmitter( + task_id=task_id, + trace_id=None, + parent_span_id=None, + ) + result = await emitter.auto_send_turn(turn) + return result.final_text diff --git a/src/agentex/lib/adk/_modules/acp.py b/src/agentex/lib/adk/_modules/acp.py new file mode 100644 index 000000000..8a8e89236 --- /dev/null +++ b/src/agentex/lib/adk/_modules/acp.py @@ -0,0 +1,294 @@ +# ruff: noqa: I001 +# Import order matters - AsyncTracer must come after client import to avoid circular imports +from __future__ import annotations +from datetime import timedelta +from typing import Any, List + +from agentex.types import Event +from temporalio.common import RetryPolicy + +from agentex import AsyncAgentex # noqa: F401 +from agentex.lib.adk.utils._modules.client import create_async_agentex_client +from agentex.lib.core.services.adk.acp.acp import ACPService +from agentex.lib.core.temporal.activities.activity_helpers import ActivityHelpers +from agentex.lib.core.temporal.activities.adk.acp.acp_activities import ( + ACPActivityName, + EventSendParams, + MessageSendParams, + TaskCancelParams, + TaskCreateParams, +) +from agentex.lib.core.tracing.tracer import AsyncTracer +from agentex.types.task_message import TaskMessage +from agentex.types.task import Task +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.temporal import in_temporal_workflow +from agentex.types.task_message_content import TaskMessageContent + +logger = make_logger(__name__) + +DEFAULT_RETRY_POLICY = RetryPolicy(maximum_attempts=0) + + +class ACPModule: + """ + Module for managing Agent to Client Protocol (ACP) agent operations in Agentex. + + This interface provides high-level methods for interacting with the agent through the ACP. + """ + + def __init__(self, acp_service: ACPService | None = None): + """ + Initialize the ACP module. + + Args: + acp_activities (Optional[ACPActivities]): Optional pre-configured ACP activities. If None, will be auto-initialized. + """ + if acp_service is None: + agentex_client = create_async_agentex_client() + tracer = AsyncTracer(agentex_client) + self._acp_service = ACPService(agentex_client=agentex_client, tracer=tracer) + else: + self._acp_service = acp_service + + async def create_task( + self, + name: str | None = None, + agent_id: str | None = None, + agent_name: str | None = None, + params: dict[str, Any] | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + request: dict[str, Any] | None = None, + ) -> Task: + """ + Create a new task. + + Args: + name: Optional human-readable name for the task. task/create is + get-or-create by name: omit it (or make it unique, e.g. append a + UUID) for a fresh task on each call; passing a name that already + exists returns that task with its prior history instead of + creating a new one. Keep it globally unique when set. + agent_id: The ID of the agent to create the task for. + agent_name: The name of the agent to create the task for. + params: The parameters for the task. + start_to_close_timeout: The start to close timeout for the task. + heartbeat_timeout: The heartbeat timeout for the task. + retry_policy: The retry policy for the task. + request: Additional request context including headers to forward to the agent. + + Returns: + The task entry. + """ + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=ACPActivityName.TASK_CREATE, + request=TaskCreateParams( + name=name, + agent_id=agent_id, + agent_name=agent_name, + params=params, + trace_id=trace_id, + parent_span_id=parent_span_id, + request=request, + ), + response_type=Task, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._acp_service.task_create( + name=name, + agent_id=agent_id, + agent_name=agent_name, + params=params, + trace_id=trace_id, + parent_span_id=parent_span_id, + request=request, + ) + + async def send_event( + self, + task_id: str, + content: TaskMessageContent, + agent_id: str | None = None, + agent_name: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + request: dict[str, Any] | None = None, + ) -> Event: + """ + Send an event to a task. + + Args: + task_id: The ID of the task to send the event to. + content: The content to send to the event. + agent_id: The ID of the agent to send the event to. + agent_name: The name of the agent to send the event to. + trace_id: The trace ID for the event. + parent_span_id: The parent span ID for the event. + start_to_close_timeout: The start to close timeout for the event. + heartbeat_timeout: The heartbeat timeout for the event. + retry_policy: The retry policy for the event. + request: Additional request context including headers to forward to the agent. + + Returns: + The event entry. + """ + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=ACPActivityName.EVENT_SEND, + request=EventSendParams( + agent_id=agent_id, + agent_name=agent_name, + task_id=task_id, + content=content, + trace_id=trace_id, + parent_span_id=parent_span_id, + request=request, + ), + response_type=None, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._acp_service.event_send( + agent_id=agent_id, + agent_name=agent_name, + task_id=task_id, + content=content, + trace_id=trace_id, + parent_span_id=parent_span_id, + request=request, + ) + + async def send_message( + self, + content: TaskMessageContent, + task_id: str | None = None, + agent_id: str | None = None, + agent_name: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + request: dict[str, Any] | None = None, + ) -> List[TaskMessage]: + """ + Send a message to a task. + + Args: + content: The task message content to send to the task. + task_id: The ID of the task to send the message to. + agent_id: The ID of the agent to send the message to. + agent_name: The name of the agent to send the message to. + trace_id: The trace ID for the message. + parent_span_id: The parent span ID for the message. + start_to_close_timeout: The start to close timeout for the message. + heartbeat_timeout: The heartbeat timeout for the message. + retry_policy: The retry policy for the message. + request: Additional request context including headers to forward to the agent. + + Returns: + The message entry. + """ + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=ACPActivityName.MESSAGE_SEND, + request=MessageSendParams( + agent_id=agent_id, + agent_name=agent_name, + task_id=task_id, + content=content, + trace_id=trace_id, + parent_span_id=parent_span_id, + request=request, + ), + response_type=TaskMessage, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._acp_service.message_send( + agent_id=agent_id, + agent_name=agent_name, + task_id=task_id, + content=content, + trace_id=trace_id, + parent_span_id=parent_span_id, + request=request, + ) + + async def cancel_task( + self, + task_id: str | None = None, + task_name: str | None = None, + agent_id: str | None = None, + agent_name: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + request: dict[str, Any] | None = None, + ) -> Task: + """ + Cancel a task by sending cancel request to the agent that owns the task. + + Args: + task_id: ID of the task to cancel. + task_name: Name of the task to cancel. + agent_id: ID of the agent that owns the task. + agent_name: Name of the agent that owns the task. + trace_id: The trace ID for the task. + parent_span_id: The parent span ID for the task. + start_to_close_timeout: The start to close timeout for the task. + heartbeat_timeout: The heartbeat timeout for the task. + retry_policy: The retry policy for the task. + request: Additional request context including headers to forward to the agent. + + Returns: + The task entry. + + Raises: + ValueError: If neither agent_name nor agent_id is provided, + or if neither task_name nor task_id is provided + """ + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=ACPActivityName.TASK_CANCEL, + request=TaskCancelParams( + task_id=task_id, + task_name=task_name, + agent_id=agent_id, + agent_name=agent_name, + trace_id=trace_id, + parent_span_id=parent_span_id, + request=request, + ), + response_type=None, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._acp_service.task_cancel( + task_id=task_id, + task_name=task_name, + agent_id=agent_id, + agent_name=agent_name, + trace_id=trace_id, + parent_span_id=parent_span_id, + request=request, + ) diff --git a/src/agentex/lib/adk/_modules/agent_task_tracker.py b/src/agentex/lib/adk/_modules/agent_task_tracker.py new file mode 100644 index 000000000..733372ec7 --- /dev/null +++ b/src/agentex/lib/adk/_modules/agent_task_tracker.py @@ -0,0 +1,180 @@ +# ruff: noqa: I001 +# Import order matters - AsyncTracer must come after client import to avoid circular imports +from __future__ import annotations +from datetime import timedelta + +from temporalio.common import RetryPolicy + +from agentex import AsyncAgentex # noqa: F401 +from agentex.lib.adk.utils._modules.client import create_async_agentex_client +from agentex.lib.core.services.adk.agent_task_tracker import AgentTaskTrackerService +from agentex.lib.core.temporal.activities.activity_helpers import ActivityHelpers +from agentex.lib.core.temporal.activities.adk.agent_task_tracker_activities import ( + AgentTaskTrackerActivityName, + GetAgentTaskTrackerByTaskAndAgentParams, + GetAgentTaskTrackerParams, + UpdateAgentTaskTrackerParams, +) +from agentex.lib.core.tracing.tracer import AsyncTracer +from agentex.types.agent_task_tracker import AgentTaskTracker +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.temporal import in_temporal_workflow + +logger = make_logger(__name__) + +# Default retry policy for all agent task tracker operations +DEFAULT_RETRY_POLICY = RetryPolicy(maximum_attempts=1) + + +class AgentTaskTrackerModule: + """ + Module for managing agent task trackers in Agentex. + Provides high-level async methods for retrieving, filtering, and updating agent task trackers. + """ + + def __init__( + self, + agent_task_tracker_service: AgentTaskTrackerService | None = None, + ): + if agent_task_tracker_service is None: + agentex_client = create_async_agentex_client() + tracer = AsyncTracer(agentex_client) + self._agent_task_tracker_service = AgentTaskTrackerService( + agentex_client=agentex_client, tracer=tracer + ) + else: + self._agent_task_tracker_service = agent_task_tracker_service + + async def get( + self, + tracker_id: str, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> AgentTaskTracker: + """ + Get an agent task tracker by ID. + + Args: + tracker_id (str): The ID of the tracker. + trace_id (Optional[str]): The trace ID for tracing. + parent_span_id (Optional[str]): The parent span ID for tracing. + start_to_close_timeout (timedelta): The start to close timeout. + heartbeat_timeout (timedelta): The heartbeat timeout. + retry_policy (RetryPolicy): The retry policy. + + Returns: + AgentTaskTracker: The agent task tracker. + """ + params = GetAgentTaskTrackerParams( + tracker_id=tracker_id, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=AgentTaskTrackerActivityName.GET_AGENT_TASK_TRACKER, + request=params, + response_type=AgentTaskTracker, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._agent_task_tracker_service.get_agent_task_tracker( + tracker_id=tracker_id, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + + async def get_by_task_and_agent( + self, + task_id: str, + agent_id: str, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> AgentTaskTracker | None: + """ + Get an agent task tracker by task ID and agent ID. + """ + params = GetAgentTaskTrackerByTaskAndAgentParams( + task_id=task_id, + agent_id=agent_id, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=AgentTaskTrackerActivityName.GET_AGENT_TASK_TRACKER_BY_TASK_AND_AGENT, + request=params, + response_type=AgentTaskTracker, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._agent_task_tracker_service.get_by_task_and_agent( + task_id=task_id, + agent_id=agent_id, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + + async def update( + self, + tracker_id: str, + last_processed_event_id: str | None = None, + status: str | None = None, + status_reason: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> AgentTaskTracker: + """ + Update an agent task tracker. + + Args: + tracker_id (str): The ID of the tracker to update. + request (UpdateAgentTaskTrackerRequest): The update request containing the new values. + trace_id (Optional[str]): The trace ID for tracing. + parent_span_id (Optional[str]): The parent span ID for tracing. + start_to_close_timeout (timedelta): The start to close timeout. + heartbeat_timeout (timedelta): The heartbeat timeout. + retry_policy (RetryPolicy): The retry policy. + + Returns: + AgentTaskTracker: The updated agent task tracker. + """ + params = UpdateAgentTaskTrackerParams( + tracker_id=tracker_id, + last_processed_event_id=last_processed_event_id, + status=status, + status_reason=status_reason, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=AgentTaskTrackerActivityName.UPDATE_AGENT_TASK_TRACKER, + request=params, + response_type=AgentTaskTracker, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._agent_task_tracker_service.update_agent_task_tracker( + tracker_id=tracker_id, + last_processed_event_id=last_processed_event_id, + status=status, + status_reason=status_reason, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) diff --git a/src/agentex/lib/adk/_modules/agents.py b/src/agentex/lib/adk/_modules/agents.py new file mode 100644 index 000000000..eee8b9f7e --- /dev/null +++ b/src/agentex/lib/adk/_modules/agents.py @@ -0,0 +1,80 @@ +# ruff: noqa: I001 +# Import order matters - AsyncTracer must come after client import to avoid circular imports +from datetime import timedelta +from typing import Optional + +from agentex.lib.adk.utils._modules.client import create_async_agentex_client +from agentex.lib.core.temporal.activities.adk.agents_activities import AgentsActivityName, GetAgentParams +from temporalio.common import RetryPolicy + +from agentex import AsyncAgentex # noqa: F401 +from agentex.lib.core.services.adk.agents import AgentsService +from agentex.lib.core.temporal.activities.activity_helpers import ActivityHelpers +from agentex.lib.core.tracing.tracer import AsyncTracer +from agentex.types.agent import Agent +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.temporal import in_temporal_workflow + +logger = make_logger(__name__) + +DEFAULT_RETRY_POLICY = RetryPolicy(maximum_attempts=1) + + +class AgentsModule: + """ + Module for managing agents in Agentex. + Provides high-level async methods for retrieving, listing, and deleting agents. + """ + + def __init__( + self, + agents_service: Optional[AgentsService] = None, + ): + if agents_service is None: + agentex_client = create_async_agentex_client() + tracer = AsyncTracer(agentex_client) + self._agents_service = AgentsService(agentex_client=agentex_client, tracer=tracer) + else: + self._agents_service = agents_service + + async def get( + self, + *, + agent_id: Optional[str] = None, + agent_name: Optional[str] = None, + trace_id: Optional[str] = None, + parent_span_id: Optional[str] = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> Agent: + """ + Get an agent by ID or name. + Args: + agent_id: The ID of the agent to retrieve. + agent_name: The name of the agent to retrieve. + Returns: + The agent entry. + """ + params = GetAgentParams( + agent_id=agent_id, + agent_name=agent_name, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=AgentsActivityName.GET_AGENT, + request=params, + response_type=Agent, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._agents_service.get_agent( + agent_id=agent_id, + agent_name=agent_name, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) diff --git a/src/agentex/lib/adk/_modules/checkpointer.py b/src/agentex/lib/adk/_modules/checkpointer.py new file mode 100644 index 000000000..544042941 --- /dev/null +++ b/src/agentex/lib/adk/_modules/checkpointer.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from agentex.lib.adk.utils._modules.client import create_async_agentex_client +from agentex.lib.adk._modules._http_checkpointer import HttpCheckpointSaver + + +async def create_checkpointer() -> HttpCheckpointSaver: + """Create an HTTP-proxy checkpointer for LangGraph. + + Checkpoint operations are proxied through the agentex backend API. + No direct database connection needed — auth is handled via the + agent API key (injected automatically by agentex). + + Usage: + checkpointer = await create_checkpointer() + graph = builder.compile(checkpointer=checkpointer) + """ + client = create_async_agentex_client() + return HttpCheckpointSaver(client=client) diff --git a/src/agentex/lib/adk/_modules/events.py b/src/agentex/lib/adk/_modules/events.py new file mode 100644 index 000000000..4995ae172 --- /dev/null +++ b/src/agentex/lib/adk/_modules/events.py @@ -0,0 +1,145 @@ +# ruff: noqa: I001 +# Import order matters - AsyncTracer must come after client import to avoid circular imports +from __future__ import annotations +from datetime import timedelta + +from temporalio.common import RetryPolicy + +from agentex import AsyncAgentex # noqa: F401 +from agentex.lib.adk.utils._modules.client import create_async_agentex_client +from agentex.lib.core.services.adk.events import EventsService +from agentex.lib.core.temporal.activities.activity_helpers import ActivityHelpers +from agentex.lib.core.temporal.activities.adk.events_activities import ( + EventsActivityName, + GetEventParams, + ListEventsParams, +) +from agentex.lib.core.tracing.tracer import AsyncTracer +from agentex.types.event import Event +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.temporal import in_temporal_workflow + +logger = make_logger(__name__) + +# Default retry policy for all events operations +DEFAULT_RETRY_POLICY = RetryPolicy(maximum_attempts=1) + + +class EventsModule: + """ + Module for managing events in Agentex. + Provides high-level async methods for retrieving and listing events. + """ + + def __init__( + self, + events_service: EventsService | None = None, + ): + if events_service is None: + agentex_client = create_async_agentex_client() + tracer = AsyncTracer(agentex_client) + self._events_service = EventsService( + agentex_client=agentex_client, tracer=tracer + ) + else: + self._events_service = events_service + + async def get( + self, + event_id: str, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> Event | None: + """ + Get an event by ID. + + Args: + event_id (str): The ID of the event. + trace_id (Optional[str]): The trace ID for tracing. + parent_span_id (Optional[str]): The parent span ID for tracing. + start_to_close_timeout (timedelta): The start to close timeout. + heartbeat_timeout (timedelta): The heartbeat timeout. + retry_policy (RetryPolicy): The retry policy. + + Returns: + Optional[Event]: The event if found, None otherwise. + """ + params = GetEventParams( + event_id=event_id, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=EventsActivityName.GET_EVENT, + request=params, + response_type=Event, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._events_service.get_event( + event_id=event_id, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + + async def list_events( + self, + task_id: str, + agent_id: str, + last_processed_event_id: str | None = None, + limit: int | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> list[Event]: + """ + List events for a specific task and agent. + + Args: + task_id (str): The ID of the task. + agent_id (str): The ID of the agent. + last_processed_event_id (Optional[str]): Optional event ID to get events after this ID. + limit (Optional[int]): Optional limit on number of results. + trace_id (Optional[str]): The trace ID for tracing. + parent_span_id (Optional[str]): The parent span ID for tracing. + start_to_close_timeout (timedelta): The start to close timeout. + heartbeat_timeout (timedelta): The heartbeat timeout. + retry_policy (RetryPolicy): The retry policy. + + Returns: + List[Event]: List of events ordered by sequence_id. + """ + params = ListEventsParams( + task_id=task_id, + agent_id=agent_id, + last_processed_event_id=last_processed_event_id, + limit=limit, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=EventsActivityName.LIST_EVENTS, + request=params, + response_type=list[Event], + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._events_service.list_events( + task_id=task_id, + agent_id=agent_id, + last_processed_event_id=last_processed_event_id, + limit=limit, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) diff --git a/src/agentex/lib/adk/_modules/messages.py b/src/agentex/lib/adk/_modules/messages.py new file mode 100644 index 000000000..992683b58 --- /dev/null +++ b/src/agentex/lib/adk/_modules/messages.py @@ -0,0 +1,301 @@ +# ruff: noqa: I001 +# Import order matters - AsyncTracer must come after client import to avoid circular imports +from __future__ import annotations +from datetime import datetime, timedelta + +from temporalio.common import RetryPolicy + +from agentex import AsyncAgentex # noqa: F401 +from agentex.lib.adk.utils._modules.client import create_async_agentex_client +from agentex.lib.core.adapters.streams.adapter_redis import RedisStreamRepository +from agentex.lib.core.services.adk.messages import MessagesService +from agentex.lib.core.services.adk.streaming import StreamingService +from agentex.lib.core.temporal.activities.activity_helpers import ActivityHelpers +from agentex.lib.core.temporal.activities.adk.messages_activities import ( + CreateMessageParams, + CreateMessagesBatchParams, + ListMessagesParams, + MessagesActivityName, + UpdateMessageParams, + UpdateMessagesBatchParams, +) +from agentex.lib.core.tracing.tracer import AsyncTracer +from agentex.types.task_message import TaskMessage, TaskMessageContent +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.temporal import in_temporal_workflow, workflow_now_if_in_workflow + +logger = make_logger(__name__) + +# Default retry policy for all message operations +DEFAULT_RETRY_POLICY = RetryPolicy(maximum_attempts=1) + + +class MessagesModule: + """ + Module for managing task messages in Agentex. + Provides high-level async methods for creating, retrieving, updating, and deleting messages. + """ + + def __init__( + self, + messages_service: MessagesService | None = None, + ): + if messages_service is None: + agentex_client = create_async_agentex_client() + stream_repository = RedisStreamRepository() + streaming_service = StreamingService( + agentex_client=agentex_client, + stream_repository=stream_repository, + ) + tracer = AsyncTracer(agentex_client) + self._messages_service = MessagesService( + agentex_client=agentex_client, + streaming_service=streaming_service, + tracer=tracer, + ) + else: + self._messages_service = messages_service + + async def create( + self, + task_id: str, + content: TaskMessageContent, + emit_updates: bool = True, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + created_at: datetime | None = None, + ) -> TaskMessage: + """ + Create a new message for a task. + + Args: + task_id (str): The ID of the task. + message (TaskMessage): The message to create. + trace_id (Optional[str]): The trace ID for tracing. + parent_span_id (Optional[str]): The parent span ID for tracing. + start_to_close_timeout (timedelta): The start to close timeout. + heartbeat_timeout (timedelta): The heartbeat timeout. + retry_policy (RetryPolicy): The retry policy. + + Returns: + TaskMessageEntity: The created message. + """ + # Default created_at to workflow.now() so two awaited adk.messages.create + # calls from the same workflow are guaranteed monotonic at the server. + if created_at is None: + created_at = workflow_now_if_in_workflow() + params = CreateMessageParams( + trace_id=trace_id, + parent_span_id=parent_span_id, + task_id=task_id, + content=content, + emit_updates=emit_updates, + created_at=created_at, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=MessagesActivityName.CREATE_MESSAGE, + request=params, + response_type=TaskMessage, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._messages_service.create_message( + task_id=task_id, + content=content, + emit_updates=emit_updates, + created_at=created_at, + ) + + async def update( + self, + task_id: str, + message_id: str, + content: TaskMessageContent, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> TaskMessage: + """ + Update a message for a task. + + Args: + task_id (str): The ID of the task. + message_id (str): The ID of the message. + message (TaskMessage): The message to update. + start_to_close_timeout (timedelta): The start to close timeout. + heartbeat_timeout (timedelta): The heartbeat timeout. + retry_policy (RetryPolicy): The retry policy. + + Returns: + TaskMessageEntity: The updated message. + """ + params = UpdateMessageParams( + task_id=task_id, + message_id=message_id, + content=content, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=MessagesActivityName.UPDATE_MESSAGE, + request=params, + response_type=TaskMessage, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._messages_service.update_message( + task_id=task_id, + message_id=message_id, + content=content, + ) + + async def create_batch( + self, + task_id: str, + contents: list[TaskMessageContent], + emit_updates: bool = True, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + created_at: datetime | None = None, + ) -> list[TaskMessage]: + """ + Create a batch of messages for a task. + + Args: + task_id (str): The ID of the task. + messages (List[TaskMessage]): The messages to create. + start_to_close_timeout (timedelta): The start to close timeout. + heartbeat_timeout (timedelta): The heartbeat timeout. + retry_policy (RetryPolicy): The retry policy. + + Returns: + List[TaskMessageEntity]: The created messages. + """ + if created_at is None: + created_at = workflow_now_if_in_workflow() + params = CreateMessagesBatchParams( + task_id=task_id, + contents=contents, + emit_updates=emit_updates, + trace_id=trace_id, + parent_span_id=parent_span_id, + created_at=created_at, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=MessagesActivityName.CREATE_MESSAGES_BATCH, + request=params, + response_type=list[TaskMessage], + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._messages_service.create_messages_batch( + task_id=task_id, + contents=contents, + emit_updates=emit_updates, + created_at=created_at, + ) + + async def update_batch( + self, + task_id: str, + updates: dict[str, TaskMessageContent], + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> list[TaskMessage]: + """ + Update a batch of messages for a task. + + Args: + task_id (str): The ID of the task. + updates (Dict[str, TaskMessage]): The updates to apply to the messages. + start_to_close_timeout (timedelta): The start to close timeout. + heartbeat_timeout (timedelta): The heartbeat timeout. + retry_policy (RetryPolicy): The retry policy. + + Returns: + List[TaskMessageEntity]: The updated messages. + """ + params = UpdateMessagesBatchParams( + task_id=task_id, + updates=updates, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=MessagesActivityName.UPDATE_MESSAGES_BATCH, + request=params, + response_type=list[TaskMessage], + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._messages_service.update_messages_batch( + task_id=task_id, + updates=updates, + ) + + async def list( + self, + task_id: str, + limit: int | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> list[TaskMessage]: + """ + List messages for a task. + + Args: + task_id (str): The ID of the task. + limit (Optional[int]): The maximum number of messages to return. + start_to_close_timeout (timedelta): The start to close timeout. + heartbeat_timeout (timedelta): The heartbeat timeout. + retry_policy (RetryPolicy): The retry policy. + + Returns: + List[TaskMessageEntity]: The list of messages. + """ + params = ListMessagesParams( + task_id=task_id, + limit=limit, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=MessagesActivityName.LIST_MESSAGES, + request=params, + response_type=list[TaskMessage], + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._messages_service.list_messages( + task_id=task_id, + limit=limit, + ) diff --git a/src/agentex/lib/adk/_modules/state.py b/src/agentex/lib/adk/_modules/state.py new file mode 100644 index 000000000..a5a343e92 --- /dev/null +++ b/src/agentex/lib/adk/_modules/state.py @@ -0,0 +1,295 @@ +# ruff: noqa: I001 +# Import order matters - AsyncTracer must come after client import to avoid circular imports +from __future__ import annotations +from datetime import timedelta +from typing import Any + +from pydantic import BaseModel +from temporalio.common import RetryPolicy + +from agentex import AsyncAgentex # noqa: F401 +from agentex.lib.adk.utils._modules.client import create_async_agentex_client +from agentex.lib.core.services.adk.state import StateService +from agentex.lib.core.temporal.activities.activity_helpers import ActivityHelpers +from agentex.lib.core.temporal.activities.adk.state_activities import ( + CreateStateParams, + DeleteStateParams, + GetStateParams, + StateActivityName, + UpdateStateParams, +) +from agentex.lib.core.tracing.tracer import AsyncTracer +from agentex.types.state import State +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.temporal import in_temporal_workflow + +logger = make_logger(__name__) + +# Default retry policy for all state operations +DEFAULT_RETRY_POLICY = RetryPolicy(maximum_attempts=1) + + +class StateModule: + """ + Module for managing task state in Agentex. + Provides high-level async methods for creating, retrieving, updating, and deleting state. + """ + + def __init__( + self, + state_service: StateService | None = None, + ): + if state_service is None: + agentex_client = create_async_agentex_client() + tracer = AsyncTracer(agentex_client) + self._state_service = StateService( + agentex_client=agentex_client, tracer=tracer + ) + else: + self._state_service = state_service + + async def create( + self, + task_id: str, + agent_id: str, + state: dict[str, Any] | BaseModel, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> State: + """ + Create a new state for a task and agent. + + Args: + task_id (str): The ID of the task. + agent_id (str): The ID of the agent. + state (Dict[str, Any]): The state to create. + trace_id (Optional[str]): The trace ID for tracing. + parent_span_id (Optional[str]): The parent span ID for tracing. + start_to_close_timeout (timedelta): The start to close timeout. + heartbeat_timeout (timedelta): The heartbeat timeout. + retry_policy (RetryPolicy): The retry policy. + + Returns: + State: The created state. + """ + state_dict = state.model_dump() if isinstance(state, BaseModel) else state + params = CreateStateParams( + task_id=task_id, + agent_id=agent_id, + state=state_dict, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=StateActivityName.CREATE_STATE, + request=params, + response_type=State, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._state_service.create_state( + task_id=task_id, + agent_id=agent_id, + state=state_dict, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + + async def get( + self, + state_id: str, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> State | None: + """ + Get a state by ID. + + Args: + state_id (str): The ID of the state. + trace_id (Optional[str]): The trace ID for tracing. + parent_span_id (Optional[str]): The parent span ID for tracing. + start_to_close_timeout (timedelta): The start to close timeout. + heartbeat_timeout (timedelta): The heartbeat timeout. + retry_policy (RetryPolicy): The retry policy. + + Returns: + Optional[State]: The state if found, None otherwise. + """ + params = GetStateParams( + state_id=state_id, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=StateActivityName.GET_STATE, + request=params, + response_type=State, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._state_service.get_state( + state_id=state_id, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + + async def get_by_task_and_agent( + self, + task_id: str, + agent_id: str, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> State | None: + """ + Get a state by task and agent ID. A state is uniquely identified by task and the agent that created it. + + Args: + task_id (str): The ID of the task. + agent_id (str): The ID of the agent. + trace_id (Optional[str]): The trace ID for tracing. + parent_span_id (Optional[str]): The parent span ID for tracing. + start_to_close_timeout (timedelta): The start to close timeout. + heartbeat_timeout (timedelta): The heartbeat timeout. + retry_policy (RetryPolicy): The retry policy. + + Returns: + Optional[State]: The state if found, None otherwise. + """ + params = GetStateParams( + task_id=task_id, + agent_id=agent_id, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=StateActivityName.GET_STATE, + request=params, + response_type=State, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._state_service.get_state( + task_id=task_id, + agent_id=agent_id, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + + async def update( + self, + state_id: str, + task_id: str, + agent_id: str, + state: dict[str, Any] | BaseModel, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> State: + """ + Update a state by ID. + + Args: + state_id (str): The ID of the state. + task_id (str): The ID of the task. + agent_id (str): The ID of the agent. + state (Dict[str, Any]): The state to update. + trace_id (Optional[str]): The trace ID for tracing. + parent_span_id (Optional[str]): The parent span ID for tracing. + start_to_close_timeout (timedelta): The start to close timeout. + heartbeat_timeout (timedelta): The heartbeat timeout. + retry_policy (RetryPolicy): The retry policy. + + Returns: + State: The updated state. + """ + state_dict = state.model_dump() if isinstance(state, BaseModel) else state + params = UpdateStateParams( + state_id=state_id, + task_id=task_id, + agent_id=agent_id, + state=state_dict, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=StateActivityName.UPDATE_STATE, + request=params, + response_type=State, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._state_service.update_state( + state_id=state_id, + task_id=task_id, + agent_id=agent_id, + state=state_dict, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + + async def delete( + self, + state_id: str, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> State: + """ + Delete a state by ID. + + Args: + state_id (str): The ID of the state. + trace_id (Optional[str]): The trace ID for tracing. + parent_span_id (Optional[str]): The parent span ID for tracing. + start_to_close_timeout (timedelta): The start to close timeout. + heartbeat_timeout (timedelta): The heartbeat timeout. + retry_policy (RetryPolicy): The retry policy. + + Returns: + State: The deleted state. + """ + params = DeleteStateParams( + state_id=state_id, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=StateActivityName.DELETE_STATE, + request=params, + response_type=State, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._state_service.delete_state( + state_id=state_id, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) diff --git a/src/agentex/lib/adk/_modules/streaming.py b/src/agentex/lib/adk/_modules/streaming.py new file mode 100644 index 000000000..561b1165d --- /dev/null +++ b/src/agentex/lib/adk/_modules/streaming.py @@ -0,0 +1,89 @@ +# ruff: noqa: I001 +# Import order matters - AsyncTracer must come after client import to avoid circular imports +from __future__ import annotations +from datetime import datetime +from temporalio.common import RetryPolicy + +from agentex import AsyncAgentex # noqa: F401 +from agentex.lib.adk.utils._modules.client import create_async_agentex_client +from agentex.lib.core.adapters.streams.adapter_redis import RedisStreamRepository +from agentex.lib.core.services.adk.streaming import ( + StreamingMode, + StreamingService, + StreamingTaskMessageContext, +) +from agentex.types.task_message_content import TaskMessageContent +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.temporal import in_temporal_workflow + +logger = make_logger(__name__) + +DEFAULT_RETRY_POLICY = RetryPolicy(maximum_attempts=1) + + +class StreamingModule: + """ + Module for streaming content to clients in Agentex. + + This interface wraps around the StreamingService and provides a high-level API + for streaming events to clients, supporting both synchronous and asynchronous + (Temporal workflow) contexts. + """ + + def __init__(self, streaming_service: StreamingService | None = None): + """ + Initialize the streaming interface. + + Args: + streaming_service (Optional[StreamingService]): Optional StreamingService instance. If not provided, + a new service will be created with default parameters. + """ + if streaming_service is None: + stream_repository = RedisStreamRepository() + agentex_client = create_async_agentex_client() + self._streaming_service = StreamingService( + agentex_client=agentex_client, + stream_repository=stream_repository, + ) + else: + self._streaming_service = streaming_service + + def streaming_task_message_context( + self, + task_id: str, + initial_content: TaskMessageContent, + streaming_mode: StreamingMode = "coalesced", + created_at: datetime | None = None, + ) -> StreamingTaskMessageContext: + """ + Create a streaming context for managing TaskMessage lifecycle. + + This is a context manager that automatically creates a TaskMessage, sends START event, + and sends DONE event when the context exits. Perfect for simple streaming scenarios. + + Args: + task_id: The ID of the task + initial_content: The initial content for the TaskMessage + streaming_mode: How per-delta updates are published. Defaults to + "coalesced" (50ms / 128-char windowed batches with an immediate + first-delta flush). Pass "per_token" for the legacy publish-every- + delta behavior, or "off" to suppress per-delta publishes entirely + while still recording the full message body on close. + + Returns: + StreamingTaskMessageContext: Context manager for streaming operations + """ + # Note: We don't support Temporal activities for streaming context methods yet + # since they involve complex state management across multiple activity calls + if in_temporal_workflow(): + logger.warning( + "Streaming context methods are not yet supported in Temporal workflows. " + "You should wrap the entire streaming context in an activity. All nondeterministic network calls should be wrapped in an activity and generators cannot operate across activities and workflows." + ) + + return self._streaming_service.streaming_task_message_context( + task_id=task_id, + initial_content=initial_content, + streaming_mode=streaming_mode, + created_at=created_at, + ) diff --git a/src/agentex/lib/adk/_modules/tasks.py b/src/agentex/lib/adk/_modules/tasks.py new file mode 100644 index 000000000..d842f23ee --- /dev/null +++ b/src/agentex/lib/adk/_modules/tasks.py @@ -0,0 +1,478 @@ +# ruff: noqa: I001 +# Import order matters - AsyncTracer must come after client import to avoid circular imports +from __future__ import annotations +from datetime import timedelta + +from temporalio.common import RetryPolicy + +from agentex import AsyncAgentex # noqa: F401 +from agentex.lib.adk.utils._modules.client import create_async_agentex_client +from agentex.lib.core.services.adk.tasks import TasksService +from agentex.lib.core.temporal.activities.activity_helpers import ActivityHelpers +from agentex.lib.core.temporal.activities.adk.tasks_activities import ( + DeleteTaskParams, + GetTaskParams, + QueryWorkflowParams, + TasksActivityName, + TaskStatusTransitionParams, + UpdateTaskParams, +) +from agentex.lib.core.tracing.tracer import AsyncTracer +from agentex.types.task import Task +from agentex.types.task_retrieve_response import TaskRetrieveResponse +from agentex.types.task_retrieve_by_name_response import TaskRetrieveByNameResponse +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.temporal import in_temporal_workflow + +logger = make_logger(__name__) + +DEFAULT_RETRY_POLICY = RetryPolicy(maximum_attempts=1) + + +class TasksModule: + """ + Module for managing tasks in Agentex. + Provides high-level async methods for retrieving, listing, and deleting tasks. + """ + + def __init__( + self, + tasks_service: TasksService | None = None, + ): + if tasks_service is None: + agentex_client = create_async_agentex_client() + tracer = AsyncTracer(agentex_client) + self._tasks_service = TasksService( + agentex_client=agentex_client, tracer=tracer + ) + else: + self._tasks_service = tasks_service + + async def get( + self, + *, + task_id: str | None = None, + task_name: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> TaskRetrieveResponse | TaskRetrieveByNameResponse: + """ + Get a task by ID or name. + Args: + task_id: The ID of the task to retrieve. + task_name: The name of the task to retrieve. + Returns: + The task entry. + """ + params = GetTaskParams( + task_id=task_id, + task_name=task_name, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=TasksActivityName.GET_TASK, + request=params, + response_type=Task, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._tasks_service.get_task( + task_id=task_id, + task_name=task_name, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + + async def delete( + self, + *, + task_id: str | None = None, + task_name: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> Task: + """ + Delete a task by ID or name. + Args: + task_id: The ID of the task to delete. + task_name: The name of the task to delete. + Returns: + The deleted task entry. + """ + params = DeleteTaskParams( + task_id=task_id, + task_name=task_name, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=TasksActivityName.DELETE_TASK, + request=params, + response_type=Task, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._tasks_service.delete_task( # type: ignore[return-value] + task_id=task_id, + task_name=task_name, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + + async def cancel( + self, + *, + task_id: str, + reason: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> Task: + """ + Mark a running task as canceled. + Args: + task_id: The ID of the task to cancel. + reason: Optional reason for cancellation. + Returns: + The updated task entry. + """ + params = TaskStatusTransitionParams( + task_id=task_id, + reason=reason, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=TasksActivityName.CANCEL_TASK, + request=params, + response_type=Task, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._tasks_service.cancel_task( + task_id=task_id, + reason=reason, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + + async def interrupt( + self, + *, + task_id: str, + reason: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> Task: + """ + Mark a running task as interrupted (non-terminal). + + Interrupt is cooperative: an agent calls this from its interrupt handler, + after it has actually stopped its in-flight turn, to record INTERRUPTED. + The task stays continuable; the control plane resumes it to RUNNING on the + next turn. + Args: + task_id: The ID of the task to interrupt. + reason: Optional reason for the interrupt. + Returns: + The updated task entry. + """ + params = TaskStatusTransitionParams( + task_id=task_id, + reason=reason, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=TasksActivityName.INTERRUPT_TASK, + request=params, + response_type=Task, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._tasks_service.interrupt_task( + task_id=task_id, + reason=reason, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + + async def complete( + self, + *, + task_id: str, + reason: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> Task: + """ + Mark a running task as completed. + Args: + task_id: The ID of the task to complete. + reason: Optional reason for completion. + Returns: + The updated task entry. + """ + params = TaskStatusTransitionParams( + task_id=task_id, + reason=reason, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=TasksActivityName.COMPLETE_TASK, + request=params, + response_type=Task, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._tasks_service.complete_task( + task_id=task_id, + reason=reason, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + + async def fail( + self, + *, + task_id: str, + reason: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> Task: + """ + Mark a running task as failed. + Args: + task_id: The ID of the task to fail. + reason: Optional reason for failure. + Returns: + The updated task entry. + """ + params = TaskStatusTransitionParams( + task_id=task_id, + reason=reason, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=TasksActivityName.FAIL_TASK, + request=params, + response_type=Task, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._tasks_service.fail_task( + task_id=task_id, + reason=reason, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + + async def terminate( + self, + *, + task_id: str, + reason: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> Task: + """ + Mark a running task as terminated. + Args: + task_id: The ID of the task to terminate. + reason: Optional reason for termination. + Returns: + The updated task entry. + """ + params = TaskStatusTransitionParams( + task_id=task_id, + reason=reason, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=TasksActivityName.TERMINATE_TASK, + request=params, + response_type=Task, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._tasks_service.terminate_task( + task_id=task_id, + reason=reason, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + + async def timeout( + self, + *, + task_id: str, + reason: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> Task: + """ + Mark a running task as timed out. + Args: + task_id: The ID of the task to time out. + reason: Optional reason for timeout. + Returns: + The updated task entry. + """ + params = TaskStatusTransitionParams( + task_id=task_id, + reason=reason, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=TasksActivityName.TIMEOUT_TASK, + request=params, + response_type=Task, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._tasks_service.timeout_task( + task_id=task_id, + reason=reason, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + + async def update( + self, + *, + task_id: str | None = None, + task_name: str | None = None, + task_metadata: dict[str, object] | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> Task: + """ + Update mutable fields for a task by ID or name. + Args: + task_id: The ID of the task to update. + task_name: The name of the task to update. + task_metadata: Metadata to update on the task. + Returns: + The updated task entry. + """ + params = UpdateTaskParams( + task_id=task_id, + task_name=task_name, + task_metadata=task_metadata, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=TasksActivityName.UPDATE_TASK, + request=params, + response_type=Task, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._tasks_service.update_task( + task_id=task_id, + task_name=task_name, + task_metadata=task_metadata, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + + async def query_workflow( + self, + *, + task_id: str, + query_name: str, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> dict[str, object]: + """ + Query a Temporal workflow associated with a task for its current state. + Args: + task_id: The ID of the task whose workflow to query. + query_name: The name of the query to execute. + Returns: + The query result. + """ + params = QueryWorkflowParams( + task_id=task_id, + query_name=query_name, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=TasksActivityName.QUERY_WORKFLOW, + request=params, + response_type=dict, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + else: + return await self._tasks_service.query_workflow( + task_id=task_id, + query_name=query_name, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) diff --git a/src/agentex/lib/adk/_modules/tracing.py b/src/agentex/lib/adk/_modules/tracing.py new file mode 100644 index 000000000..9b89d076e --- /dev/null +++ b/src/agentex/lib/adk/_modules/tracing.py @@ -0,0 +1,427 @@ +# ruff: noqa: I001 +# Import order matters - AsyncTracer must come after client import to avoid circular imports +from __future__ import annotations +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from datetime import timedelta +from typing import Any + +from temporalio import workflow +from temporalio.common import RetryPolicy +from temporalio.exceptions import ActivityError, TimeoutError as TemporalTimeoutError, is_cancelled_exception + +from agentex import AsyncAgentex # noqa: F401 +from agentex.lib.adk.utils._modules.client import create_async_agentex_client +from agentex.lib.core.services.adk.tracing import TracingService +from agentex.lib.core.temporal.activities.activity_helpers import ActivityHelpers +from agentex.lib.core.temporal.activities.adk.tracing_activities import ( + EndSpanParams, + StartSpanParams, + TracingActivityName, +) +from agentex.lib.core.tracing.span_error import set_span_error +from agentex.lib.core.tracing.tracer import AsyncTracer +from agentex.lib.core.harness.types import TurnUsage +from agentex.types.span import Span +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.utils.temporal import in_temporal_workflow + +logger = make_logger(__name__) + +DEFAULT_RETRY_POLICY = RetryPolicy(maximum_attempts=1) +TEMPORAL_SPAN_ACTIVITY_DROPPED_METRIC = "agentex.tracing.temporal_span_activity.dropped" + +# Token key spellings the backend accepts when billing usage from spans. +RECOGNIZED_USAGE_KEYS = frozenset( + { + "input_tokens", + "prompt_tokens", + "output_tokens", + "completion_tokens", + "cached_input_tokens", + "cached_tokens", + "reasoning_tokens", + "total_tokens", + "cost_usd", + } +) + + +def _record_temporal_span_activity_dropped(event_type: str) -> None: + try: + workflow.metric_meter().create_counter( + TEMPORAL_SPAN_ACTIVITY_DROPPED_METRIC, + description="Temporal tracing span activities dropped after fail-open", + unit="1", + ).add(1, {"event_type": event_type}) + except Exception: + pass + + +class TurnSpan: + """Handle for a turn-level (rollup) span, yielded by ``TracingModule.turn_span``. + + Encapsulates the billing contract so agents cannot double-count usage: + the turn's aggregate usage goes to ``span.data["usage"]`` (+ + ``span.data["cost_usd"]``) via :meth:`record_usage`. The backend keeps the + aggregate and de-dups any per-call ``output["usage"]`` children against it. + Never hand-write usage into ``output`` on a rollup span — that is the + double-count bug this helper exists to prevent. + + All methods no-op when tracing is disabled (``span`` is None), so agent + code needs no ``if span:`` guards. + """ + + def __init__(self, span: Span | None): + self.span = span + + def record_usage( + self, + usage: TurnUsage | dict[str, Any] | None = None, + cost_usd: float | None = None, + ) -> None: + """Record the turn's aggregate usage on the span's ``data``. + + Pass the harness ``TurnUsage`` (e.g. ``LangGraphTurn.usage()`` or + ``run_turn(...).usage``) — its ``cost_usd`` is stamped automatically — + or a plain dict with backend-recognized token spellings + (``prompt_tokens``/``completion_tokens`` also work). An explicit + ``cost_usd`` argument overrides any cost carried by ``usage``. The + usage must be this turn's own tokens, not a session-cumulative total. + """ + if self.span is None: + return + + blob: dict[str, Any] + if isinstance(usage, TurnUsage): + blob = usage.model_dump(exclude_none=True) + # cost lives beside the blob as data["cost_usd"], not inside it + blob_cost = blob.pop("cost_usd", None) + if cost_usd is None: + cost_usd = blob_cost + elif usage is not None: + blob = dict(usage) + if not any(key in RECOGNIZED_USAGE_KEYS for key in blob): + logger.warning( + "TurnSpan.record_usage: usage has no recognized token keys and will " + f"not be billed. Got keys {sorted(blob)}; expected any of " + f"{sorted(RECOGNIZED_USAGE_KEYS)}." + ) + else: + blob = {} + + if self.span.data is not None and not isinstance(self.span.data, dict): + logger.warning( + f"TurnSpan.record_usage: span.data is {type(self.span.data).__name__} " + "(expected dict or None); existing data will be replaced." + ) + data = self.span.data if isinstance(self.span.data, dict) else {} + if blob: + data["usage"] = blob + if cost_usd is not None: + data["cost_usd"] = cost_usd + self.span.data = data + + @property + def output(self) -> Any: + return self.span.output if self.span is not None else None + + @output.setter + def output(self, value: Any) -> None: + if self.span is not None: + self.span.output = value + + +class TracingModule: + """ + Module for managing tracing and span operations in Agentex. + Provides high-level async methods for starting, ending, and managing spans for distributed tracing. + """ + + def __init__(self, tracing_service: TracingService | None = None): + """ + Initialize the tracing interface. + + Args: + tracing_service (Optional[TracingService]): Optional pre-configured tracing service. + If None, will be lazily created on first use so the httpx client is + bound to the correct running event loop. + """ + self._tracing_service_explicit = tracing_service + self._tracing_service_lazy: TracingService | None = None + self._bound_loop_id: int | None = None + + @property + def _tracing_service(self) -> TracingService: + if self._tracing_service_explicit is not None: + return self._tracing_service_explicit + + import asyncio + + # Determine the current event loop (if any). + try: + loop = asyncio.get_running_loop() + loop_id = id(loop) + except RuntimeError: + loop_id = None + + # Re-create the underlying httpx client when the event loop changes + # (e.g. between HTTP requests in a sync ASGI server) to avoid + # "Event loop is closed" / "bound to a different event loop" errors. + if self._tracing_service_lazy is None or (loop_id is not None and loop_id != self._bound_loop_id): + import httpx + + # Keepalive ON: connections are reused within a single event + # loop, eliminating the TLS-handshake-per-span penalty under + # load. Cross-loop safety is preserved by rebuilding the + # client whenever loop_id changes (the conditional above). + agentex_client = create_async_agentex_client( + http_client=httpx.AsyncClient( + limits=httpx.Limits(max_keepalive_connections=20), + ), + ) + tracer = AsyncTracer(agentex_client) + self._tracing_service_lazy = TracingService(tracer=tracer) + self._bound_loop_id = loop_id + + return self._tracing_service_lazy + + @asynccontextmanager + async def span( + self, + trace_id: str, + name: str, + input: list[Any] | dict[str, Any] | BaseModel | None = None, + data: list[Any] | dict[str, Any] | BaseModel | None = None, + parent_id: str | None = None, + task_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> AsyncGenerator[Span | None, None]: + """ + Async context manager for creating and automatically closing a span. + Yields the started span object. The span is automatically ended when the context exits. + + If trace_id is falsy, acts as a no-op context manager. + + Args: + trace_id (str): The trace ID for the span. + name (str): The name of the span. + input (Union[List, Dict, BaseModel]): The input for the span. + parent_id (Optional[str]): The parent span ID for the span. + data (Optional[Union[List, Dict, BaseModel]]): The data for the span. + task_id (Optional[str]): The task ID this span belongs to. + start_to_close_timeout (timedelta): The start to close timeout for the span. + heartbeat_timeout (timedelta): The heartbeat timeout for the span. + retry_policy (RetryPolicy): The retry policy for the span. + + Returns: + AsyncGenerator[Optional[Span], None]: An async generator that yields the started span object. + """ + if not trace_id: + yield None + return + + span: Span | None = await self.start_span( + trace_id=trace_id, + name=name, + input=input, + parent_id=parent_id, + data=data, + task_id=task_id, + start_to_close_timeout=start_to_close_timeout, + heartbeat_timeout=heartbeat_timeout, + retry_policy=retry_policy, + ) + try: + yield span + except Exception as exc: + # Record the failure on the span so the obs span reflects the error + # instead of a false green. Agents use THIS context manager (not + # AsyncTrace.span, which is the only other place set_span_error is + # called), so without this a failed step closes green. end_span (in + # finally) reads it via get_span_error and propagates it to + # close_obs_span. Stored on span.data, so it round-trips through the + # END_SPAN activity on the Temporal path too. + # + # Guard set_span_error itself: it's obs work and must never replace + # the app's exception on the way out. We always re-raise the ORIGINAL + # exc regardless. + if span: + try: + set_span_error(span, exc) + except Exception: # pragma: no cover - obs must not break app path + pass + raise + finally: + if span: + await self.end_span( + trace_id=trace_id, + span=span, + start_to_close_timeout=start_to_close_timeout, + heartbeat_timeout=heartbeat_timeout, + retry_policy=retry_policy, + ) + + @asynccontextmanager + async def turn_span( + self, + trace_id: str, + name: str, + input: list[Any] | dict[str, Any] | BaseModel | None = None, + data: list[Any] | dict[str, Any] | BaseModel | None = None, + parent_id: str | None = None, + task_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=5), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> AsyncGenerator[TurnSpan, None]: + """Span for one agent turn, with usage recorded as the billable aggregate. + + Same lifecycle as :meth:`span`, but yields a :class:`TurnSpan` whose + ``record_usage(usage=..., cost_usd=...)`` writes the turn's rollup + usage to ``span.data`` — the shape the backend bills once per turn. + Per-call child spans (LLM adapters) may still carry + ``output["usage"]``; the backend de-dups them against this aggregate. + + Example (with a harness turn, e.g. ``LangGraphTurn`` / ``run_turn``):: + + async with adk.tracing.turn_span(trace_id=task.id, name="turn", input={...}, task_id=task.id) as turn: + result = await run_turn(...) + turn.output = {"response": result.final_output} + turn.record_usage(result.usage) # TurnUsage, cost_usd included + """ + async with self.span( + trace_id=trace_id, + name=name, + input=input, + data=data, + parent_id=parent_id, + task_id=task_id, + start_to_close_timeout=start_to_close_timeout, + heartbeat_timeout=heartbeat_timeout, + retry_policy=retry_policy, + ) as span: + yield TurnSpan(span) + + async def start_span( + self, + trace_id: str, + name: str, + input: list[Any] | dict[str, Any] | BaseModel | None = None, + parent_id: str | None = None, + data: list[Any] | dict[str, Any] | BaseModel | None = None, + task_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=1), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> Span | None: + """ + Start a new span in the trace. + + Args: + trace_id (str): The trace ID for the span. + name (str): The name of the span. + input (Union[List, Dict, BaseModel]): The input for the span. + parent_id (Optional[str]): The parent span ID for the span. + data (Optional[Union[List, Dict, BaseModel]]): The data for the span. + task_id (Optional[str]): The task ID this span belongs to. + start_to_close_timeout (timedelta): The start to close timeout for the span. + heartbeat_timeout (timedelta): The heartbeat timeout for the span. + retry_policy (RetryPolicy): The retry policy for the span. + + Returns: + Span: The started span object. + """ + params = StartSpanParams( + trace_id=trace_id, + parent_id=parent_id, + name=name, + input=input, + data=data, + task_id=task_id, + ) + if in_temporal_workflow(): + try: + return await ActivityHelpers.execute_activity( + activity_name=TracingActivityName.START_SPAN, + request=params, + response_type=Span, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + except (ActivityError, TemporalTimeoutError) as err: + if is_cancelled_exception(err): + raise + workflow.logger.warning( + "Failed to start tracing span %r for trace_id=%r; continuing without tracing", + name, + trace_id, + exc_info=True, + ) + _record_temporal_span_activity_dropped("start") + return None + else: + return await self._tracing_service.start_span( + trace_id=trace_id, + name=name, + input=input, + parent_id=parent_id, + data=data, + task_id=task_id, + ) + + async def end_span( + self, + trace_id: str, + span: Span, + start_to_close_timeout: timedelta = timedelta(seconds=5), + heartbeat_timeout: timedelta = timedelta(seconds=1), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> Span: + """ + End an existing span in the trace. + + Args: + trace_id (str): The trace ID for the span. + span (Span): The span to end. + start_to_close_timeout (timedelta): The start to close timeout for the span. + heartbeat_timeout (timedelta): The heartbeat timeout for the span. + retry_policy (RetryPolicy): The retry policy for the span. + + Returns: + Span: The ended span object. + """ + params = EndSpanParams( + trace_id=trace_id, + span=span, + ) + if in_temporal_workflow(): + try: + return await ActivityHelpers.execute_activity( + activity_name=TracingActivityName.END_SPAN, + request=params, + response_type=Span, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + except (ActivityError, TemporalTimeoutError) as err: + if is_cancelled_exception(err): + raise + workflow.logger.warning( + "Failed to end tracing span %r for trace_id=%r; continuing without closing trace", + span.id, + trace_id, + exc_info=True, + ) + _record_temporal_span_activity_dropped("end") + return span + else: + return await self._tracing_service.end_span( + trace_id=trace_id, + span=span, + ) diff --git a/src/agentex/lib/adk/providers/__init__.py b/src/agentex/lib/adk/providers/__init__.py new file mode 100644 index 000000000..9167396f4 --- /dev/null +++ b/src/agentex/lib/adk/providers/__init__.py @@ -0,0 +1,9 @@ +from agentex.lib.adk.providers._modules.sgp import SGPModule +from agentex.lib.adk.providers._modules.openai import OpenAIModule +from agentex.lib.adk.providers._modules.litellm import LiteLLMModule + +openai = OpenAIModule() +litellm = LiteLLMModule() +sgp = SGPModule() + +__all__ = ["openai", "litellm", "sgp"] diff --git a/src/agentex/lib/adk/providers/_modules/__init__.py b/src/agentex/lib/adk/providers/_modules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/adk/providers/_modules/litellm.py b/src/agentex/lib/adk/providers/_modules/litellm.py new file mode 100644 index 000000000..9793d850f --- /dev/null +++ b/src/agentex/lib/adk/providers/_modules/litellm.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +from datetime import timedelta +from collections.abc import AsyncGenerator + +from temporalio.common import RetryPolicy + +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.temporal import in_temporal_workflow, workflow_now_if_in_workflow +from agentex.types.task_message import TaskMessage +from agentex.lib.types.llm_messages import LLMConfig, Completion +from agentex.lib.core.tracing.tracer import AsyncTracer +from agentex.lib.adk.utils._modules.client import create_async_agentex_client +from agentex.lib.core.services.adk.streaming import StreamingService +from agentex.lib.core.adapters.llm.adapter_litellm import LiteLLMGateway +from agentex.lib.core.adapters.streams.adapter_redis import RedisStreamRepository +from agentex.lib.core.services.adk.providers.litellm import LiteLLMService +from agentex.lib.core.temporal.activities.activity_helpers import ActivityHelpers +from agentex.lib.core.temporal.activities.adk.providers.litellm_activities import ( + LiteLLMActivityName, + ChatCompletionParams, + ChatCompletionAutoSendParams, + ChatCompletionStreamAutoSendParams, +) + +logger = make_logger(__name__) + +# Default retry policy for all LiteLLM operations +# Retries with exponential backoff: 1s, 2s, 4s, ... up to 30s between attempts +DEFAULT_RETRY_POLICY = RetryPolicy( + maximum_attempts=3, + initial_interval=timedelta(seconds=1), + backoff_coefficient=2.0, + maximum_interval=timedelta(seconds=30), +) + + +class LiteLLMModule: + """ + Module for managing LiteLLM agent operations in Agentex. + Provides high-level methods for chat completion, streaming. + """ + + def __init__( + self, + litellm_service: LiteLLMService | None = None, + ): + if litellm_service is None: + # Create default service + agentex_client = create_async_agentex_client() + stream_repository = RedisStreamRepository() + streaming_service = StreamingService( + agentex_client=agentex_client, + stream_repository=stream_repository, + ) + litellm_gateway = LiteLLMGateway() + tracer = AsyncTracer(agentex_client) + self._litellm_service = LiteLLMService( + agentex_client=agentex_client, + llm_gateway=litellm_gateway, + streaming_service=streaming_service, + tracer=tracer, + ) + else: + self._litellm_service = litellm_service + + async def chat_completion( + self, + llm_config: LLMConfig, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=120), + heartbeat_timeout: timedelta = timedelta(seconds=120), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> Completion: + """ + Perform a chat completion using LiteLLM. + + Args: + llm_config (LLMConfig): The configuration for the LLM. + trace_id (Optional[str]): The trace ID for tracing. + parent_span_id (Optional[str]): The parent span ID for tracing. + start_to_close_timeout (timedelta): The start to close timeout. + heartbeat_timeout (timedelta): The heartbeat timeout. + retry_policy (RetryPolicy): The retry policy. + + Returns: + Completion: An OpenAI compatible Completion object + """ + if in_temporal_workflow(): + params = ChatCompletionParams(trace_id=trace_id, parent_span_id=parent_span_id, llm_config=llm_config) + return await ActivityHelpers.execute_activity( + activity_name=LiteLLMActivityName.CHAT_COMPLETION, + request=params, + response_type=Completion, + start_to_close_timeout=start_to_close_timeout, + heartbeat_timeout=heartbeat_timeout, + retry_policy=retry_policy, + ) + else: + return await self._litellm_service.chat_completion( + llm_config=llm_config, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + + async def chat_completion_auto_send( + self, + task_id: str, + llm_config: LLMConfig, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=120), + heartbeat_timeout: timedelta = timedelta(seconds=120), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> TaskMessage | None: + """ + Chat completion with automatic TaskMessage creation. + + Args: + task_id (str): The ID of the task. + llm_config (LLMConfig): The configuration for the LLM (must have stream=False). + trace_id (Optional[str]): The trace ID for tracing. + parent_span_id (Optional[str]): The parent span ID for tracing. + start_to_close_timeout (timedelta): The start to close timeout. + heartbeat_timeout (timedelta): The heartbeat timeout. + retry_policy (RetryPolicy): The retry policy. + + Returns: + TaskMessage: The final TaskMessage + """ + if in_temporal_workflow(): + # Use streaming activity with stream=False for non-streaming auto-send + params = ChatCompletionAutoSendParams( + trace_id=trace_id, + parent_span_id=parent_span_id, + task_id=task_id, + llm_config=llm_config, + created_at=workflow_now_if_in_workflow(), + ) + return await ActivityHelpers.execute_activity( + activity_name=LiteLLMActivityName.CHAT_COMPLETION_AUTO_SEND, + request=params, + response_type=TaskMessage, + start_to_close_timeout=start_to_close_timeout, + heartbeat_timeout=heartbeat_timeout, + retry_policy=retry_policy, + ) + else: + return await self._litellm_service.chat_completion_auto_send( + task_id=task_id, + llm_config=llm_config, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + + async def chat_completion_stream( + self, + llm_config: LLMConfig, + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> AsyncGenerator[Completion, None]: + """ + Stream chat completion chunks using LiteLLM. + + DEFAULT: Returns raw streaming chunks for manual handling. + + NOTE: This method does NOT work in Temporal workflows! + Temporal activities cannot return generators. Use chat_completion_stream_auto_send() instead. + + Args: + llm_config (LLMConfig): The configuration for the LLM (must have stream=True). + trace_id (Optional[str]): The trace ID for tracing. + parent_span_id (Optional[str]): The parent span ID for tracing. + start_to_close_timeout (timedelta): The start to close timeout. + heartbeat_timeout (timedelta): The heartbeat timeout. + retry_policy (RetryPolicy): The retry policy. + + Returns: + AsyncGenerator[Completion, None]: Generator yielding completion chunks + + Raises: + ValueError: If called from within a Temporal workflow + """ + # Delegate to service - it handles temporal workflow checks + async for chunk in self._litellm_service.chat_completion_stream( + llm_config=llm_config, + trace_id=trace_id, + parent_span_id=parent_span_id, + ): + yield chunk + + async def chat_completion_stream_auto_send( + self, + task_id: str, + llm_config: LLMConfig, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=120), + heartbeat_timeout: timedelta = timedelta(seconds=120), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> TaskMessage | None: + """ + Stream chat completion with automatic TaskMessage creation and streaming. + + Args: + task_id (str): The ID of the task to run the agent for. + llm_config (LLMConfig): The configuration for the LLM (must have stream=True). + trace_id (Optional[str]): The trace ID for tracing. + parent_span_id (Optional[str]): The parent span ID for tracing. + start_to_close_timeout (timedelta): The start to close timeout. + heartbeat_timeout (timedelta): The heartbeat timeout. + retry_policy (RetryPolicy): The retry policy. + + Returns: + TaskMessage: The final TaskMessage after streaming is complete + """ + if in_temporal_workflow(): + params = ChatCompletionStreamAutoSendParams( + trace_id=trace_id, + parent_span_id=parent_span_id, + task_id=task_id, + llm_config=llm_config, + created_at=workflow_now_if_in_workflow(), + ) + return await ActivityHelpers.execute_activity( + activity_name=LiteLLMActivityName.CHAT_COMPLETION_STREAM_AUTO_SEND, + request=params, + response_type=TaskMessage, + start_to_close_timeout=start_to_close_timeout, + heartbeat_timeout=heartbeat_timeout, + retry_policy=retry_policy, + ) + else: + return await self._litellm_service.chat_completion_stream_auto_send( + task_id=task_id, + llm_config=llm_config, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) diff --git a/src/agentex/lib/adk/providers/_modules/openai.py b/src/agentex/lib/adk/providers/_modules/openai.py new file mode 100644 index 000000000..418c487e4 --- /dev/null +++ b/src/agentex/lib/adk/providers/_modules/openai.py @@ -0,0 +1,514 @@ +from __future__ import annotations + +import sys +from typing import Any, Literal +from datetime import timedelta + +from mcp import StdioServerParameters +from agents import Agent, RunResult, RunResultStreaming +from agents.tool import Tool +from agents.agent import StopAtTools, ToolsToFinalOutputFunction +from agents.guardrail import InputGuardrail, OutputGuardrail +from temporalio.common import RetryPolicy +from agents.agent_output import AgentOutputSchemaBase +from agents.model_settings import ModelSettings + +# Use warnings.deprecated in Python 3.13+, typing_extensions.deprecated for older versions +if sys.version_info >= (3, 13): + from warnings import deprecated +else: + from typing_extensions import deprecated + +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.temporal import in_temporal_workflow, workflow_now_if_in_workflow +from agentex.lib.core.tracing.tracer import AsyncTracer +from agentex.lib.types.agent_results import ( + SerializableRunResult, + SerializableRunResultStreaming, +) +from agentex.lib.adk.utils._modules.client import create_async_agentex_client +from agentex.lib.core.services.adk.streaming import StreamingService +from agentex.lib.core.services.adk.providers.openai import OpenAIService +from agentex.lib.core.adapters.streams.adapter_redis import RedisStreamRepository +from agentex.lib.core.temporal.activities.activity_helpers import ActivityHelpers +from agentex.lib.core.temporal.activities.adk.providers.openai_activities import ( + RunAgentParams, + OpenAIActivityName, + RunAgentAutoSendParams, + RunAgentStreamedAutoSendParams, +) + +logger = make_logger(__name__) + +# Default retry policy for all OpenAI operations +DEFAULT_RETRY_POLICY = RetryPolicy(maximum_attempts=1) + + +class OpenAIModule: + """ + Module for managing OpenAI agent operations in Agentex. + Provides high-level methods for running agents with and without streaming. + """ + + def __init__( + self, + openai_service: OpenAIService | None = None, + ): + if openai_service is None: + # Create default service + agentex_client = create_async_agentex_client() + stream_repository = RedisStreamRepository() + streaming_service = StreamingService( + agentex_client=agentex_client, + stream_repository=stream_repository, + ) + tracer = AsyncTracer(agentex_client) + self._openai_service = OpenAIService( + agentex_client=agentex_client, + streaming_service=streaming_service, + tracer=tracer, + ) + else: + self._openai_service = openai_service + + async def run_agent( + self, + input_list: list[dict[str, Any]], + agent_name: str, + agent_instructions: str, + mcp_server_params: list[StdioServerParameters] | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=600), + heartbeat_timeout: timedelta = timedelta(seconds=600), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + handoff_description: str | None = None, + handoffs: list[Agent] | None = None, + model: str | None = None, + model_settings: ModelSettings | None = None, + tools: list[Tool] | None = None, + output_type: type[Any] | AgentOutputSchemaBase | None = None, + tool_use_behavior: ( + Literal["run_llm_again", "stop_on_first_tool"] | StopAtTools | ToolsToFinalOutputFunction + ) = "run_llm_again", + mcp_timeout_seconds: int | None = None, + input_guardrails: list[InputGuardrail] | None = None, + output_guardrails: list[OutputGuardrail] | None = None, + max_turns: int | None = None, + previous_response_id: str | None = None, + ) -> SerializableRunResult | RunResult: + """ + Run an agent without streaming or TaskMessage creation. + + DEFAULT: No TaskMessage creation, returns only the result. + + Args: + input_list: List of input data for the agent. + mcp_server_params: MCP server parameters for the agent. + agent_name: The name of the agent to run. + agent_instructions: Instructions for the agent. + trace_id: Optional trace ID for tracing. + parent_span_id: Optional parent span for tracing. + start_to_close_timeout: Maximum time allowed for the operation. + heartbeat_timeout: Maximum time between heartbeats. + retry_policy: Policy for retrying failed operations. + handoff_description: Optional description of the handoff. + handoffs: Optional list of handoffs. + model: Optional model to use. + model_settings: Optional model settings. + tools: Optional list of tools. + output_type: Optional output type. + tool_use_behavior: Optional tool use behavior. + mcp_timeout_seconds: Optional param to set the timeout threshold for the MCP servers. Defaults to 5 seconds. + input_guardrails: Optional list of input guardrails to run on initial user input. + output_guardrails: Optional list of output guardrails to run on final agent output. + max_turns: Maximum number of turns the agent can take. Uses Runner's default if None. + previous_response_id: Optional previous response ID for conversation continuity. + + Returns: + Union[SerializableRunResult, RunResult]: SerializableRunResult when in Temporal, RunResult otherwise. + """ + # Default to empty list if not provided + if mcp_server_params is None: + mcp_server_params = [] + + if in_temporal_workflow(): + params = RunAgentParams( + trace_id=trace_id, + parent_span_id=parent_span_id, + input_list=input_list, + mcp_server_params=mcp_server_params, + agent_name=agent_name, + agent_instructions=agent_instructions, + handoff_description=handoff_description, + handoffs=handoffs, # type: ignore[arg-type] + model=model, + model_settings=model_settings, # type: ignore[arg-type] + tools=tools, # type: ignore[arg-type] + output_type=output_type, + tool_use_behavior=tool_use_behavior, # type: ignore[arg-type] + mcp_timeout_seconds=mcp_timeout_seconds, + input_guardrails=input_guardrails, # type: ignore[arg-type] + output_guardrails=output_guardrails, # type: ignore[arg-type] + max_turns=max_turns, + previous_response_id=previous_response_id, + ) + return await ActivityHelpers.execute_activity( + activity_name=OpenAIActivityName.RUN_AGENT, + request=params, + response_type=SerializableRunResult, + start_to_close_timeout=start_to_close_timeout, + heartbeat_timeout=heartbeat_timeout, + retry_policy=retry_policy, + ) + else: + return await self._openai_service.run_agent( + input_list=input_list, + mcp_server_params=mcp_server_params, + agent_name=agent_name, + agent_instructions=agent_instructions, + trace_id=trace_id, + parent_span_id=parent_span_id, + handoff_description=handoff_description, + handoffs=handoffs, + model=model, + model_settings=model_settings, + tools=tools, + output_type=output_type, + tool_use_behavior=tool_use_behavior, + mcp_timeout_seconds=mcp_timeout_seconds, + input_guardrails=input_guardrails, + output_guardrails=output_guardrails, + max_turns=max_turns, + previous_response_id=previous_response_id, + ) + + async def run_agent_auto_send( + self, + task_id: str, + input_list: list[dict[str, Any]], + agent_name: str, + agent_instructions: str, + mcp_server_params: list[StdioServerParameters] | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=600), + heartbeat_timeout: timedelta = timedelta(seconds=600), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + handoff_description: str | None = None, + handoffs: list[Agent] | None = None, + model: str | None = None, + model_settings: ModelSettings | None = None, + tools: list[Tool] | None = None, + output_type: type[Any] | AgentOutputSchemaBase | None = None, + tool_use_behavior: ( + Literal["run_llm_again", "stop_on_first_tool"] | StopAtTools | ToolsToFinalOutputFunction + ) = "run_llm_again", + mcp_timeout_seconds: int | None = None, + input_guardrails: list[InputGuardrail] | None = None, + output_guardrails: list[OutputGuardrail] | None = None, + max_turns: int | None = None, + previous_response_id: str | None = None, + ) -> SerializableRunResult | RunResult: + """ + Run an agent with automatic TaskMessage creation. + + Args: + task_id: The ID of the task to run the agent for. + input_list: List of input data for the agent. + mcp_server_params: MCP server parameters for the agent. + agent_name: The name of the agent to run. + agent_instructions: Instructions for the agent. + trace_id: Optional trace ID for tracing. + parent_span_id: Optional parent span for tracing. + start_to_close_timeout: Maximum time allowed for the operation. + heartbeat_timeout: Maximum time between heartbeats. + retry_policy: Policy for retrying failed operations. + handoff_description: Optional description of the handoff. + handoffs: Optional list of handoffs. + model: Optional model to use. + model_settings: Optional model settings. + tools: Optional list of tools. + output_type: Optional output type. + tool_use_behavior: Optional tool use behavior. + mcp_timeout_seconds: Optional param to set the timeout threshold for the MCP servers. Defaults to 5 seconds. + input_guardrails: Optional list of input guardrails to run on initial user input. + output_guardrails: Optional list of output guardrails to run on final agent output. + max_turns: Maximum number of turns the agent can take. Uses Runner's default if None. + previous_response_id: Optional previous response ID for conversation continuity. + + Returns: + Union[SerializableRunResult, RunResult]: SerializableRunResult when in Temporal, RunResult otherwise. + """ + # Default to empty list if not provided + if mcp_server_params is None: + mcp_server_params = [] + + if in_temporal_workflow(): + params = RunAgentAutoSendParams( + trace_id=trace_id, + parent_span_id=parent_span_id, + task_id=task_id, + input_list=input_list, + mcp_server_params=mcp_server_params, + agent_name=agent_name, + agent_instructions=agent_instructions, + handoff_description=handoff_description, + handoffs=handoffs, # type: ignore[arg-type] + model=model, + model_settings=model_settings, # type: ignore[arg-type] + tools=tools, # type: ignore[arg-type] + output_type=output_type, + tool_use_behavior=tool_use_behavior, # type: ignore[arg-type] + mcp_timeout_seconds=mcp_timeout_seconds, + input_guardrails=input_guardrails, # type: ignore[arg-type] + output_guardrails=output_guardrails, # type: ignore[arg-type] + max_turns=max_turns, + previous_response_id=previous_response_id, + created_at=workflow_now_if_in_workflow(), + ) + return await ActivityHelpers.execute_activity( + activity_name=OpenAIActivityName.RUN_AGENT_AUTO_SEND, + request=params, + response_type=SerializableRunResult, + start_to_close_timeout=start_to_close_timeout, + heartbeat_timeout=heartbeat_timeout, + retry_policy=retry_policy, + ) + else: + return await self._openai_service.run_agent_auto_send( + task_id=task_id, + input_list=input_list, + mcp_server_params=mcp_server_params, + agent_name=agent_name, + agent_instructions=agent_instructions, + trace_id=trace_id, + parent_span_id=parent_span_id, + handoff_description=handoff_description, + handoffs=handoffs, + model=model, + model_settings=model_settings, + tools=tools, + output_type=output_type, + tool_use_behavior=tool_use_behavior, + mcp_timeout_seconds=mcp_timeout_seconds, + input_guardrails=input_guardrails, + output_guardrails=output_guardrails, + max_turns=max_turns, + previous_response_id=previous_response_id, + ) + + async def run_agent_streamed( + self, + input_list: list[dict[str, Any]], + agent_name: str, + agent_instructions: str, + mcp_server_params: list[StdioServerParameters] | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + handoff_description: str | None = None, + handoffs: list[Agent] | None = None, + model: str | None = None, + model_settings: ModelSettings | None = None, + tools: list[Tool] | None = None, + output_type: type[Any] | AgentOutputSchemaBase | None = None, + tool_use_behavior: ( + Literal["run_llm_again", "stop_on_first_tool"] | StopAtTools | ToolsToFinalOutputFunction + ) = "run_llm_again", + mcp_timeout_seconds: int | None = None, + input_guardrails: list[InputGuardrail] | None = None, + output_guardrails: list[OutputGuardrail] | None = None, + max_turns: int | None = None, + previous_response_id: str | None = None, + ) -> RunResultStreaming: + """ + Run an agent with streaming enabled but no TaskMessage creation. + + DEFAULT: No TaskMessage creation, returns only the result. + + NOTE: This method does NOT work in Temporal workflows! + Use run_agent_streamed_auto_send() instead for Temporal workflows. + + Args: + input_list: List of input data for the agent. + mcp_server_params: MCP server parameters for the agent. + agent_name: The name of the agent to run. + agent_instructions: Instructions for the agent. + trace_id: Optional trace ID for tracing. + parent_span_id: Optional parent span for tracing. + start_to_close_timeout: Maximum time allowed for the operation. + heartbeat_timeout: Maximum time between heartbeats. + retry_policy: Policy for retrying failed operations. + handoff_description: Optional description of the handoff. + handoffs: Optional list of handoffs. + model: Optional model to use. + model_settings: Optional model settings. + tools: Optional list of tools. + output_type: Optional output type. + tool_use_behavior: Optional tool use behavior. + mcp_timeout_seconds: Optional param to set the timeout threshold for the MCP servers. Defaults to 5 seconds. + input_guardrails: Optional list of input guardrails to run on initial user input. + output_guardrails: Optional list of output guardrails to run on final agent output. + max_turns: Maximum number of turns the agent can take. Uses Runner's default if None. + previous_response_id: Optional previous response ID for conversation continuity. + + Returns: + RunResultStreaming: The result of the agent run with streaming. + + Raises: + ValueError: If called from within a Temporal workflow + """ + # Default to empty list if not provided + if mcp_server_params is None: + mcp_server_params = [] + + # Temporal workflows should use the auto_send variant + if in_temporal_workflow(): + raise ValueError( + "run_agent_streamed() cannot be used in Temporal workflows. " + "Use run_agent_streamed_auto_send() instead, which properly handles " + "TaskMessage creation and streaming through the streaming service." + ) + + return await self._openai_service.run_agent_streamed( + input_list=input_list, + mcp_server_params=mcp_server_params, + agent_name=agent_name, + agent_instructions=agent_instructions, + trace_id=trace_id, + parent_span_id=parent_span_id, + handoff_description=handoff_description, + handoffs=handoffs, + model=model, + model_settings=model_settings, + tools=tools, + output_type=output_type, + tool_use_behavior=tool_use_behavior, + mcp_timeout_seconds=mcp_timeout_seconds, + input_guardrails=input_guardrails, + output_guardrails=output_guardrails, + max_turns=max_turns, + previous_response_id=previous_response_id, + ) + + @deprecated( + "Use the OpenAI Agents SDK integration with Temporal instead. " + "See examples in tutorials/10_async/10_temporal/ for migration guidance." + ) + async def run_agent_streamed_auto_send( + self, + task_id: str, + input_list: list[dict[str, Any]], + agent_name: str, + agent_instructions: str, + mcp_server_params: list[StdioServerParameters] | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=600), + heartbeat_timeout: timedelta = timedelta(seconds=600), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + handoff_description: str | None = None, + handoffs: list[Agent] | None = None, + model: str | None = None, + model_settings: ModelSettings | None = None, + tools: list[Tool] | None = None, + output_type: type[Any] | AgentOutputSchemaBase | None = None, + tool_use_behavior: ( + Literal["run_llm_again", "stop_on_first_tool"] | StopAtTools | ToolsToFinalOutputFunction + ) = "run_llm_again", + mcp_timeout_seconds: int | None = None, + input_guardrails: list[InputGuardrail] | None = None, + output_guardrails: list[OutputGuardrail] | None = None, + max_turns: int | None = None, + previous_response_id: str | None = None, + ) -> SerializableRunResultStreaming | RunResultStreaming: + """ + Run an agent with streaming enabled and automatic TaskMessage creation. + + .. deprecated:: + Use the OpenAI Agents SDK integration with Temporal instead. + See examples in tutorials/10_async/10_temporal/ for migration guidance. + + Args: + task_id: The ID of the task to run the agent for. + input_list: List of input data for the agent. + mcp_server_params: MCP server parameters for the agent. + agent_name: The name of the agent to run. + agent_instructions: Instructions for the agent. + trace_id: Optional trace ID for tracing. + parent_span_id: Optional parent span for tracing. + start_to_close_timeout: Maximum time allowed for the operation. + heartbeat_timeout: Maximum time between heartbeats. + retry_policy: Policy for retrying failed operations. + handoff_description: Optional description of the handoff. + handoffs: Optional list of handoffs. + model: Optional model to use. + model_settings: Optional model settings. + tools: Optional list of tools. + input_guardrails: Optional list of input guardrails to run on initial user input. + output_guardrails: Optional list of output guardrails to run on final agent output. + output_type: Optional output type. + tool_use_behavior: Optional tool use behavior. + mcp_timeout_seconds: Optional param to set the timeout threshold for the MCP servers. Defaults to 5 seconds. + max_turns: Maximum number of turns the agent can take. Uses Runner's default if None. + previous_response_id: Optional previous response ID for conversation continuity. + + Returns: + Union[SerializableRunResultStreaming, RunResultStreaming]: SerializableRunResultStreaming when in Temporal, RunResultStreaming otherwise. + """ + # Default to empty list if not provided + if mcp_server_params is None: + mcp_server_params = [] + + if in_temporal_workflow(): + params = RunAgentStreamedAutoSendParams( + trace_id=trace_id, + parent_span_id=parent_span_id, + task_id=task_id, + input_list=input_list, + mcp_server_params=mcp_server_params, + agent_name=agent_name, + agent_instructions=agent_instructions, + handoff_description=handoff_description, + handoffs=handoffs, + model=model, + model_settings=model_settings, + tools=tools, + output_type=output_type, + tool_use_behavior=tool_use_behavior, + mcp_timeout_seconds=mcp_timeout_seconds, + input_guardrails=input_guardrails, + output_guardrails=output_guardrails, + max_turns=max_turns, + created_at=workflow_now_if_in_workflow(), + ) + return await ActivityHelpers.execute_activity( + activity_name=OpenAIActivityName.RUN_AGENT_STREAMED_AUTO_SEND, + request=params, + response_type=SerializableRunResultStreaming, + start_to_close_timeout=start_to_close_timeout, + heartbeat_timeout=heartbeat_timeout, + retry_policy=retry_policy, + ) + else: + return await self._openai_service.run_agent_streamed_auto_send( + task_id=task_id, + input_list=input_list, + mcp_server_params=mcp_server_params, + agent_name=agent_name, + agent_instructions=agent_instructions, + trace_id=trace_id, + parent_span_id=parent_span_id, + handoff_description=handoff_description, + handoffs=handoffs, + model=model, + model_settings=model_settings, + tools=tools, + output_type=output_type, + tool_use_behavior=tool_use_behavior, + mcp_timeout_seconds=mcp_timeout_seconds, + input_guardrails=input_guardrails, + output_guardrails=output_guardrails, + max_turns=max_turns, + previous_response_id=previous_response_id, + ) diff --git a/src/agentex/lib/adk/providers/_modules/openai_turn.py b/src/agentex/lib/adk/providers/_modules/openai_turn.py new file mode 100644 index 000000000..320642dfc --- /dev/null +++ b/src/agentex/lib/adk/providers/_modules/openai_turn.py @@ -0,0 +1,12 @@ +"""Back-compat shim: ``OpenAITurn`` and ``openai_usage_to_turn_usage`` now live +in ``agentex.lib.adk._modules._openai_turn``. + +Existing importers of +``agentex.lib.adk.providers._modules.openai_turn.{OpenAITurn,openai_usage_to_turn_usage}`` +keep working. +""" + +from agentex.lib.adk._modules._openai_turn import ( # noqa: F401 + OpenAITurn, + openai_usage_to_turn_usage, +) diff --git a/src/agentex/lib/adk/providers/_modules/sgp.py b/src/agentex/lib/adk/providers/_modules/sgp.py new file mode 100644 index 000000000..fab765b76 --- /dev/null +++ b/src/agentex/lib/adk/providers/_modules/sgp.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from datetime import timedelta + +from scale_gp import SGPClient, SGPClientError +from temporalio.common import RetryPolicy + +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.temporal import in_temporal_workflow +from agentex.lib.core.tracing.tracer import AsyncTracer +from agentex.lib.adk.utils._modules.client import create_async_agentex_client +from agentex.lib.core.services.adk.providers.sgp import SGPService +from agentex.lib.core.temporal.activities.activity_helpers import ActivityHelpers +from agentex.lib.core.temporal.activities.adk.providers.sgp_activities import ( + SGPActivityName, + DownloadFileParams, + FileContentResponse, +) + +logger = make_logger(__name__) + +DEFAULT_RETRY_POLICY = RetryPolicy(maximum_attempts=1) + + +class SGPModule: + """ + Module for managing SGP agent operations in Agentex. + Provides high-level methods for chat completion, streaming, and message classification. + """ + + def __init__( + self, + sgp_service: SGPService | None = None, + ): + if sgp_service is None: + try: + sgp_client = SGPClient() + agentex_client = create_async_agentex_client() + tracer = AsyncTracer(agentex_client) + self._sgp_service = SGPService(sgp_client=sgp_client, tracer=tracer) + except SGPClientError: + self._sgp_service = None + else: + self._sgp_service = sgp_service + + async def download_file_content( + self, + params: DownloadFileParams, + start_to_close_timeout: timedelta = timedelta(seconds=30), + heartbeat_timeout: timedelta = timedelta(seconds=30), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> FileContentResponse: + """ + Download the content of a file from SGP. + + Args: + params (DownloadFileParams): The parameters for the download file content activity. + start_to_close_timeout (timedelta): The start to close timeout. + heartbeat_timeout (timedelta): The heartbeat timeout. + retry_policy (RetryPolicy): The retry policy. + + Returns: + FileContentResponse: The content of the file + """ + if self._sgp_service is None: + raise ValueError( + "SGP activities are disabled because the SGP client could not be initialized. Please check that the SGP_API_KEY environment variable is set." + ) + + params = DownloadFileParams( + file_id=params.file_id, + filename=params.filename, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=SGPActivityName.DOWNLOAD_FILE_CONTENT, + request=params, + response_type=FileContentResponse, + start_to_close_timeout=start_to_close_timeout, + heartbeat_timeout=heartbeat_timeout, + retry_policy=retry_policy, + ) + else: + return await self._sgp_service.download_file_content( + file_id=params.file_id, + filename=params.filename, + ) diff --git a/src/agentex/lib/adk/providers/_modules/sync_provider.py b/src/agentex/lib/adk/providers/_modules/sync_provider.py new file mode 100644 index 000000000..120915eec --- /dev/null +++ b/src/agentex/lib/adk/providers/_modules/sync_provider.py @@ -0,0 +1,394 @@ +"""Simple OpenAI Provider wrapper that adds logging to demonstrate streaming is working.""" + +from __future__ import annotations + +from typing import Any, Union, Optional, override + +from agents import ( + Tool, + Model, + Handoff, + ModelTracing, + ModelResponse, + ModelSettings, + TResponseInputItem, + AgentOutputSchemaBase, +) +from agents.models.openai_provider import OpenAIProvider + +from agentex import AsyncAgentex +from agentex.lib.utils.logging import make_logger +from agentex.lib.core.tracing.tracer import AsyncTracer +from agentex.lib.core.tracing.lineage import merge_refs_into_data, resolve_refs_from_items + +logger = make_logger(__name__) + + +def _serialize_item(item: Any) -> dict[str, Any]: + """ + Universal serializer for any item type from OpenAI Agents SDK. + + Uses model_dump() for Pydantic models, otherwise extracts attributes manually. + Filters out internal Pydantic fields that can't be serialized. + """ + if hasattr(item, "model_dump"): + # Pydantic model - use model_dump for proper serialization + try: + return item.model_dump(mode="json", exclude_unset=True) + except Exception: + # Fallback to dict conversion + return dict(item) if hasattr(item, "__iter__") else {} + else: + # Not a Pydantic model - extract attributes manually + item_dict = {} + for attr_name in dir(item): + if not attr_name.startswith("_") and attr_name not in ( + "model_fields", + "model_config", + "model_computed_fields", + ): + try: + attr_value = getattr(item, attr_name, None) + # Skip methods and None values + if attr_value is not None and not callable(attr_value): + # Convert to JSON-serializable format + if hasattr(attr_value, "model_dump"): + item_dict[attr_name] = attr_value.model_dump() + elif isinstance(attr_value, (str, int, float, bool, list, dict)): + item_dict[attr_name] = attr_value + else: + item_dict[attr_name] = str(attr_value) + except Exception: + # Skip attributes that can't be accessed + pass + return item_dict + + +class SyncStreamingModel(Model): + """Simple model wrapper that adds logging to stream_response and supports tracing. + + .. deprecated:: + Prefer the unified harness surface for new OpenAI Agents integrations: + wrap a ``Runner.run_streamed`` result in + ``agentex.lib.adk._modules._openai_turn.OpenAITurn`` and drive + delivery + tracing through ``UnifiedEmitter`` (see the + ``050_openai_agents`` / ``120_openai_agents`` tutorials). This + per-model tracing wrapper predates the harness and is + retained only for backwards compatibility; it will be removed in a + future release. No runtime warning is emitted. + """ + + def __init__( + self, + original_model: Model, + trace_id: str | None = None, + parent_span_id: str | None = None, + tracer: AsyncTracer | None = None, + ): + """Initialize with the original OpenAI model to wrap. + Args: + original_model: The OpenAI model instance to wrap + trace_id: Optional trace ID for distributed tracing + parent_span_id: Optional parent span ID for tracing hierarchy + tracer: Optional AsyncTracer for distributed tracing + """ + self.original_model = original_model + self.trace_id = trace_id + self.parent_span_id = parent_span_id + self.tracer = tracer + + @override + async def get_response( + self, + system_instructions: Optional[str], + input: Union[str, list[TResponseInputItem]], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: Optional[AgentOutputSchemaBase], + handoffs: list[Handoff], + tracing: ModelTracing, + *, + previous_response_id: Optional[str] = None, + conversation_id: Optional[str] = None, + prompt: Any = None, + ) -> ModelResponse: + """Pass through to the original model's get_response with tracing support.""" + + # Wrap the request in a tracing span if tracer is available + if self.tracer and self.trace_id: + trace = self.tracer.trace(self.trace_id) + async with trace.span( + parent_id=self.parent_span_id, + name="run_agent", + input={ + "system_instructions": system_instructions, + "input": input, + "model_settings": str(model_settings) if model_settings else None, + "tools": [tool.name for tool in tools] if tools else [], + "output_schema": str(output_schema) if output_schema else None, + "handoffs": [str(h) for h in handoffs] if handoffs else [], + "previous_response_id": previous_response_id, + }, + ) as span: + # Build kwargs, excluding conversation_id if not supported + kwargs = { + "system_instructions": system_instructions, + "input": input, + "model_settings": model_settings, + "tools": tools, + "output_schema": output_schema, + "handoffs": handoffs, + "tracing": tracing, + "previous_response_id": previous_response_id, + "prompt": prompt, + } + + # Only add conversation_id if the model supports it + if hasattr(self.original_model, "supports_conversation_id"): + kwargs["conversation_id"] = conversation_id + + response = await self.original_model.get_response(**kwargs) + + # Set span output with structured data + if span and response: + new_items = [] + final_output = None + + # Extract final output text from response + response_final_output = getattr(response, "final_output", None) + if response_final_output: + final_output = response_final_output + + # Extract items from the response output + response_output = getattr(response, "output", None) + if response_output: + output_items = response_output if isinstance(response_output, list) else [response_output] + + for item in output_items: + try: + item_dict = _serialize_item(item) + if item_dict: + new_items.append(item_dict) + + # Extract final_output from message type if available + if item_dict.get("type") == "message" and not final_output: + content = item_dict.get("content", []) + if content and isinstance(content, list): + for content_part in content: + if isinstance(content_part, dict) and "text" in content_part: + final_output = content_part["text"] + break + except Exception as e: + logger.warning(f"Failed to serialize item in get_response: {e}") + continue + + span.output = { + "new_items": new_items, + "final_output": final_output, + } + lineage_refs = resolve_refs_from_items(new_items) + if lineage_refs: + span.data = merge_refs_into_data(span.data, lineage_refs) + + return response + else: + # No tracing, just call normally + # Build kwargs, excluding conversation_id if not supported + kwargs = { + "system_instructions": system_instructions, + "input": input, + "model_settings": model_settings, + "tools": tools, + "output_schema": output_schema, + "handoffs": handoffs, + "tracing": tracing, + "previous_response_id": previous_response_id, + "prompt": prompt, + } + + # Only add conversation_id if the model supports it + if hasattr(self.original_model, "supports_conversation_id"): + kwargs["conversation_id"] = conversation_id + + return await self.original_model.get_response(**kwargs) + + @override + async def stream_response( + self, + system_instructions: Optional[str], + input: Union[str, list[TResponseInputItem]], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: Optional[AgentOutputSchemaBase], + handoffs: list[Handoff], + tracing: ModelTracing, + *, + previous_response_id: Optional[str] = None, + conversation_id: Optional[str] = None, + prompt: Any = None, + ): # Return type is generic AsyncIterator for flexibility + """Wrap the original model's stream_response and pass through OpenAI events. + This method passes through the OpenAI stream events from the underlying model. + The conversion to AgentEx types happens in the ACP layer. + """ + + # Wrap the streaming in a tracing span if tracer is available + if self.tracer and self.trace_id: + trace = self.tracer.trace(self.trace_id) + + # Manually start the span instead of using context manager + span = await trace.start_span( + parent_id=self.parent_span_id, + name="run_agent_streamed", + input={ + "system_instructions": system_instructions, + "input": input, + "model_settings": str(model_settings) if model_settings else None, + "tools": [tool.name for tool in tools] if tools else [], + "output_schema": str(output_schema) if output_schema else None, + "handoffs": [str(h) for h in handoffs] if handoffs else [], + "previous_response_id": previous_response_id, + }, + ) + + try: + # Get the stream from the original model + stream_kwargs = { + "system_instructions": system_instructions, + "input": input, + "model_settings": model_settings, + "tools": tools, + "output_schema": output_schema, + "handoffs": handoffs, + "tracing": tracing, + "previous_response_id": previous_response_id, + "prompt": prompt, + } + + # Only add conversation_id if the model supports it + if hasattr(self.original_model, "supports_conversation_id"): + stream_kwargs["conversation_id"] = conversation_id + + # Get the stream response from the original model and yield each event + stream_response = self.original_model.stream_response(**stream_kwargs) + + # Pass through each event from the original stream and track items + new_items = [] + final_response_text = "" + + async for event in stream_response: + event_type = getattr(event, "type", "no-type") + + # Handle response.output_item.done events which contain completed items + if event_type == "response.output_item.done": + item = getattr(event, "item", None) + if item is not None: + try: + item_dict = _serialize_item(item) + if item_dict: + new_items.append(item_dict) + + # Update final_response_text from message type if available + if item_dict.get("type") == "message": + content = item_dict.get("content", []) + if content and isinstance(content, list): + for content_part in content: + if isinstance(content_part, dict) and "text" in content_part: + final_response_text = content_part["text"] + break + except Exception as e: + logger.warning(f"Failed to serialize item in stream_response: {e}") + continue + + yield event + + # Set span output with structured data including tool calls and final response + span.output = { + "new_items": new_items, + "final_output": final_response_text if final_response_text else None, + } + lineage_refs = resolve_refs_from_items(new_items) + if lineage_refs: + span.data = merge_refs_into_data(span.data, lineage_refs) + finally: + # End the span after all events have been yielded + await trace.end_span(span) + else: + # No tracing, just stream normally + # Get the stream from the original model + stream_kwargs = { + "system_instructions": system_instructions, + "input": input, + "model_settings": model_settings, + "tools": tools, + "output_schema": output_schema, + "handoffs": handoffs, + "tracing": tracing, + "previous_response_id": previous_response_id, + "prompt": prompt, + } + + # Only add conversation_id if the model supports it + if hasattr(self.original_model, "supports_conversation_id"): + stream_kwargs["conversation_id"] = conversation_id + + # Get the stream response from the original model and yield each event + stream_response = self.original_model.stream_response(**stream_kwargs) + + # Pass through each event from the original stream + async for event in stream_response: + yield event + + +class SyncStreamingProvider(OpenAIProvider): + """Simple OpenAI provider wrapper that adds logging to streaming and supports tracing. + + .. deprecated:: + Prefer the unified harness surface for new OpenAI Agents integrations + (see :class:`SyncStreamingModel` and the ``OpenAITurn`` + + ``UnifiedEmitter`` pattern). This provider wrapper predates the harness + and is retained only for backwards compatibility; it will be removed in + a future release. No runtime warning is emitted. + """ + + def __init__(self, trace_id: str | None = None, parent_span_id: str | None = None, *args, **kwargs): + """Initialize the provider with tracing support. + Args: + trace_id: Optional trace ID for distributed tracing + parent_span_id: Optional parent span ID for tracing hierarchy + *args: Additional positional arguments for OpenAIProvider + **kwargs: Additional keyword arguments for OpenAIProvider + """ + super().__init__(*args, **kwargs) + self.trace_id = trace_id + self.parent_span_id = parent_span_id + + # Initialize AsyncTracer with client directly in the provider + if trace_id: + agentex_client = AsyncAgentex() + self.tracer = AsyncTracer(agentex_client) + else: + self.tracer = None + + @override + def get_model(self, model_name: Optional[str] = None) -> Model: + """Get a model wrapped with our logging capabilities and tracing. + Args: + model_name: The name of the model to retrieve + Returns: + A SyncStreamingModel that wraps the original OpenAI model + """ + # Get the original model from the parent class + original_model = super().get_model(model_name) + + # Wrap it with our logging capabilities and tracing info + wrapped_model = SyncStreamingModel(original_model, self.trace_id, self.parent_span_id, self.tracer) + + return wrapped_model + + +# The OpenAI streaming tap ``convert_openai_to_agentex_events`` now lives in +# ``agentex.lib.adk._modules._openai_sync``; re-exported here for back-compat. +from agentex.lib.adk._modules._openai_sync import ( # noqa: E402 + convert_openai_to_agentex_events as convert_openai_to_agentex_events, +) diff --git a/src/agentex/lib/adk/utils/__init__.py b/src/agentex/lib/adk/utils/__init__.py new file mode 100644 index 000000000..c190cb6e7 --- /dev/null +++ b/src/agentex/lib/adk/utils/__init__.py @@ -0,0 +1,5 @@ +from agentex.lib.adk.utils._modules.templating import TemplatingModule + +__all__ = ["templating"] + +templating = TemplatingModule() diff --git a/src/agentex/lib/adk/utils/_modules/__init__.py b/src/agentex/lib/adk/utils/_modules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/adk/utils/_modules/client.py b/src/agentex/lib/adk/utils/_modules/client.py new file mode 100644 index 000000000..725289631 --- /dev/null +++ b/src/agentex/lib/adk/utils/_modules/client.py @@ -0,0 +1,32 @@ +from typing import override + +import httpx + +from agentex import AsyncAgentex +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables + +logger = make_logger(__name__) + + +class EnvAuth(httpx.Auth): + def __init__(self, header_name="x-agent-api-key"): + self.header_name = header_name + + @override + def auth_flow(self, request): + # This gets called for every request + env_vars = EnvironmentVariables.refresh() + if env_vars: + agent_api_key = env_vars.AGENT_API_KEY + if agent_api_key: + request.headers[self.header_name] = agent_api_key + masked_key = agent_api_key[-4:] if agent_api_key and len(agent_api_key) > 4 else "****" + logger.info(f"Adding header {self.header_name}:{masked_key}") + yield request + + +def create_async_agentex_client(**kwargs) -> AsyncAgentex: + client = AsyncAgentex(**kwargs) + client._client.auth = EnvAuth() + return client diff --git a/src/agentex/lib/adk/utils/_modules/templating.py b/src/agentex/lib/adk/utils/_modules/templating.py new file mode 100644 index 000000000..29e6b6b2b --- /dev/null +++ b/src/agentex/lib/adk/utils/_modules/templating.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from typing import Any +from datetime import timedelta + +from temporalio.common import RetryPolicy + +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.temporal import in_temporal_workflow +from agentex.lib.core.tracing.tracer import AsyncTracer +from agentex.lib.adk.utils._modules.client import create_async_agentex_client +from agentex.lib.core.services.adk.utils.templating import TemplatingService +from agentex.lib.core.temporal.activities.activity_helpers import ActivityHelpers +from agentex.lib.core.temporal.activities.adk.utils.templating_activities import ( + JinjaActivityName, + RenderJinjaParams, +) + +logger = make_logger(__name__) + +DEFAULT_RETRY_POLICY = RetryPolicy(maximum_attempts=1) + + +class TemplatingModule: + """ + Module for managing templating operations in Agentex. + + This interface provides high-level methods for rendering Jinja templates, abstracting away + the underlying activity and workflow execution. It supports both synchronous and asynchronous + (Temporal workflow) contexts. + """ + + def __init__( + self, + templating_service: TemplatingService | None = None, + ): + """ + Initialize the templating interface. + + Args: + templating_service (Optional[TemplatingService]): Optional pre-configured templating service. If None, will be auto-initialized. + """ + if templating_service is None: + agentex_client = create_async_agentex_client() + tracer = AsyncTracer(agentex_client) + self._templating_service = TemplatingService(tracer=tracer) + else: + self._templating_service = templating_service + + async def render_jinja( + self, + trace_id: str, + template: str, + variables: dict[str, Any], + parent_span_id: str | None = None, + start_to_close_timeout: timedelta = timedelta(seconds=10), + heartbeat_timeout: timedelta = timedelta(seconds=10), + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + ) -> str: + """ + Render a Jinja template. + + Args: + trace_id (str): Unique identifier for tracing and correlation. + template (str): The Jinja template string to render. + variables (Dict[str, Any]): Variables to use in the template. + parent_span_id (Optional[str]): Optional parent span for tracing. + start_to_close_timeout (timedelta): Maximum time allowed for the operation. + heartbeat_timeout (timedelta): Maximum time between heartbeats. + retry_policy (RetryPolicy): Policy for retrying failed operations. + + Returns: + str: The rendered template as a string. + """ + render_jinja_params = RenderJinjaParams( + trace_id=trace_id, + parent_span_id=parent_span_id, + template=template, + variables=variables, + ) + if in_temporal_workflow(): + return await ActivityHelpers.execute_activity( + activity_name=JinjaActivityName.RENDER_JINJA, + request=render_jinja_params, + response_type=str, + start_to_close_timeout=start_to_close_timeout, + heartbeat_timeout=heartbeat_timeout, + retry_policy=retry_policy, + ) + else: + return await self._templating_service.render_jinja( + template=template, + variables=variables, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) diff --git a/src/agentex/lib/cli/__init__.py b/src/agentex/lib/cli/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/cli/commands/__init__.py b/src/agentex/lib/cli/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/cli/commands/agents.py b/src/agentex/lib/cli/commands/agents.py new file mode 100644 index 000000000..b4076d932 --- /dev/null +++ b/src/agentex/lib/cli/commands/agents.py @@ -0,0 +1,453 @@ +from __future__ import annotations + +import builtins +from pathlib import Path + +import typer +import questionary +from rich import print_json +from rich.panel import Panel +from rich.console import Console + +from agentex import Agentex +from agentex.lib.cli.debug import DebugMode, DebugConfig +from agentex.lib.utils.logging import make_logger +from agentex.lib.cli.utils.cli_utils import handle_questionary_cancellation +from agentex.lib.sdk.config.validation import ( + EnvironmentsValidationError, + generate_helpful_error_message, + validate_manifest_and_environments, +) +from agentex.lib.cli.utils.kubectl_utils import ( + validate_namespace, + check_and_switch_cluster_context, +) +from agentex.lib.sdk.config.agent_manifest import load_agent_manifest +from agentex.lib.cli.handlers.agent_handlers import ( + run_agent, + build_agent, + parse_build_args, + prepare_cloud_build_context, +) +from agentex.lib.cli.handlers.deploy_handlers import ( + HelmError, + DeploymentError, + InputDeployOverrides, + deploy_agent, +) +from agentex.lib.cli.handlers.cleanup_handlers import cleanup_agent_workflows + +logger = make_logger(__name__) +console = Console() + +agents = typer.Typer() + + +@agents.command() +def get( + agent_id: str = typer.Argument(..., help="ID of the agent to get"), +): + """ + Get the agent with the given name. + """ + logger.info(f"Getting agent with ID: {agent_id}") + client = Agentex() + agent = client.agents.retrieve(agent_id=agent_id) + logger.info(f"Agent retrieved: {agent}") + print_json(data=agent.to_dict(), default=str) + + +@agents.command() +def list(): + """ + List all agents. + """ + logger.info("Listing all agents") + client = Agentex() + agents = client.agents.list() + logger.info(f"Agents retrieved: {agents}") + print_json(data=[agent.to_dict() for agent in agents], default=str) + + +@agents.command() +def delete( + agent_name: str = typer.Argument(..., help="Name of the agent to delete"), +): + """ + Delete the agent with the given name. + """ + logger.info(f"Deleting agent with name: {agent_name}") + client = Agentex() + client.agents.delete_by_name(agent_name=agent_name) + logger.info(f"Agent deleted: {agent_name}") + + +@agents.command() +def cleanup_workflows( + agent_name: str = typer.Argument(..., help="Name of the agent to cleanup workflows for"), + force: bool = typer.Option( + False, help="Force cleanup using direct Temporal termination (bypasses development check)" + ), +): + """ + Clean up all running workflows for an agent. + + By default, uses graceful cancellation via agent RPC. + With --force, directly terminates workflows via Temporal client. + This is a convenience command that does the same thing as 'agentex tasks cleanup'. + """ + try: + console.print(f"[blue]Cleaning up workflows for agent '{agent_name}'...[/blue]") + + cleanup_agent_workflows(agent_name=agent_name, force=force, development_only=True) + + console.print(f"[green]✓ Workflow cleanup completed for agent '{agent_name}'[/green]") + + except Exception as e: + console.print(f"[red]Cleanup failed: {str(e)}[/red]") + logger.exception("Agent workflow cleanup failed") + raise typer.Exit(1) from e + + +@agents.command() +def build( + manifest: str = typer.Option(..., help="Path to the manifest you want to use"), + registry: str | None = typer.Option(None, help="Registry URL for pushing the built image"), + repository_name: str | None = typer.Option(None, help="Repository name to use for the built image"), + platforms: str | None = typer.Option( + None, help="Platform to build the image for. Please enter a comma separated list of platforms." + ), + push: bool = typer.Option(False, help="Whether to push the image to the registry"), + secret: str | None = typer.Option( + None, + help="Docker build secret in the format 'id=secret-id,src=path-to-secret-file'", + ), + tag: str | None = typer.Option(None, help="Image tag to use (defaults to 'latest')"), + build_arg: builtins.list[str] | None = typer.Option( # noqa: B008 + None, + help="Docker build argument in the format 'KEY=VALUE' (can be used multiple times)", + ), + cache: bool = typer.Option( + True, + "--cache/--no-cache", + help="Whether to use the build cache (default on). Pass --no-cache for a clean rebuild.", + ), +): + """ + Build an agent image locally from the given manifest. + """ + typer.echo(f"Building agent image from manifest: {manifest}") + + # Validate required parameters for building + if push and not registry: + typer.echo("Error: --registry is required when --push is enabled", err=True) + raise typer.Exit(1) + + # Only proceed with build if we have a registry (for now, to match existing behavior) + if not registry: + typer.echo("No registry provided, skipping image build") + return + + platform_list = platforms.split(",") if platforms else ["linux/amd64"] + + try: + image_url = build_agent( + manifest_path=manifest, + registry_url=registry, + repository_name=repository_name, + platforms=platform_list, + push=push, + secret=secret or "", # Provide default empty string + tag=tag or "latest", # Provide default + build_args=build_arg or [], # Provide default empty list + cache=cache, + ) + if image_url: + typer.echo(f"Successfully built image: {image_url}") + else: + typer.echo("Image build completed but no URL returned") + except Exception as e: + typer.echo(f"Error building agent image: {str(e)}", err=True) + logger.exception("Error building agent image") + raise typer.Exit(1) from e + + +@agents.command(name="package") +def package( + manifest: str = typer.Option(..., help="Path to the manifest you want to use"), + tag: str | None = typer.Option( + None, + "--tag", + "-t", + help="Image tag (defaults to deployment.image.tag from manifest, or 'latest')", + ), + output: str | None = typer.Option( + None, + "--output", + "-o", + help="Output filename for the tarball (defaults to -.tar.gz)", + ), + build_arg: builtins.list[str] | None = typer.Option( # noqa: B008 + None, + "--build-arg", + "-b", + help="Build argument in KEY=VALUE format (can be repeated)", + ), +): + """ + Package an agent's build context into a tarball for cloud builds. + + Reads manifest.yaml, prepares build context according to include_paths and + dockerignore, then saves a compressed tarball to the current directory. + + The tag defaults to the value in deployment.image.tag from the manifest. + + Example: + agentex agents package --manifest manifest.yaml + agentex agents package --manifest manifest.yaml --tag v1.0 + """ + typer.echo(f"Packaging build context from manifest: {manifest}") + + # Validate manifest exists + manifest_path = Path(manifest) + if not manifest_path.exists(): + typer.echo(f"Error: manifest not found at {manifest_path}", err=True) + raise typer.Exit(1) + + try: + # Prepare the build context (tag defaults from manifest if not provided) + build_context = prepare_cloud_build_context( + manifest_path=str(manifest_path), + tag=tag, + build_args=build_arg, + ) + + # Determine output filename using the resolved tag + if output: + output_filename = output + else: + output_filename = f"{build_context.agent_name}-{build_context.tag}.tar.gz" + + # Save tarball to current working directory + output_path = Path.cwd() / output_filename + output_path.write_bytes(build_context.archive_bytes) + + typer.echo(f"\nTarball saved to: {output_path}") + typer.echo(f"Size: {build_context.build_context_size_kb:.1f} KB") + + # Output the build parameters needed for cloud build + typer.echo("\n" + "=" * 60) + typer.echo("Build Parameters for Cloud Build API:") + typer.echo("=" * 60) + typer.echo(f" agent_name: {build_context.agent_name}") + typer.echo(f" image_name: {build_context.image_name}") + typer.echo(f" tag: {build_context.tag}") + typer.echo(f" context_file: {output_path}") + + if build_arg: + parsed_args = parse_build_args(build_arg) + typer.echo(f" build_args: {parsed_args}") + + typer.echo("") + typer.echo("Command:") + build_args_str = "" + if build_arg: + build_args_str = " ".join(f'--build-arg "{arg}"' for arg in build_arg) + build_args_str = f" {build_args_str}" + typer.echo( + f' sgp agentex build --context "{output_path}" ' + f'--image-name "{build_context.image_name}" ' + f'--tag "{build_context.tag}"{build_args_str}' + ) + typer.echo("=" * 60) + + except Exception as e: + typer.echo(f"Error packaging build context: {str(e)}", err=True) + logger.exception("Error packaging build context") + raise typer.Exit(1) from e + + +@agents.command() +def run( + manifest: str = typer.Option(..., help="Path to the manifest you want to use"), + cleanup_on_start: bool = typer.Option(False, help="Clean up existing workflows for this agent before starting"), + # Debug options + debug: bool = typer.Option(False, help="Enable debug mode for both worker and ACP (disables auto-reload)"), + debug_worker: bool = typer.Option(False, help="Enable debug mode for temporal worker only"), + debug_acp: bool = typer.Option(False, help="Enable debug mode for ACP server only"), + debug_port: int = typer.Option(5678, help="Port for remote debugging (worker uses this, ACP uses port+1)"), + wait_for_debugger: bool = typer.Option(False, help="Wait for debugger to attach before starting"), +) -> None: + """ + Run an agent locally from the given manifest. + """ + typer.echo(f"Running agent from manifest: {manifest}") + + # Optionally cleanup existing workflows before starting + if cleanup_on_start: + try: + # Parse manifest to get agent name + manifest_obj = load_agent_manifest(file_path=manifest) + agent_name = manifest_obj.agent.name + + console.print(f"[yellow]Cleaning up existing workflows for agent '{agent_name}'...[/yellow]") + cleanup_agent_workflows(agent_name=agent_name, force=False, development_only=True) + console.print("[green]✓ Pre-run cleanup completed[/green]") + + except Exception as e: + console.print(f"[yellow]⚠ Pre-run cleanup failed: {str(e)}[/yellow]") + logger.warning(f"Pre-run cleanup failed: {e}") + + # Create debug configuration based on CLI flags + debug_config = None + if debug or debug_worker or debug_acp: + # Determine debug mode + if debug: + mode = DebugMode.BOTH + elif debug_worker and debug_acp: + mode = DebugMode.BOTH + elif debug_worker: + mode = DebugMode.WORKER + elif debug_acp: + mode = DebugMode.ACP + else: + mode = DebugMode.NONE + + debug_config = DebugConfig( + enabled=True, + mode=mode, + port=debug_port, + wait_for_attach=wait_for_debugger, + auto_port=False, # Use fixed port to match VS Code launch.json + ) + + console.print(f"[blue]🐛 Debug mode enabled: {mode.value}[/blue]") + if wait_for_debugger: + console.print("[yellow]⏳ Processes will wait for debugger attachment[/yellow]") + + try: + run_agent(manifest_path=manifest, debug_config=debug_config) + except Exception as e: + typer.echo(f"Error running agent: {str(e)}", err=True) + logger.exception("Error running agent") + raise typer.Exit(1) from e + + +@agents.command() +def deploy( + cluster: str = typer.Option(..., help="Target cluster name (must match kubectl context)"), + manifest: str = typer.Option("manifest.yaml", help="Path to the manifest file"), + namespace: str | None = typer.Option( + None, + help="Override Kubernetes namespace (defaults to namespace from environments.yaml)", + ), + environment: str | None = typer.Option( + None, + help="Environment name (dev, prod, etc.) - must be defined in environments.yaml. If not provided, the namespace must be set explicitly.", + ), + tag: str | None = typer.Option(None, help="Override the image tag for deployment"), + repository: str | None = typer.Option(None, help="Override the repository for deployment"), + use_latest_chart: bool = typer.Option( + False, "--use-latest-chart", help="Fetch and use the latest Helm chart version from OCI registry" + ), + interactive: bool = typer.Option(True, "--interactive/--no-interactive", help="Enable interactive prompts"), +): + """Deploy an agent to a Kubernetes cluster using Helm""" + + console.print(Panel.fit("🚀 [bold blue]Deploy Agent[/bold blue]", border_style="blue")) + + try: + # Validate manifest exists + manifest_path = Path(manifest) + if not manifest_path.exists(): + console.print(f"[red]Error:[/red] Manifest file not found: {manifest}") + raise typer.Exit(1) + + # Validate manifest and environments configuration + try: + _, environments_config = validate_manifest_and_environments( + str(manifest_path), required_environment=environment + ) + agent_env_config = environments_config.get_config_for_env(environment) + console.print(f"[green]✓[/green] Environment config validated: {environment}") + + except EnvironmentsValidationError as e: + error_msg = generate_helpful_error_message(e, "Environment validation failed") + console.print(f"[red]Configuration Error:[/red]\n{error_msg}") + raise typer.Exit(1) from e + except Exception as e: + console.print(f"[red]Error:[/red] Failed to validate configuration: {e}") + raise typer.Exit(1) from e + + # Load manifest for credential validation + manifest_obj = load_agent_manifest(str(manifest_path)) + + # Use namespace from environment config if not overridden + if not namespace and agent_env_config: + namespace_from_config = agent_env_config.kubernetes.namespace if agent_env_config.kubernetes else None + if namespace_from_config: + console.print(f"[blue]ℹ[/blue] Using namespace from environments.yaml: {namespace_from_config}") + namespace = namespace_from_config + else: + raise DeploymentError( + f"No namespace found in environments.yaml for environment: {environment}, and not passed in as --namespace" + ) + elif not namespace: + raise DeploymentError( + "No namespace provided, and not passed in as --namespace and no environment provided to read from an environments.yaml file" + ) + + # Confirm deployment (only in interactive mode) + console.print("\n[bold]Deployment Summary:[/bold]") + console.print(f" Manifest: {manifest}") + console.print(f" Environment: {environment}") + console.print(f" Cluster: {cluster}") + console.print(f" Namespace: {namespace}") + if tag: + console.print(f" Image Tag: {tag}") + if use_latest_chart: + console.print(" Chart Version: [cyan]latest (will be fetched)[/cyan]") + + if interactive: + proceed = questionary.confirm("Proceed with deployment?").ask() + proceed = handle_questionary_cancellation(proceed, "deployment confirmation") + + if not proceed: + console.print("Deployment cancelled") + raise typer.Exit(0) + else: + console.print("Proceeding with deployment (non-interactive mode)") + + check_and_switch_cluster_context(cluster) + if not validate_namespace(namespace, cluster): + console.print(f"[red]Error:[/red] Namespace '{namespace}' does not exist in cluster '{cluster}'") + raise typer.Exit(1) + + deploy_overrides = InputDeployOverrides(repository=repository, image_tag=tag) + + # Deploy agent + deploy_agent( + manifest_path=str(manifest_path), + cluster_name=cluster, + namespace=namespace, + deploy_overrides=deploy_overrides, + environment_name=environment, + use_latest_chart=use_latest_chart, + ) + + # Use the already loaded manifest object + release_name = f"{manifest_obj.agent.name}-{cluster}" + + console.print("\n[bold green]🎉 Deployment completed successfully![/bold green]") + console.print("\nTo check deployment status:") + console.print(f" kubectl get pods -n {namespace}") + console.print(f" helm status {release_name} -n {namespace}") + + except (DeploymentError, HelmError) as e: + console.print(f"[red]Deployment failed:[/red] {str(e)}") + logger.exception("Deployment failed") + raise typer.Exit(1) from e + except Exception as e: + console.print(f"[red]Unexpected error:[/red] {str(e)}") + logger.exception("Unexpected error during deployment") + raise typer.Exit(1) from e diff --git a/src/agentex/lib/cli/commands/init.py b/src/agentex/lib/cli/commands/init.py new file mode 100644 index 000000000..9849e9bbc --- /dev/null +++ b/src/agentex/lib/cli/commands/init.py @@ -0,0 +1,448 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any, Dict +from pathlib import Path + +import questionary +from jinja2 import Environment, FileSystemLoader +from rich.rule import Rule +from rich.text import Text +from rich.panel import Panel +from rich.table import Table +from rich.console import Console + +from agentex.lib.utils.logging import make_logger + +logger = make_logger(__name__) +console = Console() + +# Get the templates directory relative to this file +TEMPLATES_DIR = Path(__file__).parent.parent / "templates" + + +class TemplateType(str, Enum): + TEMPORAL = "temporal" + TEMPORAL_OPENAI_AGENTS = "temporal-openai-agents" + TEMPORAL_PYDANTIC_AI = "temporal-pydantic-ai" + TEMPORAL_LANGGRAPH = "temporal-langgraph" + TEMPORAL_CLAUDE_CODE = "temporal-claude-code" + TEMPORAL_CODEX = "temporal-codex" + DEFAULT = "default" + DEFAULT_LANGGRAPH = "default-langgraph" + DEFAULT_PYDANTIC_AI = "default-pydantic-ai" + DEFAULT_OPENAI_AGENTS = "default-openai-agents" + DEFAULT_CLAUDE_CODE = "default-claude-code" + DEFAULT_CODEX = "default-codex" + SYNC = "sync" + SYNC_OPENAI_AGENTS = "sync-openai-agents" + SYNC_OPENAI_AGENTS_LOCAL_SANDBOX = "sync-openai-agents-local-sandbox" + SYNC_LANGGRAPH = "sync-langgraph" + SYNC_PYDANTIC_AI = "sync-pydantic-ai" + SYNC_CLAUDE_CODE = "sync-claude-code" + SYNC_CODEX = "sync-codex" + + +def render_template( + template_path: str, context: Dict[str, Any], template_type: TemplateType +) -> str: + """Render a template with the given context""" + env = Environment(loader=FileSystemLoader(TEMPLATES_DIR / template_type.value)) + template = env.get_template(template_path) + return template.render(**context) + + +def create_project_structure( + path: Path, context: Dict[str, Any], template_type: TemplateType, use_uv: bool +): + """Create the project structure from templates""" + # Create project directory + project_dir: Path = path / context["project_name"] + project_dir.mkdir(parents=True, exist_ok=True) + + # Create project/code directory + code_dir: Path = project_dir / "project" + code_dir.mkdir(parents=True, exist_ok=True) + + # Create __init__.py + (code_dir / "__init__.py").touch() + + # Define project files based on template type + project_files = { + TemplateType.TEMPORAL: ["acp.py", "workflow.py", "run_worker.py"], + TemplateType.TEMPORAL_OPENAI_AGENTS: ["acp.py", "workflow.py", "run_worker.py", "activities.py"], + TemplateType.TEMPORAL_PYDANTIC_AI: ["acp.py", "workflow.py", "run_worker.py", "agent.py", "tools.py"], + TemplateType.TEMPORAL_LANGGRAPH: ["acp.py", "workflow.py", "run_worker.py", "graph.py", "tools.py"], + TemplateType.TEMPORAL_CLAUDE_CODE: ["acp.py", "workflow.py", "run_worker.py", "activities.py"], + TemplateType.TEMPORAL_CODEX: ["acp.py", "workflow.py", "run_worker.py", "activities.py"], + TemplateType.DEFAULT: ["acp.py"], + TemplateType.DEFAULT_LANGGRAPH: ["acp.py", "graph.py", "tools.py"], + TemplateType.DEFAULT_PYDANTIC_AI: ["acp.py", "agent.py", "tools.py"], + TemplateType.DEFAULT_OPENAI_AGENTS: ["acp.py"], + TemplateType.DEFAULT_CLAUDE_CODE: ["acp.py"], + TemplateType.DEFAULT_CODEX: ["acp.py"], + TemplateType.SYNC: ["acp.py"], + TemplateType.SYNC_OPENAI_AGENTS: ["acp.py"], + TemplateType.SYNC_OPENAI_AGENTS_LOCAL_SANDBOX: ["acp.py", "agent.py", "tools.py"], + TemplateType.SYNC_LANGGRAPH: ["acp.py", "graph.py", "tools.py"], + TemplateType.SYNC_PYDANTIC_AI: ["acp.py", "agent.py", "tools.py"], + TemplateType.SYNC_CLAUDE_CODE: ["acp.py"], + TemplateType.SYNC_CODEX: ["acp.py"], + }[template_type] + + # Create project/code files + for template in project_files: + template_path = f"project/{template}.j2" + output_path = code_dir / template + output_path.write_text(render_template(template_path, context, template_type)) + + # Create root files + root_templates = { + ".dockerignore.j2": ".dockerignore", + ".env.example.j2": ".env.example", + "manifest.yaml.j2": "manifest.yaml", + "README.md.j2": "README.md", + "environments.yaml.j2": "environments.yaml", + } + + # Add package management file based on uv choice + if use_uv: + root_templates["pyproject.toml.j2"] = "pyproject.toml" + root_templates["Dockerfile-uv.j2"] = "Dockerfile" + else: + root_templates["requirements.txt.j2"] = "requirements.txt" + root_templates["Dockerfile.j2"] = "Dockerfile" + + # Add development notebook for agents + root_templates["dev.ipynb.j2"] = "dev.ipynb" + + for template, output in root_templates.items(): + output_path = project_dir / output + output_path.write_text(render_template(template, context, template_type)) + + console.print(f"\n[green]✓[/green] Created project structure at: {project_dir}") + + +def get_project_context(answers: Dict[str, Any], project_path: Path, manifest_root: Path) -> Dict[str, Any]: # noqa: ARG001 + """Get the project context from user answers""" + # Use agent_directory_name as project_name + project_name = answers["agent_directory_name"].replace("-", "_") + + # Now, this is actually the exact same as the project_name because we changed the build root to be ../ + project_path_from_build_root = project_name + + return { + **answers, + "project_name": project_name, + "workflow_class": "".join( + word.capitalize() for word in answers["agent_name"].split("-") + ) + + "Workflow", + "workflow_name": answers["agent_name"], + "queue_name": project_name + "_queue", + "project_path_from_build_root": project_path_from_build_root, + } + + +def init(): + """Initialize a new agent project""" + console.print( + Panel.fit( + "🤖 [bold blue]Initialize New Agent Project[/bold blue]", + border_style="blue", + ) + ) + + # Use a Rich table for template descriptions + table = Table(show_header=True, header_style="bold blue") + table.add_column("Template", style="cyan", no_wrap=True) + table.add_column("Description", style="white") + table.add_row( + "[bold cyan]Sync ACP[/bold cyan]", + "Synchronous agent that processes one request per task with a simple request-response pattern. Best for low-latency use cases, FAQ bots, translation services, and data lookups.", + ) + table.add_row( + "[bold cyan]Async - ACP Only[/bold cyan]", + "Asynchronous, non-blocking agent that can process multiple concurrent requests. Best for straightforward asynchronous agents that don't need durable execution. Good for asynchronous workflows, stateful applications, and multi-step analysis.", + ) + table.add_row( + "[bold cyan]Async - Temporal[/bold cyan]", + "Asynchronous, non-blocking agent with durable execution for all steps. Best for production-grade agents that require complex multi-step tool calls, human-in-the-loop approvals, and long-running processes that require transactional reliability.", + ) + console.print() + console.print(table) + console.print() + + def validate_agent_name(text: str) -> bool | str: + """Validate agent name follows required format""" + is_valid = len(text) >= 1 and text.replace("-", "").isalnum() and text.islower() + if not is_valid: + return "Invalid name. Use only lowercase letters, numbers, and hyphens. Examples: 'my-agent', 'newsbot'" + return True + + # Gather project information + template_type = questionary.select( + "What type of template would you like to create?", + choices=[ + {"name": "Sync ACP", "value": "sync_submenu"}, + {"name": "Async - ACP Only", "value": "async_submenu"}, + {"name": "Async - Temporal", "value": "temporal_submenu"}, + ], + ).ask() + if not template_type: + return + + # If a submenu was selected, show sub-menu for variants + if template_type == "async_submenu": + template_type = questionary.select( + "Which Async template would you like to use?", + choices=[ + {"name": "Basic Async ACP", "value": TemplateType.DEFAULT}, + {"name": "Async ACP + OpenAI Agents SDK", "value": TemplateType.DEFAULT_OPENAI_AGENTS}, + {"name": "Async ACP + LangGraph", "value": TemplateType.DEFAULT_LANGGRAPH}, + {"name": "Async ACP + Pydantic AI", "value": TemplateType.DEFAULT_PYDANTIC_AI}, + {"name": "Async ACP + Claude Code", "value": TemplateType.DEFAULT_CLAUDE_CODE}, + {"name": "Async ACP + Codex", "value": TemplateType.DEFAULT_CODEX}, + ], + ).ask() + if not template_type: + return + elif template_type == "temporal_submenu": + template_type = questionary.select( + "Which Temporal template would you like to use?", + choices=[ + {"name": "Basic Temporal", "value": TemplateType.TEMPORAL}, + {"name": "Temporal + OpenAI Agents SDK (Recommended)", "value": TemplateType.TEMPORAL_OPENAI_AGENTS}, + {"name": "Temporal + Pydantic AI", "value": TemplateType.TEMPORAL_PYDANTIC_AI}, + {"name": "Temporal + LangGraph", "value": TemplateType.TEMPORAL_LANGGRAPH}, + {"name": "Temporal + Claude Code", "value": TemplateType.TEMPORAL_CLAUDE_CODE}, + {"name": "Temporal + Codex", "value": TemplateType.TEMPORAL_CODEX}, + ], + ).ask() + if not template_type: + return + elif template_type == "sync_submenu": + template_type = questionary.select( + "Which Sync template would you like to use?", + choices=[ + {"name": "Basic Sync ACP", "value": TemplateType.SYNC}, + {"name": "Sync ACP + OpenAI Agents SDK (Recommended)", "value": TemplateType.SYNC_OPENAI_AGENTS}, + {"name": "Sync ACP + OpenAI Agents SDK + Local Sandbox", "value": TemplateType.SYNC_OPENAI_AGENTS_LOCAL_SANDBOX}, + {"name": "Sync ACP + LangGraph", "value": TemplateType.SYNC_LANGGRAPH}, + {"name": "Sync ACP + Pydantic AI", "value": TemplateType.SYNC_PYDANTIC_AI}, + {"name": "Sync ACP + Claude Code", "value": TemplateType.SYNC_CLAUDE_CODE}, + {"name": "Sync ACP + Codex", "value": TemplateType.SYNC_CODEX}, + ], + ).ask() + if not template_type: + return + + project_path = questionary.path( + "Where would you like to create your project?", default="." + ).ask() + if not project_path: + return + + agent_name = questionary.text( + "What's your agent name? (letters, numbers, and hyphens only)", + validate=validate_agent_name, + ).ask() + if not agent_name: + return + + agent_directory_name = questionary.text( + "What do you want to name the project folder for your agent?", + default=agent_name, + ).ask() + if not agent_directory_name: + return + + description = questionary.text( + "Provide a brief description of your agent:", default="An Agentex agent" + ).ask() + if not description: + return + + use_uv = questionary.select( + "Would you like to use uv for package management?", + choices=[ + {"name": "Yes (Recommended)", "value": True}, + {"name": "No", "value": False}, + ], + ).ask() + + answers = { + "template_type": template_type, + "project_path": project_path, + "agent_name": agent_name, + "agent_directory_name": agent_directory_name, + "description": description, + "use_uv": use_uv, + } + + # Derive all names from agent_directory_name and path + project_path = Path(answers["project_path"]).resolve() + manifest_root = Path("../../") + + # Get project context + context = get_project_context(answers, project_path, manifest_root) + context["template_type"] = answers["template_type"].value + context["use_uv"] = answers["use_uv"] + + # Create project structure + create_project_structure( + project_path, context, answers["template_type"], answers["use_uv"] + ) + + # Show success message + console.print() + success_text = Text("✅ Project created successfully!", style="bold green") + success_panel = Panel( + success_text, + border_style="green", + padding=(0, 2), + title="[bold white]Status[/bold white]", + title_align="left" + ) + console.print(success_panel) + + # Main header + console.print() + console.print(Rule("[bold blue]Next Steps[/bold blue]", style="blue")) + console.print() + + # Local Development Section + local_steps = Text() + local_steps.append("1. ", style="bold white") + local_steps.append("Navigate to your project directory:\n", style="white") + local_steps.append(f" cd {project_path}/{context['project_name']}\n\n", style="dim cyan") + + local_steps.append("2. ", style="bold white") + local_steps.append("Review the generated files. ", style="white") + local_steps.append("project/acp.py", style="yellow") + local_steps.append(" is your agent's entrypoint.\n", style="white") + local_steps.append(" See ", style="dim white") + local_steps.append("https://agentex.sgp.scale.com/docs", style="blue underline") + local_steps.append(" for how to customize different agent types", style="dim white") + local_steps.append("\n\n", style="white") + + local_steps.append("3. ", style="bold white") + local_steps.append("Set up your environment and test locally ", style="white") + local_steps.append("(no deployment needed)", style="dim white") + local_steps.append(":\n", style="white") + local_steps.append(" uv venv && uv sync && source .venv/bin/activate", style="dim cyan") + local_steps.append("\n agentex agents run --manifest manifest.yaml", style="dim cyan") + + local_panel = Panel( + local_steps, + title="[bold blue]Development Setup[/bold blue]", + title_align="left", + border_style="blue", + padding=(1, 2) + ) + console.print(local_panel) + console.print() + + # Prerequisites Note + prereq_text = Text() + prereq_text.append("The above is all you need for local development. Once you're ready for production, read this box and below.\n\n", style="white") + + prereq_text.append("• ", style="bold white") + prereq_text.append("Prerequisites for Production: ", style="bold yellow") + prereq_text.append("You need Agentex hosted on a Kubernetes cluster.\n", style="white") + prereq_text.append(" See ", style="dim white") + prereq_text.append("https://agentex.sgp.scale.com/docs", style="blue underline") + prereq_text.append(" for setup instructions. ", style="dim white") + prereq_text.append("Scale GenAI Platform (SGP) customers", style="dim cyan") + prereq_text.append(" already have this setup as part of their enterprise license.\n\n", style="dim white") + + prereq_text.append("• ", style="bold white") + prereq_text.append("Best Practice: ", style="bold blue") + prereq_text.append("Use CI/CD pipelines for production deployments, not manual commands.\n", style="white") + prereq_text.append(" Commands below demonstrate Agentex's quick deployment capabilities.", style="dim white") + + prereq_panel = Panel( + prereq_text, + border_style="yellow", + padding=(1, 2) + ) + console.print(prereq_panel) + console.print() + + # Production Setup Section (includes deployment) + prod_steps = Text() + prod_steps.append("4. ", style="bold white") + prod_steps.append("Configure where to push your container image", style="white") + prod_steps.append(":\n", style="white") + prod_steps.append(" Edit ", style="dim white") + prod_steps.append("manifest.yaml", style="dim yellow") + prod_steps.append(" → ", style="dim white") + prod_steps.append("deployment.image.repository", style="dim yellow") + prod_steps.append(" → replace ", style="dim white") + prod_steps.append('""', style="dim red") + prod_steps.append(" with your registry", style="dim white") + prod_steps.append("\n Examples: ", style="dim white") + prod_steps.append("123456789012.dkr.ecr.us-west-2.amazonaws.com/my-agent", style="dim blue") + prod_steps.append(", ", style="dim white") + prod_steps.append("gcr.io/my-project", style="dim blue") + prod_steps.append(", ", style="dim white") + prod_steps.append("myregistry.azurecr.io", style="dim blue") + prod_steps.append("\n\n", style="white") + + prod_steps.append("5. ", style="bold white") + prod_steps.append("Build your agent as a container and push to registry", style="white") + prod_steps.append(":\n", style="white") + prod_steps.append(" agentex agents build --manifest manifest.yaml --registry --push", style="dim cyan") + prod_steps.append("\n\n", style="white") + + prod_steps.append("6. ", style="bold white") + prod_steps.append("Upload secrets to cluster ", style="white") + prod_steps.append("(API keys, credentials your agent needs)", style="dim white") + prod_steps.append(":\n", style="white") + prod_steps.append(" agentex secrets sync --manifest manifest.yaml --cluster your-cluster", style="dim cyan") + prod_steps.append("\n ", style="white") + prod_steps.append("Note: ", style="dim yellow") + prod_steps.append("Secrets are ", style="dim white") + prod_steps.append("never stored in manifest.yaml", style="dim red") + prod_steps.append(". You provide them via ", style="dim white") + prod_steps.append("--values file", style="dim blue") + prod_steps.append(" or interactive prompts", style="dim white") + prod_steps.append("\n\n", style="white") + + prod_steps.append("7. ", style="bold white") + prod_steps.append("Deploy your agent to run on the cluster", style="white") + prod_steps.append(":\n", style="white") + prod_steps.append(" agentex agents deploy --cluster your-cluster --namespace your-namespace", style="dim cyan") + prod_steps.append("\n\n", style="white") + prod_steps.append("Note: These commands use Helm charts hosted by Scale to deploy agents.", style="dim italic") + + prod_panel = Panel( + prod_steps, + title="[bold magenta]Production Setup & Deployment[/bold magenta]", + title_align="left", + border_style="magenta", + padding=(1, 2) + ) + console.print(prod_panel) + + # Professional footer with helpful context + console.print() + console.print(Rule(style="dim white")) + + # Add helpful context about the workflow + help_text = Text() + help_text.append("ℹ️ ", style="blue") + help_text.append("Quick Start: ", style="bold white") + help_text.append("Steps 1-3 for local development. Steps 4-7 require Agentex cluster for production.", style="dim white") + console.print(" ", help_text) + + tip_text = Text() + tip_text.append("💡 ", style="yellow") + tip_text.append("Need help? ", style="bold white") + tip_text.append("Use ", style="dim white") + tip_text.append("agentex --help", style="cyan") + tip_text.append(" or ", style="dim white") + tip_text.append("agentex [command] --help", style="cyan") + tip_text.append(" for detailed options", style="dim white") + console.print(" ", tip_text) + console.print() diff --git a/src/agentex/lib/cli/commands/main.py b/src/agentex/lib/cli/commands/main.py new file mode 100644 index 000000000..fa3c098d2 --- /dev/null +++ b/src/agentex/lib/cli/commands/main.py @@ -0,0 +1,32 @@ +import typer + +from agentex.lib.cli.commands.uv import uv +from agentex.lib.cli.commands.init import init +from agentex.lib.cli.commands.tasks import tasks +from agentex.lib.cli.commands.agents import agents +from agentex.lib.cli.commands.secrets import secrets + +# Create the main Typer application +app = typer.Typer( + context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 800}, + pretty_exceptions_show_locals=False, + pretty_exceptions_enable=False, + add_completion=False, +) + +# Add the subcommands +app.add_typer(agents, name="agents", help="Get, list, run, build, and deploy agents") +app.add_typer(tasks, name="tasks", help="Get, list, and delete tasks") +app.add_typer(secrets, name="secrets", help="Sync, get, list, and delete secrets") +app.add_typer( + uv, name="uv", help="Wrapper for uv command with AgentEx-specific enhancements" +) + +# Add init command with documentation +app.command( + help="Initialize a new agent project with a template (interactive)", +)(init) + + +if __name__ == "__main__": + app() diff --git a/src/agentex/lib/cli/commands/secrets.py b/src/agentex/lib/cli/commands/secrets.py new file mode 100644 index 000000000..fae21e63b --- /dev/null +++ b/src/agentex/lib/cli/commands/secrets.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +from pathlib import Path + +import typer +import questionary +from rich import print_json +from rich.panel import Panel +from rich.console import Console + +from agentex.lib.utils.logging import make_logger +from agentex.lib.cli.utils.cli_utils import handle_questionary_cancellation +from agentex.lib.cli.utils.kubectl_utils import ( + validate_namespace, + check_and_switch_cluster_context, +) +from agentex.lib.sdk.config.agent_manifest import load_agent_manifest +from agentex.lib.cli.handlers.secret_handlers import ( + get_secret, + sync_secrets, + delete_secret, + get_kubernetes_secrets_by_type, +) + +logger = make_logger(__name__) +console = Console() + +secrets = typer.Typer() + + +@secrets.command() +def list( + namespace: str = typer.Option( + "agentex-agents", help="Kubernetes namespace to list secrets from" + ), + cluster: str | None = typer.Option( + None, help="Cluster context to use (defaults to current context)" + ), +): + """List names of available secrets""" + logger.info(f"Listing secrets in namespace: {namespace}") + + if cluster: + check_and_switch_cluster_context(cluster) + if not validate_namespace(namespace, cluster): + console.print( + f"[red]Error:[/red] Namespace '{namespace}' does not exist in cluster '{cluster}'" + ) + raise typer.Exit(1) + + secrets_list = get_kubernetes_secrets_by_type(namespace=namespace, context=cluster) + print_json(data=secrets_list) + + +@secrets.command() +def get( + name: str = typer.Argument(..., help="Name of the secret to get"), + namespace: str = typer.Option( + "agentex-agents", help="Kubernetes namespace for the secret" + ), + cluster: str | None = typer.Option( + None, help="Cluster context to use (defaults to current context)" + ), +): + """Get details about a secret""" + logger.info(f"Getting secret: {name} from namespace: {namespace}") + + if cluster: + check_and_switch_cluster_context(cluster) + if not validate_namespace(namespace, cluster): + console.print( + f"[red]Error:[/red] Namespace '{namespace}' does not exist in cluster '{cluster}'" + ) + raise typer.Exit(1) + + secret = get_secret(name=name, namespace=namespace, context=cluster) + print_json(data=secret) + + +@secrets.command() +def delete( + name: str = typer.Argument(..., help="Name of the secret to delete"), + namespace: str = typer.Option( + "agentex-agents", help="Kubernetes namespace for the secret" + ), + cluster: str | None = typer.Option( + None, help="Cluster context to use (defaults to current context)" + ), +): + """Delete a secret""" + logger.info(f"Deleting secret: {name} from namespace: {namespace}") + + if cluster: + check_and_switch_cluster_context(cluster) + if not validate_namespace(namespace, cluster): + console.print( + f"[red]Error:[/red] Namespace '{namespace}' does not exist in cluster '{cluster}'" + ) + raise typer.Exit(1) + + delete_secret(name=name, namespace=namespace, context=cluster) + + +@secrets.command() +def sync( + manifest: str = typer.Option(..., help="Path to the manifest file"), + # TODO: should cluster be here or be in manifest as well? + cluster: str = typer.Option(..., "--cluster", help="Cluster to sync secrets to"), + interactive: bool = typer.Option( + True, "--interactive/--no-interactive", help="Enable interactive prompts" + ), + namespace: str | None = typer.Option( + None, + help="Kubernetes namespace to deploy to (required in non-interactive mode)", + ), + values: str = typer.Option(None, "--values", help="Path to the values file"), +): + """Sync secrets from the cluster to the local environment""" + console.print( + Panel.fit("🚀 [bold blue]Sync Secrets[/bold blue]", border_style="blue") + ) + + manifest_path = Path(manifest) + if not manifest_path.exists(): + console.print(f"[red]Error:[/red] Manifest file not found: {manifest}") + raise typer.Exit(1) + + # In non-interactive mode, require namespace + if not interactive and not namespace: + console.print( + "[red]Error:[/red] --namespace is required in non-interactive mode" + ) + raise typer.Exit(1) + + # Get namespace if not provided (only in interactive mode) + if not namespace: + namespace = questionary.text( + "Enter Kubernetes namespace:", default="default" + ).ask() + namespace = handle_questionary_cancellation(namespace, "namespace input") + + if not namespace: + console.print("Deployment cancelled") + raise typer.Exit(0) + + if values: + values_path = Path(values) + if not values_path.exists(): + console.print(f"[red]Error:[/red] Values file not found: {values_path}") + raise typer.Exit(1) + + # Validate cluster and namespace + check_and_switch_cluster_context(cluster) + if not validate_namespace(namespace, cluster): + console.print( + f"[red]Error:[/red] Namespace '{namespace}' does not exist in cluster '{cluster}'" + ) + raise typer.Exit(1) + + agent_manifest = load_agent_manifest(file_path=manifest) + + # Always call sync_secrets - it will handle the case of no credentials + sync_secrets( + manifest_obj=agent_manifest, + cluster=cluster, + namespace=namespace, + interactive=interactive, + values_path=str(values) if values else None, + ) + + console.print("[green]Successfully synced secrets[/green]") diff --git a/src/agentex/lib/cli/commands/tasks.py b/src/agentex/lib/cli/commands/tasks.py new file mode 100644 index 000000000..43d54894b --- /dev/null +++ b/src/agentex/lib/cli/commands/tasks.py @@ -0,0 +1,119 @@ +from typing import Any + +import typer +from rich import print_json +from rich.console import Console + +from agentex import Agentex +from agentex.lib.utils.logging import make_logger +from agentex.lib.cli.handlers.cleanup_handlers import cleanup_agent_workflows + +logger = make_logger(__name__) +console = Console() + +tasks = typer.Typer() + + +@tasks.command() +def get( + task_id: str = typer.Argument(..., help="ID of the task to get"), +): + """ + Get the task with the given ID. + """ + logger.info(f"Getting task: {task_id}") + client = Agentex() + task = client.tasks.retrieve(task_id=task_id) + logger.info(f"Full Task {task_id}:") + print_json(data=task.to_dict(), default=str) + + +@tasks.command() +def list(): + """ + List all tasks. + """ + client = Agentex() + tasks = client.tasks.list() + print_json(data=[task.to_dict() for task in tasks], default=str) + + +@tasks.command() +def list_running( + agent_name: str = typer.Option(..., help="Name of the agent to list running tasks for"), +): + """ + List all currently running tasks for a specific agent. + """ + client = Agentex() + if agent_name: + all_tasks = client.tasks.list(agent_name=agent_name) + else: + all_tasks = client.tasks.list() + running_tasks = [task for task in all_tasks if hasattr(task, "status") and task.status == "RUNNING"] + + if not running_tasks: + console.print(f"[yellow]No running tasks found for agent '{agent_name}'[/yellow]") + return + + console.print(f"[green]Found {len(running_tasks)} running task(s) for agent '{agent_name}':[/green]") + + # Convert to dict with proper datetime serialization + serializable_tasks: list[dict[str, Any]] = [] # type: ignore[misc] + for task in running_tasks: + try: + # Use model_dump with mode='json' for proper datetime handling + if hasattr(task, "model_dump"): + serializable_tasks.append(task.model_dump(mode="json")) + else: + # Fallback for non-Pydantic objects + serializable_tasks.append( + {"id": getattr(task, "id", "unknown"), "status": getattr(task, "status", "unknown")} + ) + except Exception as e: + logger.warning(f"Failed to serialize task: {e}") + # Minimal fallback + serializable_tasks.append( + {"id": getattr(task, "id", "unknown"), "status": getattr(task, "status", "unknown")} + ) + + print_json(data=serializable_tasks, default=str) + + +@tasks.command() +def delete( + task_id: str = typer.Argument(..., help="ID of the task to delete"), +): + """ + Delete the task with the given ID. + """ + logger.info(f"Deleting task: {task_id}") + client = Agentex() + client.tasks.delete(task_id=task_id) + logger.info(f"Task deleted: {task_id}") + + +@tasks.command() +def cleanup( + agent_name: str = typer.Option(..., help="Name of the agent to cleanup tasks for"), + force: bool = typer.Option( + False, help="Force cleanup using direct Temporal termination (bypasses development check)" + ), +): + """ + Clean up all running tasks/workflows for an agent. + + By default, uses graceful cancellation via agent RPC. + With --force, directly terminates workflows via Temporal client. + """ + try: + console.print(f"[blue]Starting cleanup for agent '{agent_name}'...[/blue]") + + cleanup_agent_workflows(agent_name=agent_name, force=force, development_only=True) + + console.print(f"[green]✓ Cleanup completed for agent '{agent_name}'[/green]") + + except Exception as e: + console.print(f"[red]Cleanup failed: {str(e)}[/red]") + logger.exception("Task cleanup failed") + raise typer.Exit(1) from e diff --git a/src/agentex/lib/cli/commands/uv.py b/src/agentex/lib/cli/commands/uv.py new file mode 100644 index 000000000..e192b0e53 --- /dev/null +++ b/src/agentex/lib/cli/commands/uv.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import os +import sys +import subprocess + +import typer + +from agentex.lib.utils.logging import make_logger + +logger = make_logger(__name__) + + +uv = typer.Typer( + help="Wrapper for uv command with AgentEx-specific enhancements", + context_settings={"help_option_names": ["-h", "--help"]}, +) + +sync_args = typer.Argument(None, help="Additional arguments to pass to uv sync") + + +@uv.command() +def sync( + ctx: typer.Context, + index: str | None = typer.Option( + None, "--index", "-i", help="UV index URL to use for sync" + ), + group: str | None = typer.Option( + None, + "--group", + "-g", + help="Include dependencies from the specified dependency group", + ), + args: list[str] = sync_args, +): + """Sync dependencies with optional UV_INDEX support""" + args = args or [] + + # Check if help was requested + if "--help" in args or "-h" in args: + # Show our custom help instead of passing to uv + typer.echo(ctx.get_help()) + return + + if index: + os.environ["UV_INDEX_URL"] = index + logger.info(f"Using provided UV_INDEX_URL: {index}") + + # Build the uv sync command + cmd = ["uv", "sync"] + + # Add group if specified + if group: + cmd.extend(["--group", group]) + logger.info(f"Using dependency group: {group}") + + # Add any additional arguments + cmd.extend(args) + + try: + result = subprocess.run(cmd, check=True) + sys.exit(result.returncode) + except subprocess.CalledProcessError as e: + logger.error(f"uv sync failed with exit code {e.returncode}") + sys.exit(e.returncode) + except FileNotFoundError: + logger.error("uv command not found. Please install uv first.") + sys.exit(1) + + +add_args = typer.Argument(None, help="Additional arguments to pass to uv add") + + +@uv.command() +def add( + ctx: typer.Context, + index: str | None = typer.Option( + None, "--index", "-i", help="UV index URL to use for add" + ), + args: list[str] = add_args, +): + """Add dependencies with optional UV_INDEX support""" + + args = args or [] + + # Check if help was requested + if "--help" in args or "-h" in args: + # Show our custom help instead of passing to uv + typer.echo(ctx.get_help()) + return + + if index: + os.environ["UV_INDEX_URL"] = index + logger.info(f"Using provided UV_INDEX_URL: {index}") + + # Build the uv add command + cmd = ["uv", "add"] + (args or []) + + try: + result = subprocess.run(cmd, check=True) + sys.exit(result.returncode) + except subprocess.CalledProcessError as e: + logger.error(f"uv add failed with exit code {e.returncode}") + sys.exit(e.returncode) + except FileNotFoundError: + logger.error("uv command not found. Please install uv first.") + sys.exit(1) + + +run_args = typer.Argument(None, help="Arguments to pass to uv") + + +@uv.command() +def run( + ctx: typer.Context, + args: list[str] = run_args, +): + """Run any uv command with arguments""" + if not args: + # If no arguments provided, show help + typer.echo(ctx.get_help()) + return + + # Build the uv command + cmd = ["uv"] + args + + try: + result = subprocess.run(cmd, check=True) + sys.exit(result.returncode) + except subprocess.CalledProcessError as e: + logger.error(f"uv command failed with exit code {e.returncode}") + sys.exit(e.returncode) + except FileNotFoundError: + logger.error("uv command not found. Please install uv first.") + sys.exit(1) diff --git a/src/agentex/lib/cli/debug/__init__.py b/src/agentex/lib/cli/debug/__init__.py new file mode 100644 index 000000000..764b3565f --- /dev/null +++ b/src/agentex/lib/cli/debug/__init__.py @@ -0,0 +1,15 @@ +""" +Debug functionality for AgentEx CLI + +Provides debug support for temporal workers and ACP servers during local development. +""" + +from .debug_config import DebugMode, DebugConfig +from .debug_handlers import start_acp_server_debug, start_temporal_worker_debug + +__all__ = [ + "DebugConfig", + "DebugMode", + "start_acp_server_debug", + "start_temporal_worker_debug", +] \ No newline at end of file diff --git a/src/agentex/lib/cli/debug/debug_config.py b/src/agentex/lib/cli/debug/debug_config.py new file mode 100644 index 000000000..3b30e68e2 --- /dev/null +++ b/src/agentex/lib/cli/debug/debug_config.py @@ -0,0 +1,115 @@ +""" +Debug configuration models for AgentEx CLI debugging. +""" + +import socket +from enum import Enum + +from agentex.lib.utils.model_utils import BaseModel + + +class DebugMode(str, Enum): + """Debug mode options""" + WORKER = "worker" + ACP = "acp" + BOTH = "both" + NONE = "none" + + +class DebugConfig(BaseModel): + """Configuration for debug mode""" + + enabled: bool = False + mode: DebugMode = DebugMode.NONE + port: int = 5678 + wait_for_attach: bool = False + auto_port: bool = True # Automatically find available port if specified port is busy + + @classmethod + def create_worker_debug( + cls, + port: int = 5678, + wait_for_attach: bool = False, + auto_port: bool = True + ) -> "DebugConfig": + """Create debug config for worker debugging""" + return cls( + enabled=True, + mode=DebugMode.WORKER, + port=port, + wait_for_attach=wait_for_attach, + auto_port=auto_port + ) + + @classmethod + def create_acp_debug( + cls, + port: int = 5679, + wait_for_attach: bool = False, + auto_port: bool = True + ) -> "DebugConfig": + """Create debug config for ACP debugging""" + return cls( + enabled=True, + mode=DebugMode.ACP, + port=port, + wait_for_attach=wait_for_attach, + auto_port=auto_port + ) + + @classmethod + def create_both_debug( + cls, + worker_port: int = 5678, + _acp_port: int = 5679, + wait_for_attach: bool = False, + auto_port: bool = True + ) -> "DebugConfig": + """Create debug config for both worker and ACP debugging""" + return cls( + enabled=True, + mode=DebugMode.BOTH, + port=worker_port, # Primary port for worker + wait_for_attach=wait_for_attach, + auto_port=auto_port + ) + + def should_debug_worker(self) -> bool: + """Check if worker should be debugged""" + return self.enabled and self.mode in (DebugMode.WORKER, DebugMode.BOTH) + + def should_debug_acp(self) -> bool: + """Check if ACP should be debugged""" + return self.enabled and self.mode in (DebugMode.ACP, DebugMode.BOTH) + + def get_worker_port(self) -> int: + """Get port for worker debugging""" + return self.port + + def get_acp_port(self) -> int: + """Get port for ACP debugging""" + if self.mode == DebugMode.BOTH: + return self.port + 1 # Use port + 1 for ACP when debugging both + return self.port + + +def find_available_port(start_port: int = 5678, max_attempts: int = 10) -> int: + """Find an available port starting from start_port""" + for port in range(start_port, start_port + max_attempts): + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(('localhost', port)) + return port + except OSError: + continue + + # If we can't find an available port, just return the start port + # and let the debug server handle the error + return start_port + + +def resolve_debug_port(config: DebugConfig, target_port: int) -> int: + """Resolve the actual port to use for debugging""" + if config.auto_port: + return find_available_port(target_port) + return target_port \ No newline at end of file diff --git a/src/agentex/lib/cli/debug/debug_handlers.py b/src/agentex/lib/cli/debug/debug_handlers.py new file mode 100644 index 000000000..a27d682cd --- /dev/null +++ b/src/agentex/lib/cli/debug/debug_handlers.py @@ -0,0 +1,179 @@ +""" +Debug process handlers for AgentEx CLI. + +Provides debug-enabled versions of ACP server and temporal worker startup. +""" + +import sys +import asyncio +import asyncio.subprocess +from typing import TYPE_CHECKING, Dict +from pathlib import Path + +from rich.console import Console + +if TYPE_CHECKING: + 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 + +logger = make_logger(__name__) +console = Console() + + +async def start_temporal_worker_debug( + worker_path: Path, + env: Dict[str, str], + debug_config: DebugConfig +): + """Start temporal worker with debug support""" + + if not debug_config.should_debug_worker(): + raise ValueError("Debug config is not configured for worker debugging") + + # Resolve the actual debug port + debug_port = resolve_debug_port(debug_config, debug_config.get_worker_port()) + + # Add debug environment variables + debug_env = env.copy() + debug_env.update({ + "AGENTEX_DEBUG_ENABLED": "true", + "AGENTEX_DEBUG_PORT": str(debug_port), + "AGENTEX_DEBUG_WAIT_FOR_ATTACH": str(debug_config.wait_for_attach).lower(), + "AGENTEX_DEBUG_TYPE": "worker" + }) + + # Start the worker process + # For debugging, use absolute path to run_worker.py to run from workspace root + worker_script = worker_path.parent / "run_worker.py" + cmd = [sys.executable, str(worker_script)] + + console.print(f"[blue]🐛 Starting Temporal worker in debug mode[/blue]") + console.print(f"[yellow]📡 Debug server will listen on port {debug_port}[/yellow]") + console.print(f"[green]✓ VS Code should connect to: localhost:{debug_port}[/green]") + + if debug_config.wait_for_attach: + console.print(f"[yellow]⏳ Worker will wait for debugger to attach[/yellow]") + + console.print(f"[dim]💡 In your IDE: Attach to localhost:{debug_port}[/dim]") + console.print(f"[dim]🔧 If connection fails, check that VS Code launch.json uses port {debug_port}[/dim]") + + return await asyncio.create_subprocess_exec( + *cmd, + cwd=Path.cwd(), # Run from current working directory (workspace root) + env=debug_env, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + limit=SUBPROCESS_STREAM_LIMIT, + ) + + +async def start_acp_server_debug( + acp_path: Path, + port: int, + env: Dict[str, str], + debug_config: DebugConfig +): + """Start ACP server with debug support""" + + if not debug_config.should_debug_acp(): + raise ValueError("Debug config is not configured for ACP debugging") + + # Resolve the actual debug port + debug_port = resolve_debug_port(debug_config, debug_config.get_acp_port()) + + # Add debug environment variables + debug_env = env.copy() + debug_env.update({ + "AGENTEX_DEBUG_ENABLED": "true", + "AGENTEX_DEBUG_PORT": str(debug_port), + "AGENTEX_DEBUG_WAIT_FOR_ATTACH": str(debug_config.wait_for_attach).lower(), + "AGENTEX_DEBUG_TYPE": "acp" + }) + + # Disable uvicorn auto-reload in debug mode to prevent conflicts + cmd = [ + sys.executable, + "-m", + "uvicorn", + f"{acp_path.parent.name}.acp:acp", + "--port", + str(port), + "--host", + "0.0.0.0", + # Note: No --reload flag when debugging + ] + + console.print(f"[blue]🐛 Starting ACP server in debug mode[/blue]") + console.print(f"[yellow]📡 Debug server will listen on port {debug_port}[/yellow]") + + if debug_config.wait_for_attach: + console.print(f"[yellow]⏳ ACP server will wait for debugger to attach[/yellow]") + + console.print(f"[dim]💡 In your IDE: Attach to localhost:{debug_port}[/dim]") + + return await asyncio.create_subprocess_exec( + *cmd, + cwd=acp_path.parent.parent, + env=debug_env, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + limit=SUBPROCESS_STREAM_LIMIT, + ) + + +def create_debug_startup_script() -> str: + """Create a Python script snippet for debug initialization""" + return ''' +import os +import sys + +# Debug initialization for AgentEx +if os.getenv("AGENTEX_DEBUG_ENABLED") == "true": + try: + import debugpy + debug_port = int(os.getenv("AGENTEX_DEBUG_PORT", "5678")) + debug_type = os.getenv("AGENTEX_DEBUG_TYPE", "unknown") + wait_for_attach = os.getenv("AGENTEX_DEBUG_WAIT_FOR_ATTACH", "false").lower() == "true" + + # Configure debugpy + debugpy.configure(subProcess=False) + debugpy.listen(debug_port) + + print(f"🐛 [{debug_type.upper()}] Debug server listening on port {debug_port}") + + if wait_for_attach: + print(f"⏳ [{debug_type.upper()}] Waiting for debugger to attach...") + debugpy.wait_for_client() + print(f"✅ [{debug_type.upper()}] Debugger attached!") + else: + print(f"📡 [{debug_type.upper()}] Ready for debugger attachment") + + except ImportError: + print("❌ debugpy not available. Install with: pip install debugpy") + sys.exit(1) + except Exception as e: + print(f"❌ Debug setup failed: {e}") + sys.exit(1) +''' + + +def inject_debug_code_to_worker_template() -> str: + """Generate debug code to inject into worker template""" + return """ +# === DEBUG SETUP (Auto-generated by AgentEx CLI) === +""" + create_debug_startup_script() + """ +# === END DEBUG SETUP === +""" + + +def inject_debug_code_to_acp_template() -> str: + """Generate debug code to inject into ACP template""" + return """ +# === DEBUG SETUP (Auto-generated by AgentEx CLI) === +""" + create_debug_startup_script() + """ +# === END DEBUG SETUP === +""" \ No newline at end of file diff --git a/src/agentex/lib/cli/handlers/__init__.py b/src/agentex/lib/cli/handlers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/cli/handlers/agent_handlers.py b/src/agentex/lib/cli/handlers/agent_handlers.py new file mode 100644 index 000000000..3c966896e --- /dev/null +++ b/src/agentex/lib/cli/handlers/agent_handlers.py @@ -0,0 +1,284 @@ +from __future__ import annotations + +from typing import NamedTuple +from pathlib import Path + +from rich.console import Console +from python_on_whales import DockerException, docker + +from agentex.lib.cli.debug import DebugConfig +from agentex.lib.utils.logging import make_logger +from agentex.lib.cli.handlers.run_handlers import RunError, run_agent as _run_agent +from agentex.lib.sdk.config.agent_manifest import BuildContextManager, load_agent_manifest, build_context_manager + +logger = make_logger(__name__) +console = Console() + + +class DockerBuildError(Exception): + """An error occurred during docker build""" + + +class CloudBuildContext(NamedTuple): + """Contains the prepared build context for cloud builds.""" + + archive_bytes: bytes + dockerfile_path: str + agent_name: str + tag: str + image_name: str + build_context_size_kb: float + + +def build_agent( + manifest_path: str, + registry_url: str, + repository_name: str | None, + platforms: list[str], + push: bool = False, + secret: str | None = None, + tag: str | None = None, + build_args: list[str] | None = None, + cache: bool = True, +) -> str: + """Build the agent locally and optionally push to registry + + Args: + manifest_path: Path to the agent manifest file + registry_url: Registry URL for pushing the image + push: Whether to push the image to the registry + secret: Docker build secret in format 'id=secret-id,src=path-to-secret-file' + tag: Image tag to use (defaults to 'latest') + build_args: List of Docker build arguments in format 'KEY=VALUE' + cache: Whether to use the build cache. Defaults to True. Set to False to pass + --no-cache to buildx for a clean rebuild. + + Returns: + The image URL + """ + agent_manifest = load_agent_manifest(file_path=manifest_path) + build_context_root = (Path(manifest_path).parent / agent_manifest.build.context.root).resolve() + + repository_name = repository_name or agent_manifest.agent.name + + # Prepare image name + if registry_url: + image_name = f"{registry_url}/{repository_name}" + else: + image_name = repository_name + + if tag: + image_name = f"{image_name}:{tag}" + else: + image_name = f"{image_name}:latest" + + with build_context_manager(agent_manifest, build_context_root) as build_context: + logger.info(f"Building image {image_name} locally...") + + # Log build context information for debugging + logger.info(f"Build context path: {build_context.path}") + logger.info( + f"Dockerfile path: {build_context.path / build_context.dockerfile_path}" # type: ignore[operator] + ) + + try: + # Prepare build arguments + docker_build_kwargs = { + "context_path": str(build_context.path), + "file": str(build_context.path / build_context.dockerfile_path), # type: ignore[operator] + "tags": [image_name], + "platforms": platforms, + "cache": cache, # cache=False -> `docker buildx build --no-cache` + } + if not cache: + logger.info("Build cache disabled (--no-cache)") + + # Add Docker build args if provided + if build_args: + docker_build_args = {} + for arg in build_args: + if "=" in arg: + key, value = arg.split("=", 1) + docker_build_args[key] = value + else: + logger.warning(f"Invalid build arg format: {arg}. Expected KEY=VALUE") + + if docker_build_args: + docker_build_kwargs["build_args"] = docker_build_args + logger.info(f"Using build args: {list(docker_build_args.keys())}") + + # Add secret if provided + if secret: + docker_build_kwargs["secrets"] = [secret] + + if push: + # Build and push in one step for multi-platform builds + logger.info("Building and pushing image...") + docker_build_kwargs["push"] = True # Push directly after build for multi-platform + docker.buildx.build(**docker_build_kwargs) + + logger.info(f"Successfully built and pushed {image_name}") + else: + # Build only + logger.info("Building image...") + docker.buildx.build(**docker_build_kwargs) + + logger.info(f"Successfully built {image_name}") + + except DockerException as error: + error_msg = error.stderr if error.stderr else str(error) + action = "build or push" if push else "build" + logger.error(f"{action.capitalize()} failed: {error_msg}", exc_info=True) + raise DockerBuildError( + f"Docker {action} failed: {error_msg}\n" + f"Build context: {build_context.path}\n" + f"Dockerfile path: {build_context.dockerfile_path}" + ) from error + + return image_name + + +def run_agent(manifest_path: str, debug_config: "DebugConfig | None" = None): + """Run an agent locally from the given manifest""" + import sys + import signal + import asyncio + + # Flag to track if we're shutting down + shutting_down = False + + def signal_handler(signum, _frame): + """Handle signals by raising KeyboardInterrupt""" + nonlocal shutting_down + if shutting_down: + # If we're already shutting down and get another signal, force exit + logger.info(f"Force exit on signal {signum}") + sys.exit(1) + + shutting_down = True + logger.info(f"Received signal {signum}, shutting down...") + raise KeyboardInterrupt() + + # Set up signal handling for the main thread + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + try: + asyncio.run(_run_agent(manifest_path, debug_config)) + except KeyboardInterrupt: + logger.info("Shutdown completed.") + sys.exit(0) + except RunError as e: + raise RuntimeError(str(e)) from e + + +def parse_build_args(build_args: list[str] | None) -> dict[str, str]: + """Parse build arguments from KEY=VALUE format to a dictionary. + + Args: + build_args: List of build arguments in KEY=VALUE format + + Returns: + Dictionary mapping keys to values + """ + result: dict[str, str] = {} + if not build_args: + return result + + for arg in build_args: + if "=" in arg: + key, value = arg.split("=", 1) + result[key] = value + else: + logger.warning(f"Invalid build arg format: {arg}. Expected KEY=VALUE") + + return result + + +def prepare_cloud_build_context( + manifest_path: str, + tag: str | None = None, + build_args: list[str] | None = None, +) -> CloudBuildContext: + """Prepare the build context for cloud-based container builds. + + Reads the manifest, prepares the build context by copying files according to + the include_paths and dockerignore, then creates a compressed tar.gz archive + ready for upload to a cloud build service. + + Args: + manifest_path: Path to the agent manifest file + tag: Image tag override (if None, reads from manifest's deployment.image.tag) + build_args: List of build arguments in KEY=VALUE format + + Returns: + CloudBuildContext containing the archive bytes, dockerfile path, and metadata + """ + agent_manifest = load_agent_manifest(file_path=manifest_path) + build_context_root = (Path(manifest_path).parent / agent_manifest.build.context.root).resolve() + + agent_name = agent_manifest.agent.name + dockerfile_path = agent_manifest.build.context.dockerfile + + # Validate that the Dockerfile exists + full_dockerfile_path = build_context_root / dockerfile_path + if not full_dockerfile_path.exists(): + raise FileNotFoundError( + f"Dockerfile not found at: {full_dockerfile_path}\n" + f"Check that 'build.context.dockerfile' in your manifest points to an existing file." + ) + if not full_dockerfile_path.is_file(): + raise ValueError( + f"Dockerfile path is not a file: {full_dockerfile_path}\n" + f"'build.context.dockerfile' must point to a file, not a directory." + ) + + # Get tag and repository from manifest if not provided + if tag is None: + if agent_manifest.deployment and agent_manifest.deployment.image: + tag = agent_manifest.deployment.image.tag + else: + tag = "latest" + + # Get repository name from manifest (just the repo name, not the full registry URL) + if agent_manifest.deployment and agent_manifest.deployment.image: + repository = agent_manifest.deployment.image.repository + if repository: + # Extract just the repo name (last part after any slashes) + image_name = repository.split("/")[-1] + else: + image_name = "" + else: + image_name = "" + + logger.info(f"Agent: {agent_name}") + logger.info(f"Image name: {image_name}") + logger.info(f"Build context root: {build_context_root}") + logger.info(f"Dockerfile: {dockerfile_path}") + logger.info(f"Tag: {tag}") + + if agent_manifest.build.context.include_paths: + logger.info(f"Include paths: {agent_manifest.build.context.include_paths}") + + parsed_build_args = parse_build_args(build_args) + if parsed_build_args: + logger.info(f"Build args: {list(parsed_build_args.keys())}") + + logger.info("Preparing build context...") + + with build_context_manager(agent_manifest, build_context_root) as build_context: + # Compress the prepared context using the static zipped method + with BuildContextManager.zipped(root_path=build_context.path) as archive_buffer: + archive_bytes = archive_buffer.read() + + build_context_size_kb = len(archive_bytes) / 1024 + logger.info(f"Build context size: {build_context_size_kb:.1f} KB") + + return CloudBuildContext( + archive_bytes=archive_bytes, + dockerfile_path=build_context.dockerfile_path, + agent_name=agent_name, + tag=tag, + image_name=image_name, + build_context_size_kb=build_context_size_kb, + ) diff --git a/src/agentex/lib/cli/handlers/cleanup_handlers.py b/src/agentex/lib/cli/handlers/cleanup_handlers.py new file mode 100644 index 000000000..1d67b55e3 --- /dev/null +++ b/src/agentex/lib/cli/handlers/cleanup_handlers.py @@ -0,0 +1,183 @@ +import os +import asyncio + +from rich.console import Console + +from agentex import Agentex +from agentex.lib.utils.logging import make_logger + +# Import Temporal client for direct workflow termination +try: + from temporalio.client import Client as TemporalClient # type: ignore +except ImportError: + TemporalClient = None + +logger = make_logger(__name__) +console = Console() + + +def should_cleanup_on_restart() -> bool: + """ + Check if cleanup should be performed on restart. + + Returns True if: + - ENVIRONMENT=development, OR + - AUTO_CLEANUP_ON_RESTART=true + """ + env = os.getenv("ENVIRONMENT", "").lower() + auto_cleanup = os.getenv("AUTO_CLEANUP_ON_RESTART", "true").lower() + + return env == "development" or auto_cleanup == "true" + + +def cleanup_agent_workflows( + agent_name: str, + force: bool = False, + development_only: bool = True +) -> None: + """ + Clean up all running workflows for an agent during development. + + This cancels (graceful) all running tasks for the specified agent. + When force=True, directly terminates workflows via Temporal client. + + Args: + agent_name: Name of the agent to cleanup workflows for + force: If True, directly terminate workflows via Temporal client + development_only: Only perform cleanup in development environment + """ + + # Safety check - only run in development mode by default + if development_only and not force and not should_cleanup_on_restart(): + logger.warning("Cleanup skipped - not in development mode. Use --force to override.") + return + + method = "terminate (direct)" if force else "cancel (via agent)" + console.print(f"[blue]Cleaning up workflows for agent '{agent_name}' using {method}...[/blue]") + + try: + client = Agentex() + + # Get all running tasks + if agent_name: + all_tasks = client.tasks.list(agent_name=agent_name) + else: + all_tasks = client.tasks.list() + running_tasks = [task for task in all_tasks if hasattr(task, 'status') and task.status == "RUNNING"] + + if not running_tasks: + console.print("[yellow]No running tasks found[/yellow]") + return + + console.print(f"[blue]Cleaning up {len(running_tasks)} running task(s) for agent '{agent_name}'...[/blue]") + + successful_cleanups = 0 + total_tasks = len(running_tasks) + + for task in running_tasks: + task_cleanup_success = False + + if force: + # Force mode: Do both graceful RPC cancellation AND direct Temporal termination + rpc_success = False + temporal_success = False + + try: + # First: Graceful cancellation via agent RPC (handles database/agent cleanup) + cleanup_single_task(client, agent_name, task.id) + logger.debug(f"Completed RPC cancellation for task {task.id}") + rpc_success = True + except Exception as e: + logger.warning(f"RPC cancellation failed for task {task.id}: {e}") + + try: + # Second: Direct Temporal termination (ensures workflow is forcefully stopped) + asyncio.run(cleanup_single_task_direct(task.id)) + logger.debug(f"Completed Temporal termination for task {task.id}") + temporal_success = True + except Exception as e: + logger.warning(f"Temporal termination failed for task {task.id}: {e}") + + # Count as success if either operation succeeded + task_cleanup_success = rpc_success or temporal_success + + else: + # Normal mode: Only graceful cancellation via agent RPC + try: + cleanup_single_task(client, agent_name, task.id) + task_cleanup_success = True + except Exception as e: + logger.error(f"Failed to cleanup task {task.id}: {e}") + task_cleanup_success = False + + if task_cleanup_success: + successful_cleanups += 1 + logger.debug(f"Successfully cleaned up task {task.id}") + else: + logger.error(f"Failed to cleanup task {task.id}") + # Don't increment successful_cleanups for actual failures + + if successful_cleanups == total_tasks: + console.print(f"[green]✓ Successfully cleaned up all {successful_cleanups} task(s) for agent '{agent_name}'[/green]") + elif successful_cleanups > 0: + console.print(f"[yellow]⚠ Successfully cleaned up {successful_cleanups}/{total_tasks} task(s) for agent '{agent_name}'[/yellow]") + else: + console.print(f"[red]✗ Failed to cleanup any tasks for agent '{agent_name}'[/red]") + + except Exception as e: + console.print(f"[red]Agent workflow cleanup failed: {str(e)}[/red]") + logger.exception("Agent workflow cleanup failed") + raise + + +async def cleanup_single_task_direct(task_id: str) -> None: + """ + Directly terminate a workflow using Temporal client. + + Args: + task_id: ID of the task (used as workflow_id) + """ + if TemporalClient is None: + raise ImportError("temporalio package not available for direct workflow termination") + + try: + # Connect to Temporal server (assumes default localhost:7233) + client = await TemporalClient.connect("localhost:7233") # type: ignore + + # Get workflow handle and terminate + handle = client.get_workflow_handle(workflow_id=task_id) # type: ignore + await handle.terminate() # type: ignore + + logger.debug(f"Successfully terminated workflow {task_id} via Temporal client") + + except Exception as e: + # Check if the workflow was already completed - this is actually a success case + if "workflow execution already completed" in str(e).lower(): + logger.debug(f"Workflow {task_id} was already completed - no termination needed") + return # Don't raise an exception for this case + + logger.error(f"Failed to terminate workflow {task_id} via Temporal client: {e}") + raise + + +def cleanup_single_task(client: Agentex, agent_name: str, task_id: str) -> None: + """ + Clean up a single task/workflow using agent RPC cancel method. + + Args: + client: Agentex client instance + agent_name: Name of the agent that owns the task + task_id: ID of the task to cleanup + """ + try: + # Use the agent RPC method to cancel the task + client.agents.rpc_by_name( + agent_name=agent_name, + method="task/cancel", + params={"task_id": task_id} + ) + logger.debug(f"Successfully cancelled task {task_id} via agent '{agent_name}'") + + except Exception as e: + logger.warning(f"RPC task/cancel failed for task {task_id}: {e}") + raise \ No newline at end of file diff --git a/src/agentex/lib/cli/handlers/deploy_handlers.py b/src/agentex/lib/cli/handlers/deploy_handlers.py new file mode 100644 index 000000000..605d91709 --- /dev/null +++ b/src/agentex/lib/cli/handlers/deploy_handlers.py @@ -0,0 +1,588 @@ +from __future__ import annotations + +import os +import tempfile +import subprocess +from typing import Any +from pathlib import Path + +import yaml +from pydantic import Field, BaseModel +from rich.console import Console + +from agentex.lib.utils.logging import make_logger +from agentex.config.agent_config import AgentConfig +from agentex.config.agent_manifest import AgentManifest +from agentex.lib.cli.utils.exceptions import HelmError, DeploymentError +from agentex.lib.cli.utils.path_utils import PathResolutionError, calculate_docker_acp_module +from agentex.config.environment_config import OciRegistryConfig, AgentEnvironmentConfig +from agentex.lib.environment_variables import EnvVarKeys +from agentex.lib.cli.utils.kubectl_utils import check_and_switch_cluster_context +from agentex.lib.sdk.config.agent_manifest import load_agent_manifest +from agentex.lib.sdk.config.environment_config import load_environments_config_from_manifest_dir + +logger = make_logger(__name__) +console = Console() + +TEMPORAL_WORKER_KEY = "temporal-worker" +DEFAULT_HELM_CHART_VERSION = "0.1.9" + + +class InputDeployOverrides(BaseModel): + repository: str | None = Field(default=None, description="Override the repository for deployment") + image_tag: str | None = Field(default=None, description="Override the image tag for deployment") + + +def check_helm_installed() -> bool: + """Check if helm is installed and available""" + try: + result = subprocess.run(["helm", "version", "--short"], capture_output=True, text=True, check=True) + logger.info(f"Helm version: {result.stdout.strip()}") + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + +def add_helm_repo(helm_repository_name: str, helm_repository_url: str) -> None: + """Add the agentex helm repository if not already added (classic mode)""" + try: + # Check if repo already exists + result = subprocess.run(["helm", "repo", "list"], capture_output=True, text=True, check=True) + + if helm_repository_name not in result.stdout: + console.print("Adding agentex helm repository...") + subprocess.run( + [ + "helm", + "repo", + "add", + helm_repository_name, + helm_repository_url, + ], + check=True, + ) + else: + logger.info("Helm repository already exists. Running update...") + + subprocess.run(["helm", "repo", "update"], check=True) + console.print("[green]✓[/green] Helm repository update successfully") + + except subprocess.CalledProcessError as e: + raise HelmError(f"Failed to add helm repository: {e}") from e + + +def login_to_gar_registry(oci_registry: str) -> None: + """Auto-login to Google Artifact Registry using gcloud credentials. + + Args: + oci_registry: The GAR registry URL (e.g., 'us-west1-docker.pkg.dev/project-id/repo-name') + """ + try: + # Extract the registry host (e.g., 'us-west1-docker.pkg.dev') + registry_host = oci_registry.split("/")[0] + + # Get access token from gcloud + console.print(f"[blue]ℹ[/blue] Authenticating with Google Artifact Registry: {registry_host}") + result = subprocess.run( + ["gcloud", "auth", "print-access-token"], + capture_output=True, + text=True, + check=True, + ) + access_token = result.stdout.strip() + + # Login to helm registry using the access token + subprocess.run( + [ + "helm", + "registry", + "login", + registry_host, + "--username", + "oauth2accesstoken", + "--password-stdin", + ], + input=access_token, + text=True, + check=True, + ) + console.print(f"[green]✓[/green] Authenticated with GAR: {registry_host}") + + except subprocess.CalledProcessError as e: + raise HelmError( + f"Failed to authenticate with Google Artifact Registry: {e}\n" + "Ensure you are logged in with 'gcloud auth login' and have access to the registry." + ) from e + except FileNotFoundError: + raise HelmError( + "gcloud CLI not found. Please install the Google Cloud SDK: https://cloud.google.com/sdk/docs/install" + ) from None + + +def get_latest_gar_chart_version(oci_registry: str, chart_name: str = "agentex-agent") -> str: + """Fetch the latest version of a Helm chart from Google Artifact Registry. + + GAR stores Helm chart versions as tags (e.g., '0.1.9'), not as versions (which are SHA digests). + This function lists tags sorted by creation time and returns the most recent one. + + Args: + oci_registry: The GAR registry URL (e.g., 'us-west1-docker.pkg.dev/project-id/repo-name') + chart_name: Name of the Helm chart + + Returns: + The latest version string (e.g., '0.2.0') + """ + try: + # Parse the OCI registry URL to extract components + # Format: REGION-docker.pkg.dev/PROJECT/REPOSITORY + parts = oci_registry.split("/") + if len(parts) < 3: + raise HelmError( + f"Invalid OCI registry format: {oci_registry}. " + "Expected format: REGION-docker.pkg.dev/PROJECT/REPOSITORY" + ) + + location = parts[0].replace("-docker.pkg.dev", "") + project = parts[1] + repository = parts[2] + + console.print(f"[blue]ℹ[/blue] Fetching latest chart version from GAR...") + + # Use gcloud to list tags (not versions - versions are SHA digests) + # Tags contain the semantic versions like '0.1.9' + result = subprocess.run( + [ + "gcloud", + "artifacts", + "tags", + "list", + f"--repository={repository}", + f"--location={location}", + f"--project={project}", + f"--package={chart_name}", + "--sort-by=~createTime", + "--limit=1", + "--format=value(tag)", + ], + capture_output=True, + text=True, + check=True, + ) + + output = result.stdout.strip() + if not output: + raise HelmError(f"No tags found for chart '{chart_name}' in {oci_registry}") + + # The output is the tag name (semantic version) + version = output + console.print(f"[green]✓[/green] Latest chart version: {version}") + return version + + except subprocess.CalledProcessError as e: + raise HelmError( + f"Failed to fetch chart tags from GAR: {e.stderr}\nEnsure you have access to the Artifact Registry." + ) from e + except FileNotFoundError: + raise HelmError( + "gcloud CLI not found. Please install the Google Cloud SDK: https://cloud.google.com/sdk/docs/install" + ) from None + + +def resolve_chart( + oci_registry: OciRegistryConfig | None, + helm_repository_name: str | None, + use_latest_chart: bool, + chart_name: str = "agentex-agent", +) -> tuple[str, str]: + """Resolve the chart reference and version based on the deployment mode. + + For OCI mode, builds an oci:// reference and resolves version from: + --use-latest-chart (GAR only) > oci_registry.chart_version > default. + For classic mode, builds a repo/chart reference and uses default version. + + Returns: + (chart_reference, chart_version) + """ + if oci_registry: + chart_reference = f"oci://{oci_registry.url}/{chart_name}" + + if use_latest_chart: + if oci_registry.provider != "gar": + console.print( + "[yellow]⚠[/yellow] --use-latest-chart only works with GAR provider (provider: gar), using default version" + ) + chart_version = DEFAULT_HELM_CHART_VERSION + else: + chart_version = get_latest_gar_chart_version(oci_registry.url) + elif oci_registry.chart_version: + chart_version = oci_registry.chart_version + else: + chart_version = DEFAULT_HELM_CHART_VERSION + else: + if not helm_repository_name: + raise HelmError("Helm repository name is required for classic mode") + chart_reference = f"{helm_repository_name}/{chart_name}" + + if use_latest_chart: + console.print("[yellow]⚠[/yellow] --use-latest-chart only works with OCI registries, using default version") + chart_version = DEFAULT_HELM_CHART_VERSION + + console.print(f"[blue]ℹ[/blue] Using Helm chart version: {chart_version}") + return chart_reference, chart_version + + +def convert_env_vars_dict_to_list(env_vars: dict[str, str]) -> list[dict[str, str]]: + """Convert a dictionary of environment variables to a list of dictionaries""" + return [{"name": key, "value": value} for key, value in env_vars.items()] + + +def add_acp_command_to_helm_values(helm_values: dict[str, Any], manifest: AgentManifest, manifest_path: str) -> None: + """Add dynamic ACP command to helm values based on manifest configuration""" + try: + docker_acp_module = calculate_docker_acp_module(manifest, manifest_path) + # Create the uvicorn command with the correct module path + helm_values["command"] = ["uvicorn", f"{docker_acp_module}:acp", "--host", "0.0.0.0", "--port", "8000"] + logger.info(f"Using dynamic ACP command: uvicorn {docker_acp_module}:acp") + except (PathResolutionError, Exception) as e: + # Fallback to default command structure + logger.warning(f"Could not calculate dynamic ACP module ({e}), using default: project.acp") + helm_values["command"] = ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + + +def merge_deployment_configs( + manifest: AgentManifest, + agent_env_config: AgentEnvironmentConfig | None, + deploy_overrides: InputDeployOverrides, + manifest_path: str, +) -> dict[str, Any]: + agent_config: AgentConfig = manifest.agent + + """Merge global deployment config with environment-specific overrides into helm values""" + if not manifest.deployment: + raise DeploymentError("No deployment configuration found in manifest") + + repository = deploy_overrides.repository or manifest.deployment.image.repository + image_tag = deploy_overrides.image_tag or manifest.deployment.image.tag + + if not repository or not image_tag: + raise DeploymentError("Repository and image tag are required") + + # Start with global configuration + helm_values: dict[str, Any] = { + "global": { + "image": { + "repository": repository, + "tag": image_tag, + "pullPolicy": "IfNotPresent", + }, + "agent": { + "name": manifest.agent.name, + "description": manifest.agent.description, + "acp_type": manifest.agent.acp_type, + }, + }, + "replicaCount": manifest.deployment.global_config.replicaCount, + "resources": { + "requests": { + "cpu": manifest.deployment.global_config.resources.requests.cpu, + "memory": manifest.deployment.global_config.resources.requests.memory, + }, + "limits": { + "cpu": manifest.deployment.global_config.resources.limits.cpu, + "memory": manifest.deployment.global_config.resources.limits.memory, + }, + }, + # Enable autoscaling by default for production deployments + "autoscaling": { + "enabled": True, + "minReplicas": 1, + "maxReplicas": 10, + "targetCPUUtilizationPercentage": 50, + }, + } + + # Handle temporal configuration using new helper methods + if agent_config.is_temporal_agent(): + temporal_config = agent_config.get_temporal_workflow_config() + if temporal_config: + helm_values[TEMPORAL_WORKER_KEY] = { + "enabled": True, + # Enable autoscaling for temporal workers as well + "autoscaling": { + "enabled": True, + "minReplicas": 1, + "maxReplicas": 10, + "targetCPUUtilizationPercentage": 50, + }, + } + helm_values["global"]["workflow"] = { + "name": temporal_config.name, + "taskQueue": temporal_config.queue_name, + } + + # Collect all environment variables with proper precedence + # Priority: manifest -> environments.yaml -> secrets (highest) + all_env_vars: dict[str, str] = {} + secret_env_vars: list[dict[str, str]] = [] + + # Start with agent_config env vars from manifest + if agent_config.env: + all_env_vars.update(agent_config.env) + + # Override with environment config env vars if they exist + if agent_env_config and agent_env_config.helm_overrides and "env" in agent_env_config.helm_overrides: + env_overrides = agent_env_config.helm_overrides["env"] + if isinstance(env_overrides, list): + # Convert list format to dict for easier merging + env_override_dict: dict[str, str] = {} + for env_var in env_overrides: + if isinstance(env_var, dict) and "name" in env_var and "value" in env_var: + env_override_dict[str(env_var["name"])] = str(env_var["value"]) + all_env_vars.update(env_override_dict) + + # Handle credentials and check for conflicts + if agent_config.credentials: + for credential in agent_config.credentials: + # Handle both CredentialMapping objects and legacy dict format + if isinstance(credential, dict): + env_var_name = credential["env_var_name"] + secret_name = credential["secret_name"] + secret_key = credential["secret_key"] + else: + env_var_name = credential.env_var_name + secret_name = credential.secret_name + secret_key = credential.secret_key + + # Check if the environment variable name conflicts with existing env vars + if env_var_name in all_env_vars: + logger.warning( + f"Environment variable '{env_var_name}' is defined in both " + f"env and secretEnvVars. The secret value will take precedence." + ) + # Remove from regular env vars since secret takes precedence + del all_env_vars[env_var_name] + + secret_env_vars.append( + { + "name": env_var_name, + "secretName": secret_name, + "secretKey": secret_key, + } + ) + + # Apply agent environment configuration overrides + if agent_env_config: + # Add auth principal env var if environment config is set + if agent_env_config.auth: + from agentex.lib.cli.utils.auth_utils import _encode_principal_context_from_env_config + + encoded_principal = _encode_principal_context_from_env_config(agent_env_config.auth) + logger.info(f"Encoding auth principal from {agent_env_config.auth}") + if encoded_principal: + all_env_vars[EnvVarKeys.AUTH_PRINCIPAL_B64.value] = encoded_principal + else: + raise DeploymentError(f"Auth principal unable to be encoded for agent_env_config: {agent_env_config}") + + logger.info(f"Defined agent helm overrides: {agent_env_config.helm_overrides}") + logger.info(f"Before-merge helm values: {helm_values}") + if agent_env_config.helm_overrides: + _deep_merge(helm_values, agent_env_config.helm_overrides) + logger.info(f"After-merge helm values: {helm_values}") + + # Set final environment variables + # Environment variable precedence: manifest -> environments.yaml -> secrets (highest) + if all_env_vars: + helm_values["env"] = convert_env_vars_dict_to_list(all_env_vars) + + if secret_env_vars: + helm_values["secretEnvVars"] = secret_env_vars + + # Set environment variables for temporal worker if enabled + if TEMPORAL_WORKER_KEY in helm_values: + if all_env_vars: + helm_values[TEMPORAL_WORKER_KEY]["env"] = convert_env_vars_dict_to_list(all_env_vars) + if secret_env_vars: + helm_values[TEMPORAL_WORKER_KEY]["secretEnvVars"] = secret_env_vars + + # Handle image pull secrets + if manifest.deployment and manifest.deployment.imagePullSecrets: + pull_secrets = [pull_secret.model_dump() for pull_secret in manifest.deployment.imagePullSecrets] + helm_values["global"]["imagePullSecrets"] = pull_secrets + helm_values["imagePullSecrets"] = pull_secrets + + # Add dynamic ACP command based on manifest configuration if command is not set in helm overrides + helm_overrides_command = ( + agent_env_config and agent_env_config.helm_overrides and "command" in agent_env_config.helm_overrides + ) + if not helm_overrides_command: + add_acp_command_to_helm_values(helm_values, manifest, manifest_path) + + logger.info("Deploying with the following helm values: %s", helm_values) + return helm_values + + +def _deep_merge(base_dict: dict[str, Any], override_dict: dict[str, Any]) -> None: + """Deep merge override_dict into base_dict""" + for key, value in override_dict.items(): + if key in base_dict and isinstance(base_dict[key], dict) and isinstance(value, dict): + _deep_merge(base_dict[key], value) + else: + base_dict[key] = value + + +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: + yaml.dump(helm_values, f, default_flow_style=False) + return f.name + + +def deploy_agent( + manifest_path: str, + cluster_name: str, + namespace: str, + deploy_overrides: InputDeployOverrides, + environment_name: str | None = None, + use_latest_chart: bool = False, +) -> None: + """Deploy an agent using helm + + Args: + manifest_path: Path to the agent manifest file + cluster_name: Target Kubernetes cluster name + namespace: Kubernetes namespace to deploy to + deploy_overrides: Image repository/tag overrides + environment_name: Environment name from environments.yaml + use_latest_chart: If True, fetch and use the latest chart version from OCI registry (OCI mode only) + """ + + # Validate prerequisites + if not check_helm_installed(): + raise DeploymentError("Helm is not installed. Please install helm first.") + + # Switch to the specified cluster context + check_and_switch_cluster_context(cluster_name) + + manifest = load_agent_manifest(file_path=manifest_path) + + # Load agent environment configuration + agent_env_config = None + if environment_name: + manifest_dir = Path(manifest_path).parent + environments_config = load_environments_config_from_manifest_dir(manifest_dir) + if environments_config: + agent_env_config = environments_config.get_config_for_env(environment_name) + console.print(f"[green]✓[/green] Using environment config: {environment_name}") + else: + console.print(f"[yellow]⚠[/yellow] No environments.yaml found, skipping environment-specific config") + + # Determine deployment mode: OCI registry or classic helm repo + oci_registry = agent_env_config.oci_registry if agent_env_config else None + helm_repository_name: str | None = None + + if oci_registry: + console.print(f"[blue]ℹ[/blue] Using OCI Helm registry: {oci_registry.url}") + + # Only auto-authenticate for GAR provider + if oci_registry.provider == "gar": + login_to_gar_registry(oci_registry.url) + else: + console.print( + "[blue]ℹ[/blue] Skipping auto-authentication (no provider specified, assuming already authenticated)" + ) + else: + if agent_env_config: + helm_repository_name = agent_env_config.helm_repository_name + helm_repository_url = agent_env_config.helm_repository_url + else: + helm_repository_name = "scale-egp" + helm_repository_url = "https://scale-egp-helm-charts-us-west-2.s3.amazonaws.com/charts" + # Add helm repository/update (classic mode only) + add_helm_repo(helm_repository_name, helm_repository_url) + + # Resolve chart reference and version in one step + chart_reference, chart_version = resolve_chart( + oci_registry=oci_registry, + helm_repository_name=helm_repository_name, + use_latest_chart=use_latest_chart, + ) + + # Merge configurations + helm_values = merge_deployment_configs(manifest, agent_env_config, deploy_overrides, manifest_path) + + # Create values file + values_file = create_helm_values_file(helm_values) + + try: + agent_name = manifest.agent.name + release_name = agent_name + + console.print( + f"Deploying agent [bold]{agent_name}[/bold] to cluster [bold]{cluster_name}[/bold] in namespace [bold]{namespace}[/bold]" + ) + + # Check if release exists + try: + subprocess.run( + ["helm", "status", release_name, "-n", namespace], + capture_output=True, + check=True, + ) + + # Release exists, do upgrade + console.print("Existing deployment found, upgrading...") + command = [ + "helm", + "upgrade", + release_name, + chart_reference, + "--version", + chart_version, + "-f", + values_file, + "-n", + namespace, + "--atomic", + "--timeout", + "10m", + ] + console.print(f"[blue]ℹ[/blue] Running command: {' '.join(command)}") + subprocess.run(command, check=True) + console.print("[green]✓[/green] Agent upgraded successfully") + + except subprocess.CalledProcessError: + # Release doesn't exist, do install + console.print("Installing new deployment...") + command = [ + "helm", + "install", + release_name, + chart_reference, + "--version", + chart_version, + "-f", + values_file, + "-n", + namespace, + "--create-namespace", + "--atomic", + "--timeout", + "10m", + ] + console.print(f"[blue]ℹ[/blue] Running command: {' '.join(command)}") + subprocess.run(command, check=True) + console.print("[green]✓[/green] Agent deployed successfully") + + # Show success message with helpful commands + console.print("\n[green]🎉 Deployment completed successfully![/green]") + console.print(f"[blue]Check deployment status:[/blue] helm status {release_name} -n {namespace}") + console.print(f"[blue]View logs:[/blue] kubectl logs -l app.kubernetes.io/name=agentex-agent -n {namespace}") + + except subprocess.CalledProcessError as e: + raise HelmError( + f"Helm deployment failed: {e}\n" + f"Note: Due to --atomic flag, any partial deployment has been automatically rolled back." + ) from e + finally: + # Clean up values file + os.unlink(values_file) diff --git a/src/agentex/lib/cli/handlers/run_handlers.py b/src/agentex/lib/cli/handlers/run_handlers.py new file mode 100644 index 000000000..18ee84e93 --- /dev/null +++ b/src/agentex/lib/cli/handlers/run_handlers.py @@ -0,0 +1,465 @@ +from __future__ import annotations + +import os +import sys +import asyncio +from pathlib import Path + +from rich.panel import Panel +from rich.console import Console + +# Import debug functionality +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, +) +from agentex.lib.environment_variables import EnvVarKeys +from agentex.lib.sdk.config.agent_manifest import load_agent_manifest +from agentex.lib.cli.handlers.cleanup_handlers import cleanup_agent_workflows, should_cleanup_on_restart + +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""" + + +class ProcessManager: + """Manages multiple subprocesses with proper cleanup""" + + def __init__(self): + self.processes: list[asyncio.subprocess.Process] = [] + self.shutdown_event = asyncio.Event() + + def add_process(self, process: asyncio.subprocess.Process): + """Add a process to be managed""" + self.processes.append(process) + + async def wait_for_shutdown(self): + """Wait for shutdown signal""" + await self.shutdown_event.wait() + + def shutdown(self): + """Signal shutdown and terminate all processes""" + self.shutdown_event.set() + + async def cleanup_processes(self): + """Clean up all processes""" + if not self.processes: + return + + console.print("\n[yellow]Shutting down processes...[/yellow]") + + # Send SIGTERM to all processes + for process in self.processes: + if process.returncode is None: # Process is still running + try: + process.terminate() + except ProcessLookupError: + pass # Process already terminated + + # Wait for graceful shutdown with shorter timeout + try: + await asyncio.wait_for( + asyncio.gather(*[p.wait() for p in self.processes], return_exceptions=True), + timeout=2.0, # Reduced from 5.0 seconds + ) + except TimeoutError: + # Force kill if not terminated gracefully + console.print("[yellow]Force killing unresponsive processes...[/yellow]") + for process in self.processes: + if process.returncode is None: + try: + process.kill() + await asyncio.wait_for(process.wait(), timeout=1.0) + except (ProcessLookupError, TimeoutError): + pass # Process already dead or kill failed + + console.print("[green]All processes stopped[/green]") + + +async def start_temporal_worker_with_reload( + worker_path: Path, env: dict[str, str], process_manager: ProcessManager, manifest_dir: Path +) -> asyncio.Task[None]: + """Start temporal worker with auto-reload using watchfiles""" + try: + from watchfiles import awatch + except ImportError: + console.print("[yellow]watchfiles not installed, falling back to basic worker start[/yellow]") + console.print("[dim]Install with: pip install watchfiles[/dim]") + # Fallback to regular worker without reload + worker_process = await start_temporal_worker(worker_path, env, manifest_dir) + process_manager.add_process(worker_process) + return asyncio.create_task(stream_process_output(worker_process, "WORKER")) + + async def worker_runner() -> None: + current_process: asyncio.subprocess.Process | None = None + output_task: asyncio.Task[None] | None = None + + console.print(f"[blue]Starting Temporal worker with auto-reload from {worker_path}...[/blue]") + + async def start_worker() -> asyncio.subprocess.Process: + nonlocal current_process, output_task + + # PRE-RESTART CLEANUP - NEW! + if current_process is not None: + # Extract agent name from worker path for cleanup + + agent_name = env.get("AGENT_NAME") + console.print(f"FOUND AGENT_NAME FROM ENV VARS: {agent_name} {agent_name is None}") + if agent_name is None: + agent_name = worker_path.parent.parent.name + + # Perform cleanup if configured + if should_cleanup_on_restart(): + console.print("[yellow]Cleaning up workflows before worker restart...[/yellow]") + try: + cleanup_agent_workflows(agent_name) + except Exception as e: + logger.warning(f"Cleanup failed: {e}") + console.print(f"[yellow]⚠ Cleanup failed: {str(e)}[/yellow]") + + # Clean up previous process + if current_process and current_process.returncode is None: + current_process.terminate() + try: + await asyncio.wait_for(current_process.wait(), timeout=2.0) + except asyncio.TimeoutError: + current_process.kill() + await current_process.wait() + + # Cancel previous output task + if output_task: + output_task.cancel() + try: + await output_task + except asyncio.CancelledError: + pass + + current_process = await start_temporal_worker(worker_path, env, manifest_dir) + process_manager.add_process(current_process) + console.print("[green]Temporal worker started[/green]") + return current_process + + try: + # Start initial worker + current_process = await start_worker() + if current_process: + output_task = asyncio.create_task(stream_process_output(current_process, "WORKER")) + + # Watch for file changes + async for changes in awatch(manifest_dir, recursive=True): + # Filter for Python files + py_changes = [(change, path) for change, path in changes if str(path).endswith('.py')] + + if py_changes: + changed_files = [str(Path(path).relative_to(worker_path.parent)) for _, path in py_changes] + console.print(f"[yellow]File changes detected: {changed_files}[/yellow]") + console.print("[yellow]Restarting Temporal worker...[/yellow]") + + # Restart worker (with cleanup handled in start_worker) + await start_worker() + if current_process: + output_task = asyncio.create_task(stream_process_output(current_process, "WORKER")) + + except asyncio.CancelledError: + # Clean shutdown + if output_task: + output_task.cancel() + try: + await output_task + except asyncio.CancelledError: + pass + + if current_process and current_process.returncode is None: + current_process.terminate() + try: + await asyncio.wait_for(current_process.wait(), timeout=2.0) + except asyncio.TimeoutError: + current_process.kill() + await current_process.wait() + raise + + return asyncio.create_task(worker_runner()) + + +async def start_acp_server( + acp_path: Path, port: int, env: dict[str, str], manifest_dir: Path +) -> asyncio.subprocess.Process: + """Start the ACP server process""" + # Use file path relative to manifest directory if possible + uvicorn_target = calculate_uvicorn_target_for_local(acp_path, manifest_dir) + + cmd = [ + sys.executable, + "-m", + "uvicorn", + f"{uvicorn_target}:acp", + "--reload", + "--reload-dir", + str(acp_path.parent), # Watch the project directory specifically + "--port", + str(port), + "--host", + "0.0.0.0", + ] + + console.print(f"[blue]Starting ACP server from {acp_path} on port {port}...[/blue]") + return await asyncio.create_subprocess_exec( + *cmd, + cwd=manifest_dir, # Always use manifest directory as CWD for consistency + env=env, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + limit=SUBPROCESS_STREAM_LIMIT, + ) + + +async def start_temporal_worker( + worker_path: Path, env: dict[str, str], manifest_dir: Path +) -> asyncio.subprocess.Process: + """Start the temporal worker process""" + run_worker_target = calculate_uvicorn_target_for_local(worker_path, manifest_dir) + + cmd = [sys.executable, "-m", run_worker_target] + + console.print(f"[blue]Starting Temporal worker from {worker_path}...[/blue]") + + return await asyncio.create_subprocess_exec( + *cmd, + cwd=manifest_dir, # Use worker directory as CWD for imports to work + 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. + """ + 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 + + 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 + + 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." + ) + + +async def run_agent(manifest_path: str, debug_config: "DebugConfig | None" = None): + """Run an agent locally from the given manifest""" + + # Validate manifest exists + manifest_file = Path(manifest_path) + + if not manifest_file.exists(): + raise RunError(f"Manifest file not found: {manifest_path}") + + # Parse manifest + try: + manifest = load_agent_manifest(file_path=manifest_path) + except Exception as e: + raise RunError(f"Failed to parse manifest: {str(e)}") from e + + # Get and validate file paths + try: + file_paths = get_file_paths(manifest, manifest_path) + except Exception as e: + raise RunError(str(e)) from e + + # Check if temporal agent and validate worker file + if is_temporal_agent(manifest): + if not file_paths["worker"]: + raise RunError("Temporal agent requires a worker file path to be configured") + + # Create environment for subprocesses + agent_env = create_agent_environment(manifest) + + # Setup process manager + process_manager = ProcessManager() + + try: + console.print( + Panel.fit( + f"🚀 [bold blue]Running Agent: {manifest.agent.name}[/bold blue]", + border_style="blue", + ) + ) + + # Start ACP server (with debug support if enabled) + manifest_dir = Path(manifest_path).parent + if debug_config and debug_config.should_debug_acp(): + acp_process = await start_acp_server_debug( + file_paths["acp"], manifest.local_development.agent.port, agent_env, debug_config # type: ignore[union-attr] + ) + else: + acp_process = await start_acp_server( + file_paths["acp"], manifest.local_development.agent.port, agent_env, manifest_dir # type: ignore[union-attr] + ) + process_manager.add_process(acp_process) + + # Start output streaming for ACP + acp_output_task = asyncio.create_task(stream_process_output(acp_process, "ACP")) + + tasks = [acp_output_task] + + # Start temporal worker if needed (with debug support if enabled) + if is_temporal_agent(manifest) and file_paths["worker"]: + if debug_config and debug_config.should_debug_worker(): + # In debug mode, start worker without auto-reload to prevent conflicts + worker_process = await start_temporal_worker_debug( + file_paths["worker"], agent_env, debug_config + ) + process_manager.add_process(worker_process) + worker_task = asyncio.create_task(stream_process_output(worker_process, "WORKER")) + else: + # Normal mode with auto-reload + worker_task = await start_temporal_worker_with_reload(file_paths["worker"], agent_env, process_manager, manifest_dir) + tasks.append(worker_task) + + console.print( + f"\n[green]✓ Agent running at: http://localhost:{manifest.local_development.agent.port}[/green]" # type: ignore[union-attr] + ) + console.print("[dim]Press Ctrl+C to stop[/dim]\n") + + # Wait for shutdown signal or process failure + try: + await process_manager.wait_for_shutdown() + except KeyboardInterrupt: + console.print("\n[yellow]Received shutdown signal...[/yellow]") + + # Cancel output streaming tasks + for task in tasks: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + except Exception as e: + logger.exception("Error running agent") + raise RunError(f"Failed to run agent: {str(e)}") from e + + finally: + # Ensure cleanup happens + await process_manager.cleanup_processes() + + + + + +def create_agent_environment(manifest: AgentManifest) -> dict[str, str]: + """Create environment variables for agent processes without modifying os.environ""" + # Start with current environment + env = dict(os.environ) + + agent_config = manifest.agent + + # TODO: Combine this logic with the deploy_handlers so that we can reuse the env vars + env_vars = { + "ENVIRONMENT": "development", + "TEMPORAL_ADDRESS": "localhost:7233", + "REDIS_URL": "redis://localhost:6379", + "AGENT_NAME": manifest.agent.name, + "ACP_TYPE": manifest.agent.acp_type, + "ACP_URL": f"http://{manifest.local_development.agent.host_address}", # type: ignore[union-attr] + "ACP_PORT": str(manifest.local_development.agent.port), # type: ignore[union-attr] + } + + if manifest.agent.agent_input_type: + env_vars["AGENT_INPUT_TYPE"] = manifest.agent.agent_input_type + + # Add authorization principal if set - for local development, auth is optional + from agentex.lib.cli.utils.auth_utils import _encode_principal_context + encoded_principal = _encode_principal_context(manifest) + if encoded_principal: + env_vars[EnvVarKeys.AUTH_PRINCIPAL_B64] = encoded_principal + else: + logger.info("No auth principal configured - agent will run without authentication context") + + # Add description if available + if manifest.agent.description: + env_vars["AGENT_DESCRIPTION"] = manifest.agent.description + + # Add temporal-specific variables if this is a temporal agent + if manifest.agent.is_temporal_agent(): + temporal_config = manifest.agent.get_temporal_workflow_config() + if temporal_config: + env_vars["WORKFLOW_NAME"] = temporal_config.name + env_vars["WORKFLOW_TASK_QUEUE"] = temporal_config.queue_name + + # Set health check port from temporal config + if manifest.agent.temporal and manifest.agent.temporal.health_check_port is not None: + env_vars["HEALTH_CHECK_PORT"] = str(manifest.agent.temporal.health_check_port) + + if agent_config.env: + for key, value in agent_config.env.items(): + env_vars[key] = value + + env.update(env_vars) + + return env + + +def is_temporal_agent(manifest: AgentManifest) -> bool: + """Check if this is a temporal agent""" + return manifest.agent.is_temporal_agent() diff --git a/src/agentex/lib/cli/handlers/secret_handlers.py b/src/agentex/lib/cli/handlers/secret_handlers.py new file mode 100644 index 000000000..485efa998 --- /dev/null +++ b/src/agentex/lib/cli/handlers/secret_handlers.py @@ -0,0 +1,672 @@ +from __future__ import annotations + +import json +import base64 +from typing import Any +from pathlib import Path +from collections import defaultdict + +import yaml +import typer +import questionary +from rich.console import Console +from kubernetes.client.rest import ApiException + +from agentex.lib.utils.logging import make_logger +from agentex.config.credentials import CredentialMapping +from agentex.config.agent_config import AgentConfig +from agentex.config.agent_manifest import AgentManifest +from agentex.lib.cli.utils.cli_utils import handle_questionary_cancellation +from agentex.config.deployment_config import ( + DeploymentConfig, + ImagePullSecretConfig, + InjectedSecretsValues, +) +from agentex.lib.cli.utils.kubectl_utils import get_k8s_client +from agentex.lib.cli.utils.kubernetes_secrets_utils import ( + VALID_SECRET_TYPES, + KUBERNETES_SECRET_TYPE_OPAQUE, + KUBERNETES_SECRET_TO_MANIFEST_KEY, + KUBERNETES_SECRET_TYPE_DOCKERCONFIGJSON, + get_secret_data, + create_secret_with_data, + update_secret_with_data, + create_image_pull_secret_with_data, + update_image_pull_secret_with_data, +) + +logger = make_logger(__name__) +console = Console() + + +# TODO: parse this into a Pydantic model. +def load_values_file(values_path: str) -> dict[str, dict[str, str]]: + """Load and parse the values file (YAML/JSON)""" + try: + path = Path(values_path) + content = path.read_text() + + if path.suffix.lower() in [".yaml", ".yml"]: + data = yaml.safe_load(content) + elif path.suffix.lower() == ".json": + data = json.loads(content) + else: + # Try YAML first, then JSON + try: + data = yaml.safe_load(content) + except yaml.YAMLError: + data = json.loads(content) + return InjectedSecretsValues.model_validate(data).model_dump() + + except Exception as e: + raise RuntimeError( + f"Failed to load values file '{values_path}': {str(e)}" + ) from e + + +def interactive_secret_input(secret_name: str, secret_key: str) -> str: + """Prompt user for secret value with appropriate input method""" + console.print( + f"\n[bold]Enter value for secret '[cyan]{secret_name}[/cyan]' key '[cyan]{secret_key}[/cyan]':[/bold]" + ) + + input_type = questionary.select( + "What type of value is this?", + choices=[ + "Simple text", + "Sensitive/password (hidden input)", + "Multi-line text", + "JSON/YAML content", + "Read from file", + ], + ).ask() + + input_type = handle_questionary_cancellation(input_type, "secret input") + + if input_type == "Sensitive/password (hidden input)": + result = questionary.password("Enter value (input will be hidden):").ask() + return handle_questionary_cancellation(result, "password input") + + elif input_type == "Multi-line text": + console.print( + "[yellow]Enter multi-line text (press Ctrl+D when finished):[/yellow]" + ) + lines = [] + try: + while True: + line = input() + lines.append(line) + except EOFError: + pass + except KeyboardInterrupt: + console.print("[yellow]Multi-line input cancelled by user[/yellow]") + raise typer.Exit(0) # noqa + return "\n".join(lines) + + elif input_type == "JSON/YAML content": + value = questionary.text("Enter JSON/YAML content:").ask() + value = handle_questionary_cancellation(value, "JSON/YAML input") + # Validate JSON/YAML format + try: + json.loads(value) + except json.JSONDecodeError: + try: + yaml.safe_load(value) + except yaml.YAMLError: + console.print( + "[yellow]Warning: Content doesn't appear to be valid JSON or YAML[/yellow]" + ) + return value + + elif input_type == "Read from file": + file_path = questionary.path("Enter file path:").ask() + file_path = handle_questionary_cancellation(file_path, "file path input") + try: + return Path(file_path).read_text().strip() + except Exception as e: + console.print(f"[red]Error reading file: {e}[/red]") + manual_value = questionary.text("Enter value manually:").ask() + return handle_questionary_cancellation(manual_value, "manual value input") + + else: # Simple text + result = questionary.text("Enter value:").ask() + return handle_questionary_cancellation(result, "text input") + + +def get_secret(name: str, namespace: str, context: str | None = None) -> dict[str, Any]: + """Get details about a secret""" + v1 = get_k8s_client(context) + + try: + secret = v1.read_namespaced_secret(name=name, namespace=namespace) + return { + "name": secret.metadata.name, # type: ignore[union-attr] + "namespace": namespace, + "created": secret.metadata.creation_timestamp.isoformat(), # type: ignore[union-attr] + "exists": True, + } + except ApiException as e: + if e.status == 404: + console.print( + f"[red]Error: Secret '{name}' not found in namespace '{namespace}'[/red]" + ) + return {"name": name, "namespace": namespace, "exists": False} + raise RuntimeError(f"Failed to get secret: {str(e)}") from e + + +def delete_secret(name: str, namespace: str, context: str | None = None) -> None: + """Delete a secret""" + v1 = get_k8s_client(context) + + try: + v1.delete_namespaced_secret(name=name, namespace=namespace) + console.print( + f"[green]Deleted secret '{name}' from namespace '{namespace}'[/green]" + ) + except ApiException as e: + if e.status == 404: + console.print( + f"[red]Error: Secret '{name}' not found in namespace '{namespace}'[/red]" + ) + else: + console.print(f"[red]Error deleting secret: {e.reason}[/red]") + raise RuntimeError(f"Failed to delete secret: {str(e)}") from e + + +def get_kubernetes_secrets_by_type( + namespace: str, context: str | None = None +) -> dict[str, list[dict[str, Any]]]: + """List metadata about secrets in the namespace""" + v1 = get_k8s_client(context) + + try: + secrets = v1.list_namespaced_secret(namespace=namespace) + secret_type_to_secret = defaultdict(list) + for secret in secrets.items: + if secret.type in VALID_SECRET_TYPES: + secret_type_to_secret[secret.type].append( + { + "name": secret.metadata.name, + "namespace": namespace, + "created": secret.metadata.creation_timestamp.isoformat(), + } + ) + + return secret_type_to_secret + except ApiException as e: + console.print( + f"[red]Error listing secrets in namespace '{namespace}': {e.reason}[/red]" + ) + raise RuntimeError(f"Failed to list secrets: {str(e)}") from e + + # NOTE: This corresponds with KUBERNETES_SECRET_TYPE_OPAQUE + + +def sync_user_defined_secrets( + manifest_obj: AgentManifest, + found_secrets: list[dict], + values_data: dict[str, Any], + cluster: str, + namespace: str, + interactive: bool, + changes: dict[str, list[str]], +) -> None: + """Sync user defined secrets between manifest, cluster, and values file""" + console.print( + f"[bold]Syncing user defined secrets to cluster: {cluster} namespace: {namespace}[/bold]" + ) + + # Get the secrets from the cluster using the specified namespace and cluster context + cluster_secret_names = {secret["name"] for secret in found_secrets} + # Get the secrets from the manifest + agent_config: AgentConfig = manifest_obj.agent + manifest_credentials: list[CredentialMapping] = agent_config.credentials or [] # type: ignore[assignment] + + if not manifest_credentials: + console.print("[yellow]No credentials found in manifest[/yellow]") + return + + # Build required secrets map from manifest + required_secrets = {} # {secret_name: {secret_key: env_var_name}} + for cred in manifest_credentials: + if cred.secret_name not in required_secrets: + required_secrets[cred.secret_name] = {} + required_secrets[cred.secret_name][cred.secret_key] = cred.env_var_name + + # Process each required secret + for secret_name, required_keys in required_secrets.items(): + current_secret_data = get_secret_data(secret_name, namespace, cluster) + new_secret_data = {} + secret_needs_update = False + + # Process each required key in this secret + for secret_key, _ in required_keys.items(): + current_value = current_secret_data.get(secret_key) + + # Get the new value + if ( + values_data + and secret_name in values_data + and secret_key in values_data[secret_name] + ): + new_value = values_data[secret_name][secret_key] + elif interactive: + if current_value: + console.print( + f"[blue]Secret '{secret_name}' key '{secret_key}' already exists[/blue]" + ) + update_choice = questionary.select( + "What would you like to do?", + choices=[ + "Keep current value", + "Update with new value", + "Show current value", + ], + ).ask() + update_choice = handle_questionary_cancellation( + update_choice, "secret update choice" + ) + + if update_choice == "Show current value": + console.print(f"Current value: [dim]{current_value}[/dim]") + update_choice = questionary.select( + "What would you like to do?", + choices=["Keep current value", "Update with new value"], + ).ask() + update_choice = handle_questionary_cancellation( + update_choice, "secret update choice" + ) + + if update_choice == "Update with new value": + new_value = interactive_secret_input(secret_name, secret_key) + else: + new_value = current_value + else: + console.print( + f"[yellow]Secret '{secret_name}' key '{secret_key}' does not exist[/yellow]" + ) + new_value = interactive_secret_input(secret_name, secret_key) + else: + raise RuntimeError( + f"No value provided for secret '{secret_name}' key '{secret_key}'. Provide values file or use interactive mode." + ) + + # Must be a string because kubernetes always expects a + new_value = str(new_value) + new_secret_data[secret_key] = new_value + + # Check if value changed + if current_value != new_value: + secret_needs_update = True + else: + changes["noop"].append( + f"Secret '{secret_name}' key '{secret_key}' is up to date" + ) + + # Determine action needed + if secret_name not in cluster_secret_names: + changes["create"].append( + f"Create secret '{secret_name}' with keys: {list(required_keys.keys())}" + ) + create_secret_with_data(secret_name, new_secret_data, namespace, cluster) + elif secret_needs_update: + changes["update"].append(f"Update secret '{secret_name}' (values changed)") + update_secret_with_data(secret_name, new_secret_data, namespace, cluster) + + # Handle orphaned secrets (in cluster but not in manifest) + orphaned_secrets = cluster_secret_names - set(required_secrets.keys()) + if orphaned_secrets: + console.print( + f"\n[yellow]Warning: Found {len(orphaned_secrets)} secrets in cluster not defined in manifest:[/yellow]" + ) + for secret in orphaned_secrets: + console.print(f" - {secret}") + + +def create_dockerconfigjson_string( + registry: str, username: str, password: str, email: str | None = None +) -> str: + """Create raw dockerconfigjson string data for use with Kubernetes string_data field""" + # Create the auth field (base64 encoded username:password) + auth_string = f"{username}:{password}" + auth_b64 = base64.b64encode(auth_string.encode("utf-8")).decode("utf-8") + + # Build the auth entry + auth_entry = {"username": username, "password": password, "auth": auth_b64} + + # Only include email if provided + if email: + auth_entry["email"] = email + + # Create the full dockerconfig structure + docker_config = {"auths": {registry: auth_entry}} + + # Return raw JSON string (Kubernetes will handle base64 encoding when using string_data) + return json.dumps(docker_config) + + +def parse_dockerconfigjson_data(input_data: str) -> dict[str, dict[str, str]]: + """Parse existing dockerconfigjson data to extract registry credentials""" + try: + # Decode base64 + config = json.loads(input_data) + + # Extract auths section + auths = config.get("auths", {}) + + # Convert to comparable format: {registry: {username, password, email}} + parsed_auths = {} + for registry, auth_data in auths.items(): + # Try to decode the base64 auth field first + username = "" + password = "" + if "auth" in auth_data: + try: + auth_b64 = auth_data["auth"] + username_password = base64.b64decode(auth_b64).decode("utf-8") + if ":" in username_password: + username, password = username_password.split(":", 1) + except Exception: + pass + + # Fall back to direct username/password fields if auth decode failed + if not username: + username = auth_data.get("username", "") + if not password: + password = auth_data.get("password", "") + + parsed_auths[registry] = { + "username": username, + "password": password, + "email": auth_data.get("email", ""), + } + + return parsed_auths + except Exception: + return {} # If parsing fails, assume empty/invalid + + +def credentials_changed( + current_auths: dict[str, dict[str, str]], + new_registry: str, + new_username: str, + new_password: str, + new_email: str = "", +) -> bool: + """Check if credentials have actually changed""" + + # If registry doesn't exist in current, it's a change + if new_registry not in current_auths: + return True + + current_creds = current_auths[new_registry] + # Compare each field + if ( + current_creds.get("username", "") != new_username + or current_creds.get("password", "") != new_password + or current_creds.get("email", "") != (new_email or "") + ): + return True + else: + return False # No changes detected + + +def interactive_image_pull_secret_input(secret_name: str) -> dict[str, str]: + """Prompt user for image pull secret values""" + console.print( + f"\n[bold]Configure image pull secret '[cyan]{secret_name}[/cyan]':[/bold]" + ) + + registry = questionary.text( + "Registry URL (e.g., docker.io, gcr.io, your-registry.com):", + default="docker.io", + ).ask() + registry = handle_questionary_cancellation(registry, "registry input") + + username = questionary.text("Username:").ask() + username = handle_questionary_cancellation(username, "username input") + + password = questionary.password("Password (input will be hidden):").ask() + password = handle_questionary_cancellation(password, "password input") + + email_choice = questionary.confirm( + "Do you want to include an email address? (optional)" + ).ask() + email_choice = handle_questionary_cancellation(email_choice, "email choice") + email = "" + if email_choice: + email = questionary.text("Email address:").ask() or "" + if email is None: # Handle None from questionary + email = "" + + return { + "registry": registry, + "username": username, + "password": password, + "email": email, + } + + +def sync_image_pull_secrets( + manifest_obj: AgentManifest, + found_dockerconfigjson_secrets: list[dict], + values_data: dict[str, Any], + cluster: str, + namespace: str, + interactive: bool, + changes: dict[str, list[str]], +) -> None: + """Sync image pull secrets between manifest, cluster, and values file""" + console.print( + f"[bold]Syncing image pull secrets to cluster: {cluster} namespace: {namespace}[/bold]" + ) + + # Get the secrets of type KUBERNETES_SECRET_TYPE_DOCKERCONFIGJSON + cluster_dockerconfigjson_secret_names = { + secret["name"] for secret in found_dockerconfigjson_secrets + } + + # Get the secrets from the manifest + deployment_config: DeploymentConfig = manifest_obj.deployment # type: ignore[assignment] + manifest_image_pull_secrets: list[ImagePullSecretConfig] = ( + deployment_config.imagePullSecrets or [] + ) + + if not manifest_image_pull_secrets: + logger.info("No image pull secrets found in manifest") + return + + # Get image pull secrets from values data + image_pull_values = values_data + + # Process each required image pull secret + for pull_secret in manifest_image_pull_secrets: + secret_name = pull_secret.name + current_secret_data = get_secret_data(secret_name, namespace, cluster) + + # Get new values + new_registry = "" + new_username = "" + new_password = "" + new_email = "" + + if secret_name in image_pull_values: + # Get values from values file + secret_config = image_pull_values[secret_name] + new_registry = secret_config.get("registry", "") + new_username = secret_config.get("username", "") + new_password = secret_config.get("password", "") + new_email = secret_config.get("email", "") + + if not new_registry or not new_username or not new_password: + raise RuntimeError( + f"Incomplete image pull secret configuration for '{secret_name}'. " + f"Required: registry, username, password. Optional: email" + ) + elif interactive: + # Get values interactively + if secret_name in cluster_dockerconfigjson_secret_names: + console.print( + f"[blue]Image pull secret '{secret_name}' already exists[/blue]" + ) + update_choice = questionary.select( + "What would you like to do?", + choices=["Keep current credentials", "Update with new credentials"], + ).ask() + update_choice = handle_questionary_cancellation( + update_choice, "image pull secret update choice" + ) + + if update_choice == "Keep current credentials": + continue # Skip this secret + + console.print( + f"[yellow]Image pull secret '{secret_name}' needs configuration[/yellow]" + ) + creds = interactive_image_pull_secret_input(secret_name) + new_registry = creds["registry"] + new_username = creds["username"] + new_password = creds["password"] + new_email = creds["email"] + else: + raise RuntimeError( + f"No configuration provided for image pull secret '{secret_name}'. " + f"Provide values file or use interactive mode." + ) + + # Check if update is needed + secret_needs_update = False + action = "" + + if secret_name not in cluster_dockerconfigjson_secret_names: + # Secret doesn't exist, needs creation + secret_needs_update = True + action = "create" + else: + # Secret exists, check if values changed + current_dockerconfig = current_secret_data.get(".dockerconfigjson", {}) + current_auths = parse_dockerconfigjson_data(current_dockerconfig) + if credentials_changed( + current_auths, new_registry, new_username, new_password, new_email + ): + secret_needs_update = True + action = "update" + else: + changes["noop"].append( + f"Secret '{secret_name}' key '{secret_name}' is up to date" + ) + + # Only perform action if update is needed + if secret_needs_update: + dockerconfig_string = create_dockerconfigjson_string( + new_registry, new_username, new_password, new_email + ) + secret_data = {".dockerconfigjson": dockerconfig_string} + + if action == "create": + changes[action].append( + f"Create image pull secret '{secret_name}' for registry '{new_registry}'" + ) + create_image_pull_secret_with_data( + secret_name, secret_data, namespace, cluster + ) + elif action == "update": + changes[action].append( + f"Update image pull secret '{secret_name}' (credentials changed)" + ) + update_image_pull_secret_with_data( + secret_name, secret_data, namespace, cluster + ) + + +def print_changes_summary(change_type: str, changes: dict[str, list[str]]) -> None: + # Show summary + console.print(f"\n[bold]Sync Summary for {change_type}:[/bold]") + if changes["create"]: + console.print("[green]Created:[/green]") + for change in changes["create"]: + console.print(f" ✓ {change}") + + if changes["update"]: + console.print("[yellow]Updated:[/yellow]") + for change in changes["update"]: + console.print(f" ⚠ {change}") + + if changes["noop"]: + console.print("[yellow]No changes:[/yellow]") + for change in changes["noop"]: + console.print(f" ✓ {change}") + del changes["noop"] + + if not any(changes.values()): + console.print( + f"[green]✓ All secrets are already in sync for {change_type}[/green]" + ) + + console.print("") + + +def sync_secrets( + manifest_obj: AgentManifest, + cluster: str, + namespace: str, + interactive: bool, + values_path: str | None, +) -> None: + """Sync secrets between manifest, cluster, and values file""" + logger.info(f"Syncing secrets to cluster: {cluster} namespace: {namespace}") + + # Load values from file if provided + values_data = {} + if values_path: + try: + # TODO: Convert this to a pydantic model to validate the values file + values_data = load_values_file(values_path) + console.print(f"[green]Loaded values from {values_path}[/green]") + except Exception as e: + console.print(f"[red]Error loading values file: {e}[/red]") + raise + + # Get the secrets from the cluster using the specified namespace and cluster context + cluster_secrets_by_type = get_kubernetes_secrets_by_type( + namespace=namespace, context=cluster + ) + + # Track changes for summary + changes = {"create": [], "update": [], "noop": []} + + sync_user_defined_secrets( + manifest_obj, + cluster_secrets_by_type[KUBERNETES_SECRET_TYPE_OPAQUE], + values_data.get( + KUBERNETES_SECRET_TO_MANIFEST_KEY[KUBERNETES_SECRET_TYPE_OPAQUE], {} + ), + cluster, + namespace, + interactive, + changes, + ) + + print_changes_summary("User Defined Secrets", changes) + + # Track changes for summary + changes = {"create": [], "update": [], "noop": []} + + sync_image_pull_secrets( + manifest_obj, + cluster_secrets_by_type[KUBERNETES_SECRET_TYPE_DOCKERCONFIGJSON], + values_data.get( + KUBERNETES_SECRET_TO_MANIFEST_KEY[KUBERNETES_SECRET_TYPE_DOCKERCONFIGJSON], + {}, + ), + cluster, + namespace, + interactive, + changes, + ) + + print_changes_summary("Image Pull Secrets", changes) + + console.print( + f"\n[green]Secret sync completed for cluster '{cluster}' namespace '{namespace}'[/green]" + ) diff --git a/src/agentex/lib/cli/templates/default-claude-code/.dockerignore.j2 b/src/agentex/lib/cli/templates/default-claude-code/.dockerignore.j2 new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/src/agentex/lib/cli/templates/default-claude-code/.dockerignore.j2 @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/src/agentex/lib/cli/templates/default-claude-code/.env.example.j2 b/src/agentex/lib/cli/templates/default-claude-code/.env.example.j2 new file mode 100644 index 000000000..5aff34a60 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-claude-code/.env.example.j2 @@ -0,0 +1,13 @@ +# {{ agent_name }} - Environment Variables +# Copy this file to .env and fill in the values + +# API key for the Claude Code CLI (the `claude` subprocess this agent spawns) +ANTHROPIC_API_KEY= + +# LLM base URL (optional - override to use a different provider) +# OPENAI_BASE_URL= + +# SGP Configuration (optional - for tracing) +# SGP_API_KEY= +# SGP_ACCOUNT_ID= +# SGP_CLIENT_BASE_URL= 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 new file mode 100644 index 000000000..93d0f82d1 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 @@ -0,0 +1,51 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +# Install the Claude Code CLI: the agent shells out to `claude` on every turn, +# so the binary must be present in the runtime image. +RUN npm install -g @anthropic-ai/claude-code + +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +ENV UV_HTTP_TIMEOUT=1000 + +WORKDIR /app/{{ project_path_from_build_root }} + +# Copy dependency files for layer caching +COPY {{ project_path_from_build_root }}/pyproject.toml ./ + +# Install dependencies (without project itself, for layer caching) +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-install-project --no-dev + +# Copy the project code +COPY {{ project_path_from_build_root }}/project ./project + +# Install the project +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 new file mode 100644 index 000000000..d714d96f9 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 @@ -0,0 +1,46 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install the Claude Code CLI: the agent shells out to `claude` on every turn, +# so the binary must be present in the runtime image. +RUN npm install -g @anthropic-ai/claude-code + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy just the requirements file to optimize caching +COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt + +WORKDIR /app/{{ project_path_from_build_root }} + +# Install the required Python packages +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 + +# Set environment variables +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-claude-code/README.md.j2 b/src/agentex/lib/cli/templates/default-claude-code/README.md.j2 new file mode 100644 index 000000000..ab05398e3 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-claude-code/README.md.j2 @@ -0,0 +1,64 @@ +# {{ agent_name }} - AgentEx Async Claude Code Agent + +This template builds an **asynchronous** (non-Temporal) agent that drives the +**Claude Code CLI** through the unified harness surface on AgentEx: +- Spawns `claude -p --output-format stream-json --verbose` as a local subprocess +- Wraps the CLI's stdout stream in a `ClaudeCodeTurn` +- Delivers canonical `StreamTaskMessage*` events via `UnifiedEmitter.auto_send_turn` + (the async Redis push path), so the UI receives output in real time +- Tracing integration to SGP / AgentEx + +## Prerequisites + +- The `claude` CLI installed and on your `PATH` +- An `ANTHROPIC_API_KEY` (or equivalent credential) in your environment + +## Running the Agent + +```bash +agentex agents run --manifest manifest.yaml +``` + +## Project Structure + +``` +{{ project_name }}/ +├── project/ +│ ├── __init__.py +│ └── acp.py # ACP server, subprocess spawn, and event handlers +├── Dockerfile +├── manifest.yaml +├── dev.ipynb +{% if use_uv %} +└── pyproject.toml +{% else %} +└── requirements.txt +{% endif %} +``` + +## Key Concepts + +### Async ACP with the harness +The async ACP model streams events over Redis instead of an HTTP response. The +`@acp.on_task_event_send` handler spawns the Claude Code CLI and pushes the +harness events to the task stream. + +### The unified harness surface +`ClaudeCodeTurn` + `UnifiedEmitter` are the unified harness surface. The turn +normalizes CLI output into canonical AgentEx events; the emitter traces and +delivers them. + +## Development + +### 1. Customize the subprocess +Edit `_spawn_claude` in `project/acp.py` to change the CLI flags, working +directory, or how the prompt is delivered. + +### 2. Configure Credentials +Set your credentials via `manifest.yaml`, an exported environment variable, or a +`.env` file in the project directory. + +### 3. Run Locally +```bash +export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml +``` diff --git a/src/agentex/lib/cli/templates/default-claude-code/dev.ipynb.j2 b/src/agentex/lib/cli/templates/default-claude-code/dev.ipynb.j2 new file mode 100644 index 000000000..d3a68303f --- /dev/null +++ b/src/agentex/lib/cli/templates/default-claude-code/dev.ipynb.j2 @@ -0,0 +1,126 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"{{ agent_name }}\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [], + "source": [ + "# (REQUIRED) Create a new task. For Async agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b03b0d37", + "metadata": {}, + "outputs": [], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a6927cc0", + "metadata": {}, + "outputs": [], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4864e354", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/agentex/lib/cli/templates/default-claude-code/environments.yaml.j2 b/src/agentex/lib/cli/templates/default-claude-code/environments.yaml.j2 new file mode 100644 index 000000000..f802776f0 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-claude-code/environments.yaml.j2 @@ -0,0 +1,57 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-{{agent_name}}" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + temporal: + enabled: false + + diff --git a/src/agentex/lib/cli/templates/default-claude-code/manifest.yaml.j2 b/src/agentex/lib/cli/templates/default-claude-code/manifest.yaml.j2 new file mode 100644 index 000000000..ee08bc91b --- /dev/null +++ b/src/agentex/lib/cli/templates/default-claude-code/manifest.yaml.j2 @@ -0,0 +1,123 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../ # Keep this as the default root + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - {{ project_path_from_build_root }} + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: {{ project_path_from_build_root }}/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: {{ project_path_from_build_root }}/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + +# Agent Configuration +# ----------------- +agent: + acp_type: async + + # Unique name for your agent + # Used for task routing and monitoring + name: {{ agent_name }} + + # Description of what your agent does + # Helps with documentation and discovery + description: {{ description | tojson }} + + # Temporal workflow configuration + # Set enabled: true to use Temporal workflows for long-running tasks + temporal: + enabled: false + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + credentials: + # The Claude Code CLI authenticates with ANTHROPIC_API_KEY (LITELLM_API_KEY + # is not read by the `claude` subprocess this agent spawns). + - env_var_name: ANTHROPIC_API_KEY + secret_name: anthropic-api-key + secret_key: api-key + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on. ANTHROPIC_API_KEY is supplied via the credential + # mapping above (deploy) or your local .env (load_dotenv). Do NOT set it to an + # empty string here — that would shadow the real key at runtime. + env: {} + # ANTHROPIC_API_KEY: "" # uncomment only to hardcode for local runs + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: [] # Update with your image pull secret names + # - name: my-registry-secret + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific environments (environments.yaml) + global: + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-claude-code/project/acp.py.j2 b/src/agentex/lib/cli/templates/default-claude-code/project/acp.py.j2 new file mode 100644 index 000000000..42512c601 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-claude-code/project/acp.py.j2 @@ -0,0 +1,167 @@ +"""ACP handler for {{ agent_name }} — an async Claude Code agent. + +Spawns ``claude -p --output-format stream-json --verbose`` as a LOCAL +asyncio subprocess (no Scale sandbox — that is a production concern). Stdout +lines are fed into ``ClaudeCodeTurn``. Events are delivered via +``UnifiedEmitter.auto_send_turn``, the async Redis push path. + +Live runs require the ``claude`` CLI to be installed and an +ANTHROPIC_API_KEY (or equivalent credential) in the environment. +""" + +from __future__ import annotations + +import os +import asyncio +from typing import AsyncIterator +from collections import deque + +from dotenv import load_dotenv + +load_dotenv() + +import agentex.lib.adk as adk +from agentex.lib.adk import ClaudeCodeTurn +from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams +from agentex.lib.core.harness import UnifiedEmitter +from agentex.lib.types.fastacp import AsyncACPConfig +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config + +logger = make_logger(__name__) + +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) +) + +acp = FastACP.create( + acp_type="async", + config=AsyncACPConfig(type="base"), +) + + +async def _spawn_claude(prompt: str) -> AsyncIterator[str]: + """Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines. + + Injectable seam: tests can monkeypatch this with a fake async iterator of + pre-recorded lines so no real CLI invocation is needed offline. + """ + proc = await asyncio.create_subprocess_exec( + "claude", + "-p", + "--output-format", + "stream-json", + "--verbose", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + assert proc.stdout is not None + assert proc.stdin is not None + + proc.stdin.write(prompt.encode()) + await proc.stdin.drain() + proc.stdin.close() + + # Drain stderr concurrently. With --verbose, Claude Code can write enough to + # stderr to fill the OS pipe buffer; if we only read stdout, the CLI blocks + # on its stderr write while we block reading stdout — a deadlock. A + # background task keeps stderr flowing so stdout never stalls. We keep a + # bounded tail so a non-zero exit can be surfaced with context instead of + # silently completing the turn. + stderr_tail: deque[str] = deque(maxlen=20) + + async def _drain_stderr() -> None: + assert proc.stderr is not None + async for raw in proc.stderr: + text = raw.decode("utf-8", errors="replace").rstrip() + if text: + stderr_tail.append(text) + + stderr_task = asyncio.create_task(_drain_stderr()) + + try: + buffer = "" + async for chunk in proc.stdout: + buffer += chunk.decode("utf-8", errors="replace") + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if line: + yield line + + if buffer.strip(): + yield buffer.strip() + + await proc.wait() + if proc.returncode: + # The CLI failed (missing binary/auth, bad command). Raise so the + # turn surfaces as failed instead of completing with no output. + tail = "\n".join(stderr_tail) + raise RuntimeError( + f"claude CLI exited with status {proc.returncode}:\n{tail}" + ) + finally: + # Release the subprocess and stderr drain task even if the consumer + # abandons the generator early (task cancellation / client disconnect): + # cancel the drain task and terminate+reap the process if it is still + # running, so neither is leaked. + stderr_task.cancel() + try: + await stderr_task + except asyncio.CancelledError: + pass + if proc.returncode is None: + try: + proc.terminate() + except ProcessLookupError: + pass + await proc.wait() + + +@acp.on_task_create +async def handle_task_create(params: CreateTaskParams): + logger.info("Task created: %s", params.task.id) + + +@acp.on_task_event_send +async def handle_task_event_send(params: SendEventParams): + """Handle a user message: spawn Claude Code locally and push events to the task stream.""" + task_id = params.task.id + content = params.event.content + if not isinstance(content, TextContent): + logger.warning("Ignoring non-text event content (type=%s)", getattr(content, "type", "?")) + return + prompt = content.content + logger.info("Processing message for task %s", task_id) + + await adk.messages.create(task_id=task_id, content=params.event.content) + + async with adk.tracing.span( + trace_id=task_id, + task_id=task_id, + name="message", + input={"message": prompt}, + data={"__span_type__": "AGENT_WORKFLOW"}, + ) as turn_span: + emitter = UnifiedEmitter( + task_id=task_id, + trace_id=task_id, + parent_span_id=turn_span.id if turn_span else None, + ) + turn = ClaudeCodeTurn(_spawn_claude(prompt)) + result = await emitter.auto_send_turn(turn) + if turn_span: + turn_span.output = {"final_text": result.final_text} + + +@acp.on_task_cancel +async def handle_task_canceled(params: CancelTaskParams): + logger.info("Task canceled: %s", params.task.id) diff --git a/src/agentex/lib/cli/templates/default-claude-code/pyproject.toml.j2 b/src/agentex/lib/cli/templates/default-claude-code/pyproject.toml.j2 new file mode 100644 index 000000000..e499b1dc1 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-claude-code/pyproject.toml.j2 @@ -0,0 +1,33 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ project_name }}" +version = "0.1.0" +description = "{{ description }}" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "python-dotenv>=1.0,<2", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/src/agentex/lib/cli/templates/default-claude-code/requirements.txt.j2 b/src/agentex/lib/cli/templates/default-claude-code/requirements.txt.j2 new file mode 100644 index 000000000..8c0630384 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-claude-code/requirements.txt.j2 @@ -0,0 +1,8 @@ +# Install agentex-sdk from local path +agentex-sdk + +# Scale GenAI Platform Python SDK +scale-gp + +# Loads .env files for local development +python-dotenv>=1.0,<2 diff --git a/src/agentex/lib/cli/templates/default-codex/.dockerignore.j2 b/src/agentex/lib/cli/templates/default-codex/.dockerignore.j2 new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/src/agentex/lib/cli/templates/default-codex/.dockerignore.j2 @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/src/agentex/lib/cli/templates/default-codex/.env.example.j2 b/src/agentex/lib/cli/templates/default-codex/.env.example.j2 new file mode 100644 index 000000000..5d621a83e --- /dev/null +++ b/src/agentex/lib/cli/templates/default-codex/.env.example.j2 @@ -0,0 +1,13 @@ +# {{ agent_name }} - Environment Variables +# Copy this file to .env and fill in the values + +# API key used by the codex CLI (`codex exec` reads OPENAI_API_KEY directly) +OPENAI_API_KEY= + +# LLM base URL (optional - override to use a different provider) +# OPENAI_BASE_URL= + +# SGP Configuration (optional - for tracing) +# SGP_API_KEY= +# SGP_ACCOUNT_ID= +# SGP_CLIENT_BASE_URL= diff --git a/src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 new file mode 100644 index 000000000..02860b9b9 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 @@ -0,0 +1,51 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +# Install the codex CLI: the agent shells out to `codex` on every turn, so the +# binary must be present in the runtime image. +RUN npm install -g @openai/codex + +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +ENV UV_HTTP_TIMEOUT=1000 + +WORKDIR /app/{{ project_path_from_build_root }} + +# Copy dependency files for layer caching +COPY {{ project_path_from_build_root }}/pyproject.toml ./ + +# Install dependencies (without project itself, for layer caching) +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-install-project --no-dev + +# Copy the project code +COPY {{ project_path_from_build_root }}/project ./project + +# Install the project +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-codex/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-codex/Dockerfile.j2 new file mode 100644 index 000000000..1a8eb1484 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-codex/Dockerfile.j2 @@ -0,0 +1,46 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install the codex CLI: the agent shells out to `codex` on every turn, so the +# binary must be present in the runtime image. +RUN npm install -g @openai/codex + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy just the requirements file to optimize caching +COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt + +WORKDIR /app/{{ project_path_from_build_root }} + +# Install the required Python packages +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 + +# Set environment variables +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-codex/README.md.j2 b/src/agentex/lib/cli/templates/default-codex/README.md.j2 new file mode 100644 index 000000000..b82f1c5f2 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-codex/README.md.j2 @@ -0,0 +1,72 @@ +# {{ agent_name }} - AgentEx Async Codex Agent + +This template builds an **asynchronous** (non-Temporal) agent that drives the +**Codex CLI** through the unified harness surface on AgentEx: +- Spawns `codex exec --json` as a local subprocess +- Wraps the CLI's stdout stream in a `CodexTurn` +- Delivers canonical `StreamTaskMessage*` events via `UnifiedEmitter.auto_send_turn` + (the async Redis push path), so the UI receives output in real time +- Persists the codex session/thread ID via `adk.state` for multi-turn memory +- Tracing integration to SGP / AgentEx + +## Prerequisites + +- The `codex` CLI installed and on your `PATH` (`npm install -g @openai/codex`) +- An `OPENAI_API_KEY` in your environment + +## Running the Agent + +```bash +agentex agents run --manifest manifest.yaml +``` + +## Project Structure + +``` +{{ project_name }}/ +├── project/ +│ ├── __init__.py +│ └── acp.py # ACP server, subprocess spawn, state, and event handlers +├── Dockerfile +├── manifest.yaml +├── dev.ipynb +{% if use_uv %} +└── pyproject.toml +{% else %} +└── requirements.txt +{% endif %} +``` + +## Key Concepts + +### Async ACP with the harness +The async ACP model streams events over Redis instead of an HTTP response. The +`@acp.on_task_event_send` handler spawns the Codex CLI and pushes the harness +events to the task stream. + +### Multi-turn memory +The codex session/thread ID is persisted via `adk.state`, so each new turn +resumes the same codex session with `codex exec resume `. + +### The unified harness surface +`CodexTurn` + `UnifiedEmitter` are the unified harness surface. The turn +normalizes CLI output into canonical AgentEx events; the emitter traces and +delivers them. + +## Development + +### 1. Choose a model +Set `CODEX_MODEL` (defaults to `o4-mini`) to control which model codex uses. + +### 2. Customize the subprocess +Edit `_spawn_codex` in `project/acp.py` to change the CLI flags or how the +prompt is delivered. + +### 3. Configure Credentials +Set your credentials via `manifest.yaml`, an exported environment variable, or a +`.env` file in the project directory. + +### 4. Run Locally +```bash +export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml +``` diff --git a/src/agentex/lib/cli/templates/default-codex/dev.ipynb.j2 b/src/agentex/lib/cli/templates/default-codex/dev.ipynb.j2 new file mode 100644 index 000000000..d3a68303f --- /dev/null +++ b/src/agentex/lib/cli/templates/default-codex/dev.ipynb.j2 @@ -0,0 +1,126 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"{{ agent_name }}\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [], + "source": [ + "# (REQUIRED) Create a new task. For Async agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b03b0d37", + "metadata": {}, + "outputs": [], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a6927cc0", + "metadata": {}, + "outputs": [], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4864e354", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/agentex/lib/cli/templates/default-codex/environments.yaml.j2 b/src/agentex/lib/cli/templates/default-codex/environments.yaml.j2 new file mode 100644 index 000000000..f802776f0 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-codex/environments.yaml.j2 @@ -0,0 +1,57 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-{{agent_name}}" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + temporal: + enabled: false + + diff --git a/src/agentex/lib/cli/templates/default-codex/manifest.yaml.j2 b/src/agentex/lib/cli/templates/default-codex/manifest.yaml.j2 new file mode 100644 index 000000000..3c894318f --- /dev/null +++ b/src/agentex/lib/cli/templates/default-codex/manifest.yaml.j2 @@ -0,0 +1,123 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../ # Keep this as the default root + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - {{ project_path_from_build_root }} + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: {{ project_path_from_build_root }}/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: {{ project_path_from_build_root }}/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + +# Agent Configuration +# ----------------- +agent: + acp_type: async + + # Unique name for your agent + # Used for task routing and monitoring + name: {{ agent_name }} + + # Description of what your agent does + # Helps with documentation and discovery + description: {{ description | tojson }} + + # Temporal workflow configuration + # Set enabled: true to use Temporal workflows for long-running tasks + temporal: + enabled: false + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + credentials: + # The codex CLI (`codex exec`) reads OPENAI_API_KEY directly; it does not + # use a LiteLLM key. + - env_var_name: OPENAI_API_KEY + secret_name: openai-api-key + secret_key: api-key + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on. OPENAI_API_KEY is supplied via the credential + # mapping above (deploy) or your local .env. Do NOT set it to an empty string + # here — that would shadow the real key at runtime. + env: {} + # OPENAI_BASE_URL: "" + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: [] # Update with your image pull secret names + # - name: my-registry-secret + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific environments (environments.yaml) + global: + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-codex/project/acp.py.j2 b/src/agentex/lib/cli/templates/default-codex/project/acp.py.j2 new file mode 100644 index 000000000..f676ef137 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-codex/project/acp.py.j2 @@ -0,0 +1,271 @@ +"""Async (base) ACP handler for {{ agent_name }} — a Codex CLI harness agent. + +Demonstrates the ``convert_codex_to_agentex_events`` tap + ``CodexTurn`` + +``UnifiedEmitter`` for an async (Redis-streaming) ACP agent without Temporal. + +The handler: +1. Spawns ``codex exec --json`` as a LOCAL asyncio subprocess (no sandbox). + This is correct for local development; production isolation is a separate + concern. +2. Wraps the stdout line stream in a ``CodexTurn``. +3. Delivers every canonical ``StreamTaskMessage*`` event to Redis via + ``UnifiedEmitter.auto_send_turn``, so the UI receives tokens in real time. +4. Multi-turn memory is persisted via ``adk.state``. + +Live runs require: +- ``codex`` CLI on PATH (``npm install -g @openai/codex``) +- ``OPENAI_API_KEY`` set in the environment +""" + +from __future__ import annotations + +import os +import time +import codecs +import asyncio +from collections.abc import AsyncIterator + +from dotenv import load_dotenv + +load_dotenv() + +import agentex.lib.adk as adk +from agentex.lib.adk import CodexTurn +from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams +from agentex.types.text_content import TextContent +from agentex.lib.core.harness import UnifiedEmitter +from agentex.lib.types.fastacp import AsyncACPConfig +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config + +logger = make_logger(__name__) + +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) +) + +acp = FastACP.create( + acp_type="async", + config=AsyncACPConfig(type="base"), +) + +MODEL = os.environ.get("CODEX_MODEL", "o4-mini") + +# Serialize turns per task. Two ``task/event/send`` calls for the same task can +# otherwise both read the old ``codex_thread_id`` (or ``None``), run independent +# codex turns, and race to overwrite the stored thread id — forking the session. +# A per-task lock keeps turns sequential without blocking other tasks. +_task_locks: dict[str, asyncio.Lock] = {} + + +def _task_lock(task_id: str) -> asyncio.Lock: + lock = _task_locks.get(task_id) + if lock is None: + lock = asyncio.Lock() + _task_locks[task_id] = lock + return lock + + +class ConversationState(BaseModel): + """Per-task conversation state persisted via ``adk.state``. + + We store the codex session/thread ID so subsequent turns can resume the + same codex session via ``codex exec resume ``. + """ + + codex_thread_id: str | None = None + turn_number: int = 0 + + +async def _spawn_codex( + model: str, + thread_id: str | None = None, +) -> asyncio.subprocess.Process: + """Spawn ``codex exec --json`` locally and return the live process. + + Injection seam: tests replace this function with a fake that returns a + mock process whose stdout yields pre-recorded event lines. + + When ``thread_id`` is provided the subcommand becomes + ``codex exec ... resume -`` so codex continues the prior + conversation thread. + + The caller writes the prompt to stdin after the process starts, then + closes stdin so codex knows input is complete. + """ + base_flags = [ + "--json", + "--skip-git-repo-check", + "--dangerously-bypass-approvals-and-sandbox", + "--model", + model, + ] + + if thread_id: + cmd = ["codex", "exec", *base_flags, "resume", thread_id, "-"] + else: + cmd = ["codex", "exec", *base_flags, "-"] + + return await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + # Discard stderr: codex --json writes events to stdout; its stderr is + # progress/debug noise. Capturing it with PIPE but never reading it + # would deadlock once codex fills the OS pipe buffer (~64 KB). + stderr=asyncio.subprocess.DEVNULL, + env={**os.environ}, + ) + + +async def _process_stdout(process: asyncio.subprocess.Process) -> AsyncIterator[str]: + """Yield newline-delimited JSON lines from the process stdout. + + Uses an incremental UTF-8 decoder so a multibyte character split across two + 4 KB reads is decoded correctly instead of being corrupted at the boundary. + """ + assert process.stdout is not None + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + buffer = "" + while True: + chunk = await process.stdout.read(4096) + if not chunk: + break + buffer += decoder.decode(chunk) + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if line: + yield line + buffer += decoder.decode(b"", final=True) + if buffer.strip(): + yield buffer.strip() + + +@acp.on_task_create +async def handle_task_create(params: CreateTaskParams): + """Initialize per-task state on task creation.""" + logger.info("Task created: %s", params.task.id) + await adk.state.create( + task_id=params.task.id, + agent_id=params.agent.id, + state=ConversationState(), + ) + + +@acp.on_task_event_send +async def handle_task_event_send(params: SendEventParams): + """Handle each user message: spawn codex, stream events, save thread ID.""" + task_id = params.task.id + agent_id = params.agent.id + + content = params.event.content + if not isinstance(content, TextContent): + logger.warning( + "Ignoring non-text event content (type=%s) for task %s", + getattr(content, "type", "?"), + task_id, + ) + return + user_message = content.content + + logger.info("Processing message for task %s", task_id) + + # Serialize the whole turn (echo + the read-modify-write of + # ``codex_thread_id``) so two concurrent turns on the same task cannot fork + # the codex session or interleave their echoed messages. + lock = _task_lock(task_id) + await lock.acquire() + try: + # Echo inside the lock so this turn's message stays ordered with it. + await adk.messages.create(task_id=task_id, content=content) + + task_state = await adk.state.get_by_task_and_agent(task_id=task_id, agent_id=agent_id) + if task_state is None: + state = ConversationState() + task_state = await adk.state.create(task_id=task_id, agent_id=agent_id, state=state) + else: + state = ConversationState.model_validate(task_state.state) + + state.turn_number += 1 + + async with adk.tracing.span( + trace_id=task_id, + task_id=task_id, + name=f"Turn {state.turn_number}", + input={"message": user_message}, + data={"__span_type__": "AGENT_WORKFLOW"}, + ) as turn_span: + start_ms = int(time.monotonic() * 1000) + + process = await _spawn_codex(MODEL, thread_id=state.codex_thread_id) + + assert process.stdin is not None + process.stdin.write(user_message.encode("utf-8")) + await process.stdin.drain() + process.stdin.close() + + turn = CodexTurn( + events=_process_stdout(process), + model=MODEL, + ) + + emitter = UnifiedEmitter( + task_id=task_id, + trace_id=task_id, + parent_span_id=turn_span.id if turn_span else None, + ) + + # Guarantee the subprocess is reaped even if auto_send_turn raises + # (e.g. a Redis error); otherwise codex stays blocked writing to a full + # stdout pipe buffer and the OS process leaks until the server restarts. + try: + result = await emitter.auto_send_turn(turn) + finally: + if process.returncode is None: + process.kill() + await process.wait() + + # Record the real wall-clock duration AFTER streaming completes; setting + # it before the stream ran would capture only subprocess spawn overhead. + turn.duration_ms = int(time.monotonic() * 1000) - start_ms + + usage = turn.usage() + + # Persist the codex session id (public accessor; valid post-stream) so the + # next turn resumes the same session. + if turn.session_id: + state.codex_thread_id = turn.session_id + + await adk.state.update( + state_id=task_state.id, + task_id=task_id, + agent_id=agent_id, + state=state, + ) + + if turn_span: + turn_span.output = { + "final_text": result.final_text, + "model": usage.model, + } + finally: + lock.release() + # Evict the lock once released and idle (unlocked, no waiters) so + # ``_task_locks`` stays bounded even if the turn raised. There is no + # await between ``_task_lock()`` and acquiring it, so an unlocked, + # waiter-free lock has no in-flight user. + if not lock.locked() and not getattr(lock, "_waiters", None): + _task_locks.pop(task_id, None) + + +@acp.on_task_cancel +async def handle_task_canceled(params: CancelTaskParams): + logger.info("Task canceled: %s", params.task.id) diff --git a/src/agentex/lib/cli/templates/default-codex/pyproject.toml.j2 b/src/agentex/lib/cli/templates/default-codex/pyproject.toml.j2 new file mode 100644 index 000000000..e499b1dc1 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-codex/pyproject.toml.j2 @@ -0,0 +1,33 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ project_name }}" +version = "0.1.0" +description = "{{ description }}" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "python-dotenv>=1.0,<2", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/src/agentex/lib/cli/templates/default-codex/requirements.txt.j2 b/src/agentex/lib/cli/templates/default-codex/requirements.txt.j2 new file mode 100644 index 000000000..8c0630384 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-codex/requirements.txt.j2 @@ -0,0 +1,8 @@ +# Install agentex-sdk from local path +agentex-sdk + +# Scale GenAI Platform Python SDK +scale-gp + +# Loads .env files for local development +python-dotenv>=1.0,<2 diff --git a/src/agentex/lib/cli/templates/default-langgraph/.dockerignore.j2 b/src/agentex/lib/cli/templates/default-langgraph/.dockerignore.j2 new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/src/agentex/lib/cli/templates/default-langgraph/.dockerignore.j2 @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/src/agentex/lib/cli/templates/default-langgraph/.env.example.j2 b/src/agentex/lib/cli/templates/default-langgraph/.env.example.j2 new file mode 100644 index 000000000..015f49ef7 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-langgraph/.env.example.j2 @@ -0,0 +1,13 @@ +# {{ agent_name }} - Environment Variables +# Copy this file to .env and fill in the values + +# API key for your LLM provider +LITELLM_API_KEY= + +# LLM base URL (optional - override to use a different provider) +# OPENAI_BASE_URL= + +# SGP Configuration (optional - for tracing) +# SGP_API_KEY= +# SGP_ACCOUNT_ID= +# SGP_CLIENT_BASE_URL= diff --git a/src/agentex/lib/cli/templates/default-langgraph/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-langgraph/Dockerfile-uv.j2 new file mode 100644 index 000000000..dd3035f7b --- /dev/null +++ b/src/agentex/lib/cli/templates/default-langgraph/Dockerfile-uv.j2 @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +ENV UV_HTTP_TIMEOUT=1000 + +WORKDIR /app/{{ project_path_from_build_root }} + +# Copy dependency files for layer caching +COPY {{ project_path_from_build_root }}/pyproject.toml ./ + +# Install dependencies (without project itself, for layer caching) +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-install-project --no-dev + +# Copy the project code +COPY {{ project_path_from_build_root }}/project ./project + +# Install the project +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-langgraph/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-langgraph/Dockerfile.j2 new file mode 100644 index 000000000..0395caf74 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-langgraph/Dockerfile.j2 @@ -0,0 +1,42 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + node \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy just the requirements file to optimize caching +COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt + +WORKDIR /app/{{ project_path_from_build_root }} + +# Install the required Python packages +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 + +# Set environment variables +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-langgraph/README.md.j2 b/src/agentex/lib/cli/templates/default-langgraph/README.md.j2 new file mode 100644 index 000000000..59bea5bdb --- /dev/null +++ b/src/agentex/lib/cli/templates/default-langgraph/README.md.j2 @@ -0,0 +1,85 @@ +# {{ agent_name }} - AgentEx Async LangGraph Agent + +This template builds an **asynchronous** LangGraph agent on AgentEx with: +- Task-based event handling via Redis +- Tool calling (ReAct pattern) +- Multi-turn conversation memory via AgentEx checkpointer +- Tracing integration + +## Graph Structure + +``` +START --> agent --> [has tool calls?] --> tools --> agent + --> [no tool calls?] --> END +``` + +## Sync vs Async + +| Aspect | Sync | Async (This Template) | +|--------|------|-----------------------| +| **ACP Type** | `sync` | `async` | +| **Handler** | `@acp.on_message_send` | `@acp.on_task_event_send` | +| **Response** | HTTP streaming (yields) | Redis streaming | +| **Message Echo** | Implicit | Explicit (`adk.messages.create`) | +| **Streaming Helper** | `convert_langgraph_to_agentex_events()` | `stream_langgraph_events()` | + +### When to use Async? +- Long-running tasks that may exceed HTTP timeout +- Agents that need to push updates asynchronously +- Multi-step workflows where the client polls for results +- Production agents that need reliable message delivery via Redis + +## Running the Agent + +```bash +agentex agents run --manifest manifest.yaml +``` + +## Project Structure + +``` +{{ project_name }}/ +├── project/ +│ ├── __init__.py +│ ├── acp.py # ACP server with async event handlers +│ ├── graph.py # LangGraph state graph definition +│ └── tools.py # Tool definitions +├── Dockerfile +├── manifest.yaml +├── dev.ipynb +{% if use_uv %} +└── pyproject.toml +{% else %} +└── requirements.txt +{% endif %} +``` + +## Development + +### 1. Add Your Own Tools +Edit `project/tools.py` to define custom tools: + +```python +from langchain_core.tools import Tool + +def my_tool(query: str) -> str: + """Your tool description.""" + return "result" + +my_tool = Tool(name="my_tool", func=my_tool, description="...") +TOOLS = [my_tool] +``` + +### 2. Customize the Graph +Edit `project/graph.py` to modify the model, system prompt, or graph structure. + +### 3. Configure Credentials +Set your LLM API key: +1. In `manifest.yaml` under `env.LITELLM_API_KEY` +2. Or export: `export LITELLM_API_KEY=...` +3. Or create a `.env` file in the project directory + +### 4. Run Locally +```bash +export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml +``` diff --git a/src/agentex/lib/cli/templates/default-langgraph/dev.ipynb.j2 b/src/agentex/lib/cli/templates/default-langgraph/dev.ipynb.j2 new file mode 100644 index 000000000..d3a68303f --- /dev/null +++ b/src/agentex/lib/cli/templates/default-langgraph/dev.ipynb.j2 @@ -0,0 +1,126 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"{{ agent_name }}\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [], + "source": [ + "# (REQUIRED) Create a new task. For Async agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b03b0d37", + "metadata": {}, + "outputs": [], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a6927cc0", + "metadata": {}, + "outputs": [], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4864e354", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/agentex/lib/cli/templates/default-langgraph/environments.yaml.j2 b/src/agentex/lib/cli/templates/default-langgraph/environments.yaml.j2 new file mode 100644 index 000000000..f802776f0 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-langgraph/environments.yaml.j2 @@ -0,0 +1,57 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-{{agent_name}}" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + temporal: + enabled: false + + diff --git a/src/agentex/lib/cli/templates/default-langgraph/manifest.yaml.j2 b/src/agentex/lib/cli/templates/default-langgraph/manifest.yaml.j2 new file mode 100644 index 000000000..e6c15cf33 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-langgraph/manifest.yaml.j2 @@ -0,0 +1,120 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../ # Keep this as the default root + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - {{ project_path_from_build_root }} + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: {{ project_path_from_build_root }}/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: {{ project_path_from_build_root }}/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + +# Agent Configuration +# ----------------- +agent: + acp_type: async + + # Unique name for your agent + # Used for task routing and monitoring + name: {{ agent_name }} + + # Description of what your agent does + # Helps with documentation and discovery + description: {{ description | tojson }} + + # Temporal workflow configuration + # Set enabled: true to use Temporal workflows for long-running tasks + temporal: + enabled: false + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + credentials: + - env_var_name: LITELLM_API_KEY + secret_name: litellm-api-key + secret_key: api-key + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + env: + LITELLM_API_KEY: "" # Set your LLM API key + # OPENAI_BASE_URL: "" + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: [] # Update with your image pull secret names + # - name: my-registry-secret + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific environments (environments.yaml) + global: + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-langgraph/project/acp.py.j2 b/src/agentex/lib/cli/templates/default-langgraph/project/acp.py.j2 new file mode 100644 index 000000000..da5d37905 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-langgraph/project/acp.py.j2 @@ -0,0 +1,102 @@ +""" +ACP handler for async LangGraph agent. + +Uses the async ACP model with Redis streaming instead of HTTP yields. +""" + +from dotenv import load_dotenv + +load_dotenv() +import os + +# LiteLLM proxy auth: copy LITELLM_API_KEY to OPENAI_API_KEY for OpenAI client compatibility +_litellm_key = os.environ.get("LITELLM_API_KEY") +if _litellm_key: + os.environ["OPENAI_API_KEY"] = _litellm_key + +import agentex.lib.adk as adk +from agentex.lib.core.harness import UnifiedEmitter +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.protocol.acp import SendEventParams, CancelTaskParams, CreateTaskParams +from agentex.lib.types.fastacp import AsyncACPConfig +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.adk import LangGraphTurn + +from project.graph import create_graph + +logger = make_logger(__name__) + +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + )) + +acp = FastACP.create( + acp_type="async", + config=AsyncACPConfig(type="base"), +) + +_graph = None + + +async def get_graph(): + global _graph + if _graph is None: + _graph = await create_graph() + return _graph + + +@acp.on_task_event_send +async def handle_task_event_send(params: SendEventParams): + """Handle incoming events, streaming tokens and tool calls via Redis.""" + graph = await get_graph() + task_id = params.task.id + content = params.event.content + if not isinstance(content, TextContent): + logger.warning("Ignoring non-text event content (type=%s)", getattr(content, "type", "?")) + return + user_message = content.content + + logger.info(f"Processing message for thread {task_id}") + + # Echo the user's message + await adk.messages.create(task_id=task_id, content=params.event.content) + + async with adk.tracing.span( + trace_id=task_id, + name="message", + input={"message": user_message}, + data={"__span_type__": "AGENT_WORKFLOW"}, + ) as turn_span: + stream = graph.astream( + {"messages": [{"role": "user", "content": user_message}]}, + config={"configurable": {"thread_id": task_id}}, + stream_mode=["messages", "updates"], + ) + + turn = LangGraphTurn(stream, model=None) + emitter = UnifiedEmitter( + task_id=task_id, + trace_id=task_id, + parent_span_id=turn_span.id if turn_span else None, + ) + + result = await emitter.auto_send_turn(turn) + + if turn_span: + turn_span.output = {"final_output": result.final_text} + + +@acp.on_task_create +async def handle_task_create(params: CreateTaskParams): + logger.info(f"Task created: {params.task.id}") + + +@acp.on_task_cancel +async def handle_task_canceled(params: CancelTaskParams): + logger.info(f"Task canceled: {params.task.id}") diff --git a/src/agentex/lib/cli/templates/default-langgraph/project/graph.py.j2 b/src/agentex/lib/cli/templates/default-langgraph/project/graph.py.j2 new file mode 100644 index 000000000..b7fd2d6bd --- /dev/null +++ b/src/agentex/lib/cli/templates/default-langgraph/project/graph.py.j2 @@ -0,0 +1,63 @@ +""" +LangGraph graph definition. + +Defines the state, nodes, edges, and compiles the graph. +""" + +from datetime import datetime +from typing import Annotated, Any + +from agentex.lib.adk import create_checkpointer +from langchain_core.messages import SystemMessage +from langchain_openai import ChatOpenAI +from langgraph.graph import START, StateGraph +from langgraph.graph.message import add_messages +from langgraph.prebuilt import ToolNode, tools_condition +from typing_extensions import TypedDict + +from project.tools import TOOLS + +MODEL_NAME = "gpt-4o" +SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. + +Current date and time: {timestamp} + +Guidelines: +- Be concise and helpful +- Use tools when they would help answer the user's question +- If you're unsure, ask clarifying questions +- Always provide accurate information +""" + + +class AgentState(TypedDict): + """State schema for the agent graph.""" + messages: Annotated[list[Any], add_messages] + + +async def create_graph(): + """Create and compile the agent graph with checkpointer.""" + llm = ChatOpenAI(model=MODEL_NAME) + llm_with_tools = llm.bind_tools(TOOLS) + + checkpointer = await create_checkpointer() + + def agent_node(state: AgentState) -> dict[str, Any]: + """Process the current state and generate a response.""" + messages = state["messages"] + if not messages or not isinstance(messages[0], SystemMessage): + system_content = SYSTEM_PROMPT.format( + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ) + messages = [SystemMessage(content=system_content)] + messages + response = llm_with_tools.invoke(messages) + return {"messages": [response]} + + builder = StateGraph(AgentState) + builder.add_node("agent", agent_node) + builder.add_node("tools", ToolNode(tools=TOOLS)) + builder.add_edge(START, "agent") + builder.add_conditional_edges("agent", tools_condition, "tools") + builder.add_edge("tools", "agent") + + return builder.compile(checkpointer=checkpointer) diff --git a/src/agentex/lib/cli/templates/default-langgraph/project/tools.py.j2 b/src/agentex/lib/cli/templates/default-langgraph/project/tools.py.j2 new file mode 100644 index 000000000..1b402a906 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-langgraph/project/tools.py.j2 @@ -0,0 +1,32 @@ +""" +Tool definitions for the LangGraph agent. + +Add your custom tools here. Each tool should be a function decorated with @tool +or created using the Tool class. +""" + +from langchain_core.tools import Tool + + +def get_weather(city: str) -> str: + """Get the current weather for a city. + + Args: + city: The name of the city to get weather for. + + Returns: + A string describing the weather conditions. + """ + # TODO: Replace with actual weather API call + return f"The weather in {city} is sunny and 72°F" + + +# Define tools +weather_tool = Tool( + name="get_weather", + func=get_weather, + description="Get the current weather for a city. Input should be a city name.", +) + +# Export all tools as a list +TOOLS = [weather_tool] diff --git a/src/agentex/lib/cli/templates/default-langgraph/pyproject.toml.j2 b/src/agentex/lib/cli/templates/default-langgraph/pyproject.toml.j2 new file mode 100644 index 000000000..3c752f025 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-langgraph/pyproject.toml.j2 @@ -0,0 +1,35 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ project_name }}" +version = "0.1.0" +description = "{{ description }}" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "langgraph", + "langchain-openai", + "python-dotenv", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/src/agentex/lib/cli/templates/default-langgraph/requirements.txt.j2 b/src/agentex/lib/cli/templates/default-langgraph/requirements.txt.j2 new file mode 100644 index 000000000..4a148e901 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-langgraph/requirements.txt.j2 @@ -0,0 +1,10 @@ +# Install agentex-sdk from local path +agentex-sdk + +# Scale GenAI Platform Python SDK +scale-gp + +# LangGraph and LangChain +langgraph +langchain-openai +python-dotenv diff --git a/src/agentex/lib/cli/templates/default-langgraph/test_agent.py.j2 b/src/agentex/lib/cli/templates/default-langgraph/test_agent.py.j2 new file mode 100644 index 000000000..ee71f177c --- /dev/null +++ b/src/agentex/lib/cli/templates/default-langgraph/test_agent.py.j2 @@ -0,0 +1,147 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: {{ agent_name }}) +""" + +import os +import uuid +import asyncio +import pytest +import pytest_asyncio +from agentex import AsyncAgentex +from agentex.types import TaskMessage +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam +from test_utils.async_utils import ( + poll_for_agent_response, + send_event_and_poll_yielding, + stream_agent_response, + validate_text_in_response, + poll_messages, +) + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "{{ agent_name }}") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, _agent_name: str, agent_id: str): + """Test sending an event and polling for the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # TODO: Poll for the initial task creation message (if your agent sends one) + # async for message in poll_messages( + # client=client, + # task_id=task.id, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected initial message + # assert "expected initial text" in message.content.content + # break + + # TODO: Send an event and poll for response using the yielding helper function + # user_message = "Your test message here" + # async for message in send_event_and_poll_yielding( + # client=client, + # agent_id=agent_id, + # task_id=task.id, + # user_message=user_message, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected response + # assert "expected response text" in message.content.content + # break + pass + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, _agent_name: str, agent_id: str): + """Test sending an event and streaming the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # user_message = "Your test message here" + + # # Collect events from stream + # all_events = [] + + # async def collect_stream_events(): + # async for event in stream_agent_response( + # client=client, + # task_id=task.id, + # timeout=30, + # ): + # all_events.append(event) + + # # Start streaming task + # stream_task = asyncio.create_task(collect_stream_events()) + + # # Send the event + # event_content = TextContentParam(type="text", author="user", content=user_message) + # await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + + # # Wait for streaming to complete + # await stream_task + + # # TODO: Add your validation here + # assert len(all_events) > 0, "No events received in streaming response" + pass + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/src/agentex/lib/cli/templates/default-openai-agents/.dockerignore.j2 b/src/agentex/lib/cli/templates/default-openai-agents/.dockerignore.j2 new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/src/agentex/lib/cli/templates/default-openai-agents/.dockerignore.j2 @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/src/agentex/lib/cli/templates/default-openai-agents/.env.example.j2 b/src/agentex/lib/cli/templates/default-openai-agents/.env.example.j2 new file mode 100644 index 000000000..015f49ef7 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-openai-agents/.env.example.j2 @@ -0,0 +1,13 @@ +# {{ agent_name }} - Environment Variables +# Copy this file to .env and fill in the values + +# API key for your LLM provider +LITELLM_API_KEY= + +# LLM base URL (optional - override to use a different provider) +# OPENAI_BASE_URL= + +# SGP Configuration (optional - for tracing) +# SGP_API_KEY= +# SGP_ACCOUNT_ID= +# SGP_CLIENT_BASE_URL= 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 new file mode 100644 index 000000000..dd3035f7b --- /dev/null +++ b/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile-uv.j2 @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +ENV UV_HTTP_TIMEOUT=1000 + +WORKDIR /app/{{ project_path_from_build_root }} + +# Copy dependency files for layer caching +COPY {{ project_path_from_build_root }}/pyproject.toml ./ + +# Install dependencies (without project itself, for layer caching) +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-install-project --no-dev + +# Copy the project code +COPY {{ project_path_from_build_root }}/project ./project + +# Install the project +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile.j2 new file mode 100644 index 000000000..056d60b96 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile.j2 @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy just the requirements file to optimize caching +COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt + +WORKDIR /app/{{ project_path_from_build_root }} + +# Install the required Python packages +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 + + +# Set environment variables +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-openai-agents/README.md.j2 b/src/agentex/lib/cli/templates/default-openai-agents/README.md.j2 new file mode 100644 index 000000000..9611e83bd --- /dev/null +++ b/src/agentex/lib/cli/templates/default-openai-agents/README.md.j2 @@ -0,0 +1,69 @@ +# {{ agent_name }} - AgentEx Async OpenAI Agents SDK Agent + +This template builds an **asynchronous** (non-Temporal) agent built on the +**OpenAI Agents SDK**, delivered through the unified harness surface on AgentEx: +- Defines an OpenAI Agents SDK `Agent` (with an example weather tool) inline in + `acp.py` +- Wraps the SDK run in an `OpenAITurn` +- Delivers canonical `StreamTaskMessage*` events via `UnifiedEmitter.auto_send_turn` + (the async Redis push path), so the UI receives output in real time +- Tracing integration to SGP / AgentEx + +## Prerequisites + +- An `OPENAI_API_KEY` in your environment (or a `LITELLM_API_KEY`, which is + copied to `OPENAI_API_KEY` for LiteLLM-proxy compatibility) + +## Running the Agent + +```bash +agentex agents run --manifest manifest.yaml +``` + +## Project Structure + +``` +{{ project_name }}/ +├── project/ +│ ├── __init__.py +│ └── acp.py # ACP server, agent + tool definitions, event handlers +├── Dockerfile +├── manifest.yaml +├── dev.ipynb +{% if use_uv %} +└── pyproject.toml +{% else %} +└── requirements.txt +{% endif %} +``` + +## Key Concepts + +### Async ACP with the harness +The async ACP model streams events over Redis instead of an HTTP response. The +`@acp.on_task_event_send` handler runs the OpenAI Agents SDK and pushes the +harness events to the task stream. + +### The unified harness surface +`OpenAITurn` + `UnifiedEmitter` are the unified harness surface. The turn +normalizes the SDK's streamed run into canonical AgentEx events; the emitter +traces and delivers them. + +## Development + +### 1. Add Your Own Tools +Define new `@function_tool` functions in `project/acp.py` and add them to the +agent's `tools=[...]` list in `create_agent()`. + +### 2. Customize the Agent +Edit `MODEL_NAME` and `INSTRUCTIONS` in `project/acp.py` to change the model or +system prompt. + +### 3. Configure Credentials +Set your credentials via `manifest.yaml`, an exported environment variable, or a +`.env` file in the project directory. + +### 4. Run Locally +```bash +export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml +``` diff --git a/src/agentex/lib/cli/templates/default-openai-agents/dev.ipynb.j2 b/src/agentex/lib/cli/templates/default-openai-agents/dev.ipynb.j2 new file mode 100644 index 000000000..b0691b1b1 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-openai-agents/dev.ipynb.j2 @@ -0,0 +1,167 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"{{ agent_name }}\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [], + "source": [ + "# # (Optional) Create a new task. If you don't create a new task, each message will be sent to a new task. The server will create the task for you.\n", + "\n", + "# import uuid\n", + "\n", + "# TASK_ID = str(uuid.uuid4())[:8]\n", + "\n", + "# rpc_response = client.agents.rpc_by_name(\n", + "# agent_name=AGENT_NAME,\n", + "# method=\"task/create\",\n", + "# params={\n", + "# \"name\": f\"{TASK_ID}-task\",\n", + "# \"params\": {}\n", + "# }\n", + "# )\n", + "\n", + "# task = rpc_response.result\n", + "# print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b03b0d37", + "metadata": {}, + "outputs": [], + "source": [ + "# Test non streaming response\n", + "from agentex.types import TextContent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_message(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": False\n", + " }\n", + ")\n", + "\n", + "if not rpc_response or not rpc_response.result:\n", + " raise ValueError(\"No result in response\")\n", + "\n", + "# Extract and print just the text content from the response\n", + "for task_message in rpc_response.result:\n", + " content = task_message.content\n", + " if isinstance(content, TextContent):\n", + " text = content.content\n", + " print(text)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "79688331", + "metadata": {}, + "outputs": [], + "source": [ + "# Test streaming response\n", + "from agentex.types.task_message_update import StreamTaskMessageDelta, StreamTaskMessageFull\n", + "from agentex.types.text_delta import TextDelta\n", + "\n", + "\n", + "# The result object of message/send will be a TaskMessageUpdate which is a union of the following types:\n", + "# - StreamTaskMessageStart: \n", + "# - An indicator that a streaming message was started, doesn't contain any useful content\n", + "# - StreamTaskMessageDelta: \n", + "# - A delta of a streaming message, contains the text delta to aggregate\n", + "# - StreamTaskMessageDone: \n", + "# - An indicator that a streaming message was done, doesn't contain any useful content\n", + "# - StreamTaskMessageFull: \n", + "# - A non-streaming message, there is nothing to aggregate, since this contains the full message, not deltas\n", + "\n", + "# Whenn processing StreamTaskMessageDelta, if you are expecting more than TextDeltas, such as DataDelta, ToolRequestDelta, or ToolResponseDelta, you can process them as well\n", + "# Whenn processing StreamTaskMessageFull, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "for agent_rpc_response_chunk in client.agents.send_message_stream(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": True\n", + " }\n", + "):\n", + " # We know that the result of the message/send when stream is set to True will be a TaskMessageUpdate\n", + " task_message_update = agent_rpc_response_chunk.result\n", + " # Print oly the text deltas as they arrive or any full messages\n", + " if isinstance(task_message_update, StreamTaskMessageDelta):\n", + " delta = task_message_update.delta\n", + " if isinstance(delta, TextDelta):\n", + " print(delta.text_delta, end=\"\", flush=True)\n", + " else:\n", + " print(f\"Found non-text {type(task_message_update)} object in streaming message.\")\n", + " elif isinstance(task_message_update, StreamTaskMessageFull):\n", + " content = task_message_update.content\n", + " if isinstance(content, TextContent):\n", + " print(content.content)\n", + " else:\n", + " print(f\"Found non-text {type(task_message_update)} object in full message.\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c5e7e042", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/agentex/lib/cli/templates/default-openai-agents/environments.yaml.j2 b/src/agentex/lib/cli/templates/default-openai-agents/environments.yaml.j2 new file mode 100644 index 000000000..73924abdd --- /dev/null +++ b/src/agentex/lib/cli/templates/default-openai-agents/environments.yaml.j2 @@ -0,0 +1,53 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-{{agent_name}}" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + diff --git a/src/agentex/lib/cli/templates/default-openai-agents/manifest.yaml.j2 b/src/agentex/lib/cli/templates/default-openai-agents/manifest.yaml.j2 new file mode 100644 index 000000000..b633518be --- /dev/null +++ b/src/agentex/lib/cli/templates/default-openai-agents/manifest.yaml.j2 @@ -0,0 +1,115 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../ # Keep this as the default root + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - {{ project_path_from_build_root }} + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: {{ project_path_from_build_root }}/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: {{ project_path_from_build_root }}/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + +# Agent Configuration +# ----------------- +agent: + acp_type: async + # Unique name for your agent + # Used for task routing and monitoring + name: {{ agent_name }} + + # Description of what your agent does + # Helps with documentation and discovery + description: {{ description | tojson }} + + # Temporal workflow configuration + # Set enabled: true to use Temporal workflows for long-running tasks + temporal: + enabled: false + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + credentials: [] # Update with your credentials + # - env_var_name: LITELLM_API_KEY + # secret_name: litellm-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + env: {} # Update with your environment variables + # LITELLM_API_KEY: "" + # OPENAI_BASE_URL: "" + # OPENAI_ORG_ID: "" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: [] # Update with your image pull secret names + # - name: my-registry-secret + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific environments (environments.yaml) + global: + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file 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 new file mode 100644 index 000000000..66ee31243 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-openai-agents/project/acp.py.j2 @@ -0,0 +1,171 @@ +"""ACP handler for {{ agent_name }} — an async OpenAI Agents SDK agent. + +Uses the async ACP model with Redis streaming instead of HTTP yields. The +OpenAI Agents SDK run is wrapped in an ``OpenAITurn`` and pushed to the task +stream via ``UnifiedEmitter.auto_send_turn`` — the async delivery path of the +unified harness surface. ``auto_send_turn`` returns a ``TurnResult`` carrying +the accumulated final text and normalized usage. + +The agent and its tools are defined inline below so this template stays a +single, self-contained ``acp.py``. +""" + +from __future__ import annotations + +import os +from typing import List +from datetime import datetime + +from dotenv import load_dotenv + +load_dotenv() + +from agents import Agent, Runner, function_tool, set_tracing_disabled + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams +from agentex.lib.types.fastacp import AsyncACPConfig +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.sdk.fastacp.fastacp import FastACP +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 + +# 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__) + +# LiteLLM proxy auth: copy LITELLM_API_KEY to OPENAI_API_KEY for OpenAI client compatibility. +_litellm_key = os.environ.get("LITELLM_API_KEY") +if _litellm_key and not os.environ.get("OPENAI_API_KEY"): + os.environ["OPENAI_API_KEY"] = _litellm_key + +_sgp_api_key = os.environ.get("SGP_API_KEY", "") +_sgp_account_id = os.environ.get("SGP_ACCOUNT_ID", "") +if _sgp_api_key and _sgp_account_id: + add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=_sgp_api_key, + sgp_account_id=_sgp_account_id, + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) + ) + +acp = FastACP.create( + acp_type="async", + config=AsyncACPConfig(type="base"), +) + +MODEL_NAME = "gpt-4o" +INSTRUCTIONS = """You are a helpful AI assistant with access to tools. + +Current date and time: {timestamp} + +Guidelines: +- Be concise and helpful +- Use the weather tool when the user asks about the weather +- Always report the real tool output back to the user +""" + + +@function_tool +def get_weather(city: str) -> str: + """Get the current weather for a city.""" + return f"The weather in {city} is sunny and 72°F" + + +def create_agent() -> Agent: + """Build and return the OpenAI Agents SDK agent with the weather tool.""" + return Agent( + name="{{ agent_name }}", + model=MODEL_NAME, + instructions=INSTRUCTIONS.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + tools=[get_weather], + ) + + +def get_agent() -> Agent: + """Build a fresh agent per request so the timestamp in the instructions stays current.""" + return create_agent() + + +class StateModel(BaseModel): + """Per-task conversation state persisted between turns.""" + + input_list: List[dict] + turn_number: int + + +@acp.on_task_create +async def handle_task_create(params: CreateTaskParams): + logger.info(f"Task created: {params.task.id}") + + +@acp.on_task_event_send +async def handle_task_event_send(params: SendEventParams): + """Handle each user message: run the agent and auto-send its turn.""" + agent = get_agent() + task_id = params.task.id + agent_id = params.agent.id + content = params.event.content + if not isinstance(content, TextContent): + logger.warning("Ignoring non-text event content (type=%s)", getattr(content, "type", "?")) + return + user_message = content.content + + logger.info(f"Processing message for task {task_id}") + + # Echo the user's message into the task history. + await adk.messages.create(task_id=task_id, content=params.event.content) + + # Load (or create) the persisted conversation history for this task so the + # agent can see prior turns, then append the new user message. + task_state = await adk.state.get_by_task_and_agent(task_id=task_id, agent_id=agent_id) + if task_state is None: + state = StateModel(input_list=[], turn_number=0) + task_state = await adk.state.create(task_id=task_id, agent_id=agent_id, state=state) + else: + state = StateModel.model_validate(task_state.state) + + state.turn_number += 1 + state.input_list.append({"role": "user", "content": user_message}) + + async with adk.tracing.span( + trace_id=task_id, + task_id=task_id, + name="message", + input={"message": user_message}, + data={"__span_type__": "AGENT_WORKFLOW"}, + ) as turn_span: + result = Runner.run_streamed(starting_agent=agent, input=state.input_list) + turn = OpenAITurn(result=result, model=MODEL_NAME) + emitter = UnifiedEmitter( + task_id=task_id, + trace_id=task_id, + parent_span_id=turn_span.id if turn_span else None, + ) + turn_result = await emitter.auto_send_turn(turn) + + # Persist the full conversation history (user + assistant + tool calls) + # so the next turn resumes with complete context. + state.input_list = result.to_input_list() + await adk.state.update( + state_id=task_state.id, + task_id=task_id, + agent_id=agent_id, + state=state, + ) + + if turn_span: + turn_span.output = {"final_output": turn_result.final_text} + + +@acp.on_task_cancel +async def handle_task_canceled(params: CancelTaskParams): + logger.info(f"Task canceled: {params.task.id}") diff --git a/src/agentex/lib/cli/templates/default-openai-agents/pyproject.toml.j2 b/src/agentex/lib/cli/templates/default-openai-agents/pyproject.toml.j2 new file mode 100644 index 000000000..4b9c7ed71 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-openai-agents/pyproject.toml.j2 @@ -0,0 +1,34 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ project_name }}" +version = "0.1.0" +description = "{{ description }}" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "openai-agents", + "python-dotenv>=1.0,<2", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/src/agentex/lib/cli/templates/default-openai-agents/requirements.txt.j2 b/src/agentex/lib/cli/templates/default-openai-agents/requirements.txt.j2 new file mode 100644 index 000000000..14779c089 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-openai-agents/requirements.txt.j2 @@ -0,0 +1,11 @@ +# Install agentex-sdk from local path +agentex-sdk + +# Scale GenAI Platform Python SDK +scale-gp + +# OpenAI Agents SDK +openai-agents + +# Loads .env files for local development +python-dotenv>=1.0,<2 diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/.dockerignore.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/.dockerignore.j2 new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/src/agentex/lib/cli/templates/default-pydantic-ai/.dockerignore.j2 @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/.env.example.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/.env.example.j2 new file mode 100644 index 000000000..1e81b15dd --- /dev/null +++ b/src/agentex/lib/cli/templates/default-pydantic-ai/.env.example.j2 @@ -0,0 +1,12 @@ +# {{ agent_name }} - Environment Variables +# Copy this file to .env and fill in the values + +# API key for your LLM provider +LITELLM_API_KEY= + +# LLM base URL (optional - override to use a different provider) +# OPENAI_BASE_URL= + +# SGP Configuration (optional - for tracing) +# SGP_API_KEY= +# SGP_ACCOUNT_ID= 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 new file mode 100644 index 000000000..dd3035f7b --- /dev/null +++ b/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile-uv.j2 @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +ENV UV_HTTP_TIMEOUT=1000 + +WORKDIR /app/{{ project_path_from_build_root }} + +# Copy dependency files for layer caching +COPY {{ project_path_from_build_root }}/pyproject.toml ./ + +# Install dependencies (without project itself, for layer caching) +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-install-project --no-dev + +# Copy the project code +COPY {{ project_path_from_build_root }}/project ./project + +# Install the project +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile.j2 new file mode 100644 index 000000000..0395caf74 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile.j2 @@ -0,0 +1,42 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + node \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy just the requirements file to optimize caching +COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt + +WORKDIR /app/{{ project_path_from_build_root }} + +# Install the required Python packages +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 + +# Set environment variables +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/README.md.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/README.md.j2 new file mode 100644 index 000000000..40ca35458 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-pydantic-ai/README.md.j2 @@ -0,0 +1,77 @@ +# {{ agent_name }} - AgentEx Async ACP + Pydantic AI + +This template builds an **asynchronous** [Pydantic AI](https://ai.pydantic.dev/) +agent on AgentEx with: +- Task-based event handling, with deltas streamed back over Redis +- Tool calling (typed, declarative — pydantic-ai owns the tool-call loop) +- **Multi-turn conversation memory** persisted in `adk.state` +- Per-turn tracing spans, with per-tool-call child spans + +## Sync vs Async + +| Aspect | Sync | Async (This Template) | +|---|---|---| +| **ACP Type** | `sync` | `async` | +| **Handler** | `@acp.on_message_send` | `@acp.on_task_event_send` | +| **Response** | HTTP streaming (yields) | Redis streaming | +| **Streaming Helper** | `convert_pydantic_ai_to_agentex_events()` | `stream_pydantic_ai_events()` | +| **Tracing** | wraps a single HTTP request | wraps each task event | + +### When to use Async? +- Long-running tasks that may exceed HTTP timeout +- Agents that need to push updates after the request returns +- Production agents that need reliable message delivery via Redis + +## Running the Agent + +```bash +agentex agents run --manifest manifest.yaml +``` + +## Project Structure + +``` +{{ project_name }}/ +├── project/ +│ ├── __init__.py +│ ├── acp.py # ACP server, tracing wiring, multi-turn state +│ ├── agent.py # Pydantic AI Agent + tool registration +│ └── tools.py # Tool function implementations +├── Dockerfile +├── manifest.yaml +├── dev.ipynb +{% if use_uv %} +└── pyproject.toml +{% else %} +└── requirements.txt +{% endif %} +``` + +## Development + +### 1. Add Your Own Tools +Edit `project/tools.py` to add tool functions and register them in `project/agent.py`: + +```python +# project/tools.py +def search_docs(query: str) -> str: + """Look up internal docs.""" + return "..." + +# project/agent.py — inside create_agent() +agent.tool_plain(search_docs) +``` + +### 2. Customize the Agent +Edit `project/agent.py` to swap the model (`MODEL_NAME`) or system prompt. + +### 3. Configure Credentials +Set your LLM API key: +1. In `manifest.yaml` under `env.LITELLM_API_KEY` +2. Or export: `export LITELLM_API_KEY=...` +3. Or create a `.env` file in the project directory + +### 4. Run Locally +```bash +export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml +``` diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/dev.ipynb.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/dev.ipynb.j2 new file mode 100644 index 000000000..d3a68303f --- /dev/null +++ b/src/agentex/lib/cli/templates/default-pydantic-ai/dev.ipynb.j2 @@ -0,0 +1,126 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"{{ agent_name }}\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [], + "source": [ + "# (REQUIRED) Create a new task. For Async agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b03b0d37", + "metadata": {}, + "outputs": [], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a6927cc0", + "metadata": {}, + "outputs": [], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4864e354", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/environments.yaml.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/environments.yaml.j2 new file mode 100644 index 000000000..f802776f0 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-pydantic-ai/environments.yaml.j2 @@ -0,0 +1,57 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-{{agent_name}}" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + temporal: + enabled: false + + diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/manifest.yaml.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/manifest.yaml.j2 new file mode 100644 index 000000000..e6c15cf33 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-pydantic-ai/manifest.yaml.j2 @@ -0,0 +1,120 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../ # Keep this as the default root + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - {{ project_path_from_build_root }} + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: {{ project_path_from_build_root }}/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: {{ project_path_from_build_root }}/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + +# Agent Configuration +# ----------------- +agent: + acp_type: async + + # Unique name for your agent + # Used for task routing and monitoring + name: {{ agent_name }} + + # Description of what your agent does + # Helps with documentation and discovery + description: {{ description | tojson }} + + # Temporal workflow configuration + # Set enabled: true to use Temporal workflows for long-running tasks + temporal: + enabled: false + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + credentials: + - env_var_name: LITELLM_API_KEY + secret_name: litellm-api-key + secret_key: api-key + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + env: + LITELLM_API_KEY: "" # Set your LLM API key + # OPENAI_BASE_URL: "" + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: [] # Update with your image pull secret names + # - name: my-registry-secret + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific environments (environments.yaml) + global: + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/project/acp.py.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/project/acp.py.j2 new file mode 100644 index 000000000..245f9ec38 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-pydantic-ai/project/acp.py.j2 @@ -0,0 +1,170 @@ +"""ACP handler for async Pydantic AI agent. + +Uses the async ACP model with Redis streaming instead of HTTP yields. +Text and reasoning tokens stream as Redis deltas; tool requests and +responses are persisted as discrete full messages. + +Multi-turn memory is persisted via ``adk.state``: on each turn we load the +previous pydantic-ai ``message_history`` from state, run the agent with it, +then save the updated history back. Without this, every turn would be a +fresh stateless run and the agent would forget the prior conversation. +""" + +from __future__ import annotations + +import os +from typing import Any, AsyncIterator + +from dotenv import load_dotenv + +load_dotenv() + +from project.agent import MODEL_NAME, create_agent +from pydantic_ai.run import AgentRunResultEvent +from pydantic_ai.messages import ModelMessagesTypeAdapter + +import agentex.lib.adk as adk +from agentex.protocol.acp import SendEventParams, CancelTaskParams, CreateTaskParams +from agentex.lib.core.harness import UnifiedEmitter +from agentex.lib.types.fastacp import AsyncACPConfig +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.lib.adk import PydanticAITurn +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config + +logger = make_logger(__name__) + +# Register the SGP tracing exporter. Spans also reach the AgentEx backend +# via the default Agentex processor that's lazy-initialised on first span, +# so they show up in the per-task spans dropdown out of the box. +SGP_API_KEY = os.environ.get("SGP_API_KEY", "") +SGP_ACCOUNT_ID = os.environ.get("SGP_ACCOUNT_ID", "") +if SGP_API_KEY and SGP_ACCOUNT_ID: + add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=SGP_API_KEY, + sgp_account_id=SGP_ACCOUNT_ID, + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) + ) + +acp = FastACP.create( + acp_type="async", + config=AsyncACPConfig(type="base"), +) + +_agent = None + + +def get_agent(): + """Return the cached Pydantic AI agent, creating it on first use.""" + global _agent + if _agent is None: + _agent = create_agent() + return _agent + + +class ConversationState(BaseModel): + """Per-task conversation state persisted via ``adk.state``. + + ``history_json`` holds the pydantic-ai message history serialized by + ``ModelMessagesTypeAdapter`` — pydantic-ai's official way to round-trip + ``ModelMessage`` objects through JSON. We can't use a plain + ``list[ModelMessage]`` field because ``ModelMessage`` is a discriminated + union of runtime types, not a stable Pydantic schema. + """ + + history_json: str = "[]" + turn_number: int = 0 + + +@acp.on_task_create +async def handle_task_create(params: CreateTaskParams): + """Initialize per-task state on task creation.""" + logger.info(f"Task created: {params.task.id}") + await adk.state.create( + task_id=params.task.id, + agent_id=params.agent.id, + state=ConversationState(), + ) + + +@acp.on_task_event_send +async def handle_task_event_send(params: SendEventParams): + """Handle each user message: load prior history, run the agent, save updated history.""" + agent = get_agent() + task_id = params.task.id + agent_id = params.agent.id + content = params.event.content + if not isinstance(content, TextContent): + logger.warning("Ignoring non-text event content (type=%s)", getattr(content, "type", "?")) + return + user_message = content.content + + logger.info(f"Processing message for task {task_id}") + + # Echo the user's message into the task history. + await adk.messages.create(task_id=task_id, content=params.event.content) + + # Load prior conversation state. Fall back to a fresh state if missing + # (e.g. the task wasn't initialised through on_task_create). + task_state = await adk.state.get_by_task_and_agent(task_id=task_id, agent_id=agent_id) + if task_state is None: + state = ConversationState() + task_state = await adk.state.create(task_id=task_id, agent_id=agent_id, state=state) + else: + state = ConversationState.model_validate(task_state.state) + + state.turn_number += 1 + previous_messages = ModelMessagesTypeAdapter.validate_json(state.history_json) + + async with adk.tracing.span( + trace_id=task_id, + task_id=task_id, + name=f"Turn {state.turn_number}", + input={"message": user_message}, + data={"__span_type__": "AGENT_WORKFLOW"}, + ) as turn_span: + # Construct the UnifiedEmitter from the ACP context so tracing is + # automatic and messages are auto-sent to the task stream (Redis). + emitter = UnifiedEmitter( + task_id=task_id, + trace_id=task_id, + parent_span_id=turn_span.id if turn_span else None, + ) + + # Wrap the pydantic-ai event stream so we can capture the final + # AgentRunResultEvent (which carries the full message list for the + # next turn) before forwarding events to the emitter. + captured_messages: list[Any] = [] + + async def tee_messages(upstream) -> AsyncIterator[Any]: + async for event in upstream: + if isinstance(event, AgentRunResultEvent): + captured_messages[:] = list(event.result.all_messages()) + yield event + + async with agent.run_stream_events(user_message, message_history=previous_messages) as stream: + turn = PydanticAITurn(tee_messages(stream), model=MODEL_NAME) + result = await emitter.auto_send_turn(turn) + + # Save the updated message history so the next turn picks up here. + if captured_messages: + state.history_json = ModelMessagesTypeAdapter.dump_json(captured_messages).decode() + await adk.state.update( + state_id=task_state.id, + task_id=task_id, + agent_id=agent_id, + state=state, + ) + + if turn_span: + turn_span.output = {"final_output": result.final_text} + + +@acp.on_task_cancel +async def handle_task_canceled(params: CancelTaskParams): + logger.info(f"Task canceled: {params.task.id}") diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/project/agent.py.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/project/agent.py.j2 new file mode 100644 index 000000000..3e6fd1711 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-pydantic-ai/project/agent.py.j2 @@ -0,0 +1,43 @@ +"""Pydantic AI agent definition for {{ agent_name }}. + +Constructs a ``pydantic_ai.Agent`` with tools registered. The Agent is the +boundary between this module and the API layer (acp.py); pydantic-ai +handles its own tool-call loop internally. +""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic_ai import Agent +from project.tools import get_weather + +# Swap this for any Pydantic AI-supported model identifier +# (e.g. "anthropic:claude-3-5-sonnet-latest", "openai:gpt-4o"). +MODEL_NAME = "openai:gpt-4o-mini" + +SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. + +Current date and time: {timestamp} + +Guidelines: +- Be concise and helpful +- Use tools when they would help answer the user's question +- If you're unsure, ask clarifying questions +- Always provide accurate information +""" + + +def create_agent() -> Agent: + """Build and return the Pydantic AI agent with tools registered.""" + agent = Agent( + MODEL_NAME, + system_prompt=SYSTEM_PROMPT.format( + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + ), + ) + + # Register additional tools by adding more `agent.tool_plain(...)` calls. + agent.tool_plain(get_weather) + + return agent diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/project/tools.py.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/project/tools.py.j2 new file mode 100644 index 000000000..bab87942a --- /dev/null +++ b/src/agentex/lib/cli/templates/default-pydantic-ai/project/tools.py.j2 @@ -0,0 +1,20 @@ +"""Tool definitions for the Pydantic AI agent. + +Pydantic AI tools are registered directly on the Agent via decorators +(see project.agent). This module hosts the bare functions so they're +easy to unit-test in isolation. +""" + +from __future__ import annotations + + +def get_weather(city: str) -> str: + """Get the current weather for a city. + + Args: + city: The name of the city to get weather for. + + Returns: + A string describing the weather conditions. + """ + return f"The weather in {city} is sunny and 72°F" diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/pyproject.toml.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/pyproject.toml.j2 new file mode 100644 index 000000000..8881c5b74 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-pydantic-ai/pyproject.toml.j2 @@ -0,0 +1,34 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ project_name }}" +version = "0.1.0" +description = "{{ description }}" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "pydantic-ai-slim[openai]>=1.0,<2", + "python-dotenv", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/requirements.txt.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/requirements.txt.j2 new file mode 100644 index 000000000..75e880b53 --- /dev/null +++ b/src/agentex/lib/cli/templates/default-pydantic-ai/requirements.txt.j2 @@ -0,0 +1,9 @@ +# Install agentex-sdk from local path +agentex-sdk + +# Scale GenAI Platform Python SDK +scale-gp + +# Pydantic AI agent framework +pydantic-ai-slim[openai]>=1.0,<2 +python-dotenv diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/test_agent.py.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/test_agent.py.j2 new file mode 100644 index 000000000..ee71f177c --- /dev/null +++ b/src/agentex/lib/cli/templates/default-pydantic-ai/test_agent.py.j2 @@ -0,0 +1,147 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: {{ agent_name }}) +""" + +import os +import uuid +import asyncio +import pytest +import pytest_asyncio +from agentex import AsyncAgentex +from agentex.types import TaskMessage +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam +from test_utils.async_utils import ( + poll_for_agent_response, + send_event_and_poll_yielding, + stream_agent_response, + validate_text_in_response, + poll_messages, +) + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "{{ agent_name }}") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, _agent_name: str, agent_id: str): + """Test sending an event and polling for the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # TODO: Poll for the initial task creation message (if your agent sends one) + # async for message in poll_messages( + # client=client, + # task_id=task.id, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected initial message + # assert "expected initial text" in message.content.content + # break + + # TODO: Send an event and poll for response using the yielding helper function + # user_message = "Your test message here" + # async for message in send_event_and_poll_yielding( + # client=client, + # agent_id=agent_id, + # task_id=task.id, + # user_message=user_message, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected response + # assert "expected response text" in message.content.content + # break + pass + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, _agent_name: str, agent_id: str): + """Test sending an event and streaming the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # user_message = "Your test message here" + + # # Collect events from stream + # all_events = [] + + # async def collect_stream_events(): + # async for event in stream_agent_response( + # client=client, + # task_id=task.id, + # timeout=30, + # ): + # all_events.append(event) + + # # Start streaming task + # stream_task = asyncio.create_task(collect_stream_events()) + + # # Send the event + # event_content = TextContentParam(type="text", author="user", content=user_message) + # await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + + # # Wait for streaming to complete + # await stream_task + + # # TODO: Add your validation here + # assert len(all_events) > 0, "No events received in streaming response" + pass + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/src/agentex/lib/cli/templates/default/.dockerignore.j2 b/src/agentex/lib/cli/templates/default/.dockerignore.j2 new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/src/agentex/lib/cli/templates/default/.dockerignore.j2 @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/src/agentex/lib/cli/templates/default/.env.example.j2 b/src/agentex/lib/cli/templates/default/.env.example.j2 new file mode 100644 index 000000000..015f49ef7 --- /dev/null +++ b/src/agentex/lib/cli/templates/default/.env.example.j2 @@ -0,0 +1,13 @@ +# {{ agent_name }} - Environment Variables +# Copy this file to .env and fill in the values + +# API key for your LLM provider +LITELLM_API_KEY= + +# LLM base URL (optional - override to use a different provider) +# OPENAI_BASE_URL= + +# SGP Configuration (optional - for tracing) +# SGP_API_KEY= +# SGP_ACCOUNT_ID= +# SGP_CLIENT_BASE_URL= diff --git a/src/agentex/lib/cli/templates/default/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default/Dockerfile-uv.j2 new file mode 100644 index 000000000..dd3035f7b --- /dev/null +++ b/src/agentex/lib/cli/templates/default/Dockerfile-uv.j2 @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +ENV UV_HTTP_TIMEOUT=1000 + +WORKDIR /app/{{ project_path_from_build_root }} + +# Copy dependency files for layer caching +COPY {{ project_path_from_build_root }}/pyproject.toml ./ + +# Install dependencies (without project itself, for layer caching) +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-install-project --no-dev + +# Copy the project code +COPY {{ project_path_from_build_root }}/project ./project + +# Install the project +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default/Dockerfile.j2 b/src/agentex/lib/cli/templates/default/Dockerfile.j2 new file mode 100644 index 000000000..0395caf74 --- /dev/null +++ b/src/agentex/lib/cli/templates/default/Dockerfile.j2 @@ -0,0 +1,42 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + node \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy just the requirements file to optimize caching +COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt + +WORKDIR /app/{{ project_path_from_build_root }} + +# Install the required Python packages +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 + +# Set environment variables +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default/README.md.j2 b/src/agentex/lib/cli/templates/default/README.md.j2 new file mode 100644 index 000000000..26d49c8f7 --- /dev/null +++ b/src/agentex/lib/cli/templates/default/README.md.j2 @@ -0,0 +1,214 @@ +# {{ agent_name }} - AgentEx Starter Template + +This is a generic starter template for building agents with the AgentEx framework. It provides a basic implementation of the Agent 2 Client Protocol (ACP) to help you get started quickly. + +## What You'll Learn + +- **Tasks**: A task is a grouping mechanism for related messages. Think of it as a conversation thread or a session. +- **Messages**: Messages are communication objects within a task. They can contain text, data, or instructions. +- **ACP Events**: The agent responds to four main events: + - `task_received`: When a new task is created + - `task_message_received`: When a message is sent within a task + - `task_approved`: When a task is approved + - `task_canceled`: When a task is canceled + +## Running the Agent + +1. Run the agent locally: +```bash +agentex agents run --manifest manifest.yaml +``` + +The agent will start on port 8000 and print messages whenever it receives any of the ACP events. + +## What's Inside + +This template: +- Sets up a basic ACP server +- Handles each of the required ACP events with simple print statements +- Provides a foundation for building more complex agents + +## Next Steps + +For more advanced agent development, check out the AgentEx tutorials: + +- **Tutorials 00-08**: Learn about building synchronous agents with ACP +- **Tutorials 09-10**: Learn how to use Temporal to power asynchronous agents + - Tutorial 09: Basic Temporal workflow setup + - Tutorial 10: Advanced Temporal patterns and best practices + +These tutorials will help you understand: +- How to handle long-running tasks +- Implementing state machines +- Managing complex workflows +- Best practices for async agent development + +## The Manifest File + +The `manifest.yaml` file is your agent's configuration file. It defines: +- How your agent should be built and packaged +- What files are included in your agent's Docker image +- Your agent's name and description +- Local development settings (like the port your agent runs on) + +This file is essential for both local development and deployment of your agent. + +## Project Structure + +``` +{{ project_name }}/ +├── project/ # Your agent's code +│ ├── __init__.py +│ └── acp.py # ACP server and event handlers +├── Dockerfile # Container definition +├── manifest.yaml # Deployment config +├── dev.ipynb # Development notebook for testing +{% if use_uv %} +└── pyproject.toml # Dependencies (uv) +{% else %} +└── requirements.txt # Dependencies (pip) +{% endif %} +``` + +## Development + +### 1. Customize Event Handlers +- Modify the handlers in `acp.py` to implement your agent's logic +- Add your own tools and capabilities +- Implement custom state management + +### 2. Test Your Agent with the Development Notebook +Use the included `dev.ipynb` Jupyter notebook to test your agent interactively: + +```bash +# Start Jupyter notebook (make sure you have jupyter installed) +jupyter notebook dev.ipynb + +# Or use VS Code to open the notebook directly +code dev.ipynb +``` + +The notebook includes: +- **Setup**: Connect to your local AgentEx backend +- **Task creation**: Create a new task for the conversation +- **Event sending**: Send events to the agent and get responses +- **Async message subscription**: Subscribe to server-side events to receive agent responses +- **Rich message display**: Beautiful formatting with timestamps and author information + +The notebook automatically uses your agent name (`{{ agent_name }}`) and demonstrates the async ACP workflow: create task → send event → subscribe to responses. + +### 3. Manage Dependencies + +{% if use_uv %} +You chose **uv** for package management. Here's how to work with dependencies: + +```bash +# Add new dependencies +agentex uv add requests openai anthropic + +# Install/sync dependencies +agentex uv sync + +# Run commands with uv +uv run agentex agents run --manifest manifest.yaml +``` + +**Benefits of uv:** +- Faster dependency resolution and installation +- Better dependency isolation +- Modern Python packaging standards + +{% else %} +You chose **pip** for package management. Here's how to work with dependencies: + +```bash +# Edit requirements.txt manually to add dependencies +echo "requests" >> requirements.txt +echo "openai" >> requirements.txt + +# Install dependencies +pip install -r requirements.txt +``` + +**Benefits of pip:** +- Familiar workflow for most Python developers +- Simple requirements.txt management +- Wide compatibility +{% endif %} + +### 4. Configure Credentials +Options: +1. Add any required credentials to your manifest.yaml via the `env` section +2. Export them in your shell: `export LITELLM_API_KEY=...` +3. For local development, create a `.env.local` file in the project directory + +```python +import os +from dotenv import load_dotenv + +if os.environ.get("ENVIRONMENT") == "development": + load_dotenv() +``` + +## Local Development + + +### 1. Start the Agentex Backend +```bash +# Navigate to the backend directory +cd agentex + +# Start all services using Docker Compose +make dev + +# Optional: In a separate terminal, use lazydocker for a better UI (everything should say "healthy") +lzd +``` + +### 2. Setup Your Agent's requirements/pyproject.toml +```bash +agentex uv sync [--group editable-apy] +source .venv/bin/activate + +# OR +conda create -n {{ project_name }} python=3.12 +conda activate {{ project_name }} +pip install -r requirements.txt +``` +### 3. Run Your Agent +```bash +# From this directory +export ENVIRONMENT=development && [uv run] agentex agents run --manifest manifest.yaml +``` + +### 4. Interact with Your Agent + +Option 0: CLI (deprecated - to be replaced once a new CLI is implemented - please use the web UI for now!) +```bash +# Submit a task via CLI +agentex tasks submit --agent {{ agent_name }} --task "Your task here" +``` + +Option 1: Web UI +```bash +# Start the local web interface +cd agentex-web +make dev + +# Then open http://localhost:3000 in your browser to chat with your agent +``` + +## Development Tips + +### Environment Variables +- Set environment variables in project/.env for any required credentials +- Or configure them in the manifest.yaml under the `env` section +- The `.env` file is automatically loaded in development mode + +### To build the agent Docker image locally (normally not necessary): + +1. Build the agent image: +```bash +agentex agents build --manifest manifest.yaml +``` + diff --git a/src/agentex/lib/cli/templates/default/dev.ipynb.j2 b/src/agentex/lib/cli/templates/default/dev.ipynb.j2 new file mode 100644 index 000000000..d3a68303f --- /dev/null +++ b/src/agentex/lib/cli/templates/default/dev.ipynb.j2 @@ -0,0 +1,126 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"{{ agent_name }}\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [], + "source": [ + "# (REQUIRED) Create a new task. For Async agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b03b0d37", + "metadata": {}, + "outputs": [], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a6927cc0", + "metadata": {}, + "outputs": [], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4864e354", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/agentex/lib/cli/templates/default/environments.yaml.j2 b/src/agentex/lib/cli/templates/default/environments.yaml.j2 new file mode 100644 index 000000000..f802776f0 --- /dev/null +++ b/src/agentex/lib/cli/templates/default/environments.yaml.j2 @@ -0,0 +1,57 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-{{agent_name}}" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + temporal: + enabled: false + + diff --git a/src/agentex/lib/cli/templates/default/manifest.yaml.j2 b/src/agentex/lib/cli/templates/default/manifest.yaml.j2 new file mode 100644 index 000000000..c78ce1f44 --- /dev/null +++ b/src/agentex/lib/cli/templates/default/manifest.yaml.j2 @@ -0,0 +1,119 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../ # Keep this as the default root + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - {{ project_path_from_build_root }} + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: {{ project_path_from_build_root }}/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: {{ project_path_from_build_root }}/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + +# Agent Configuration +# ----------------- +agent: + acp_type: async + + # Unique name for your agent + # Used for task routing and monitoring + name: {{ agent_name }} + + # Description of what your agent does + # Helps with documentation and discovery + description: {{ description | tojson }} + + # Temporal workflow configuration + # Set enabled: true to use Temporal workflows for long-running tasks + temporal: + enabled: false + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + credentials: + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + # credentials: + # - env_var_name: LITELLM_API_KEY + # secret_name: litellm-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + env: {} + # LITELLM_API_KEY: "" + # OPENAI_BASE_URL: "" + # OPENAI_ORG_ID: "" + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: [] # Update with your image pull secret names + # - name: my-registry-secret + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific environments (environments.yaml) + global: + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default/project/acp.py.j2 b/src/agentex/lib/cli/templates/default/project/acp.py.j2 new file mode 100644 index 000000000..b0da14a5c --- /dev/null +++ b/src/agentex/lib/cli/templates/default/project/acp.py.j2 @@ -0,0 +1,56 @@ +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.lib.types.fastacp import AsyncACPConfig +from agentex.protocol.acp import SendEventParams, CancelTaskParams, CreateTaskParams +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib import adk + + +logger = make_logger(__name__) + + +# Create an ACP server +# This sets up the core server that will handle task creation, events, and cancellation +# The `type="base"` configuration is the default configuration for the ACP server +acp = FastACP.create( + acp_type="async", + config=AsyncACPConfig( + type="base", + ), +) + + +# This handler is called first whenever a new task is created. +# It's a good place to initialize any state or resources needed for the task. +@acp.on_task_event_send +async def handle_task_event_send(params: SendEventParams): + # For this tutorial, we log the parameters sent to the handler + # so you can see where and how messages within a long running task are handled + logger.info(f"Received task event send rpc: {params}") + + # 1. Echo back the client's message to show it in the UI. This is not done by default so the agent developer has full control over what is shown to the user. + await adk.messages.create(task_id=params.task.id, content=params.event.content) + + # 2. Send a simple response message. + # In future tutorials, this is where we'll add more sophisticated response logic. + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"Hello! I've received your message. I can't respond right now, but in future tutorials we'll see how you can get me to intelligently respond to your message.", + ), + ) + +@acp.on_task_cancel +async def handle_task_canceled(params: CancelTaskParams): + # For this tutorial, we print the parameters sent to the handler + # so you can see where and how task cancellation is handled + logger.info(f"Received task cancel rpc: {params}") + +@acp.on_task_create +async def handle_task_create(params: CreateTaskParams): + # For this tutorial, we log the parameters sent to the handler + # so you can see where and how task creation is handled + + # Here is where you can initialize any state or resources needed for the task. + logger.info(f"Received task create rpc: {params}") diff --git a/src/agentex/lib/cli/templates/default/pyproject.toml.j2 b/src/agentex/lib/cli/templates/default/pyproject.toml.j2 new file mode 100644 index 000000000..34e04e6a4 --- /dev/null +++ b/src/agentex/lib/cli/templates/default/pyproject.toml.j2 @@ -0,0 +1,32 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ project_name }}" +version = "0.1.0" +description = "{{ description }}" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/src/agentex/lib/cli/templates/default/requirements.txt.j2 b/src/agentex/lib/cli/templates/default/requirements.txt.j2 new file mode 100644 index 000000000..0b8ae19b3 --- /dev/null +++ b/src/agentex/lib/cli/templates/default/requirements.txt.j2 @@ -0,0 +1,5 @@ +# Install agentex-sdk from local path +agentex-sdk + +# Scale GenAI Platform Python SDK +scale-gp diff --git a/src/agentex/lib/cli/templates/default/test_agent.py.j2 b/src/agentex/lib/cli/templates/default/test_agent.py.j2 new file mode 100644 index 000000000..ee71f177c --- /dev/null +++ b/src/agentex/lib/cli/templates/default/test_agent.py.j2 @@ -0,0 +1,147 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: {{ agent_name }}) +""" + +import os +import uuid +import asyncio +import pytest +import pytest_asyncio +from agentex import AsyncAgentex +from agentex.types import TaskMessage +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam +from test_utils.async_utils import ( + poll_for_agent_response, + send_event_and_poll_yielding, + stream_agent_response, + validate_text_in_response, + poll_messages, +) + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "{{ agent_name }}") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, _agent_name: str, agent_id: str): + """Test sending an event and polling for the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # TODO: Poll for the initial task creation message (if your agent sends one) + # async for message in poll_messages( + # client=client, + # task_id=task.id, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected initial message + # assert "expected initial text" in message.content.content + # break + + # TODO: Send an event and poll for response using the yielding helper function + # user_message = "Your test message here" + # async for message in send_event_and_poll_yielding( + # client=client, + # agent_id=agent_id, + # task_id=task.id, + # user_message=user_message, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected response + # assert "expected response text" in message.content.content + # break + pass + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, _agent_name: str, agent_id: str): + """Test sending an event and streaming the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # user_message = "Your test message here" + + # # Collect events from stream + # all_events = [] + + # async def collect_stream_events(): + # async for event in stream_agent_response( + # client=client, + # task_id=task.id, + # timeout=30, + # ): + # all_events.append(event) + + # # Start streaming task + # stream_task = asyncio.create_task(collect_stream_events()) + + # # Send the event + # event_content = TextContentParam(type="text", author="user", content=user_message) + # await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + + # # Wait for streaming to complete + # await stream_task + + # # TODO: Add your validation here + # assert len(all_events) > 0, "No events received in streaming response" + pass + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/src/agentex/lib/cli/templates/sync-claude-code/.dockerignore.j2 b/src/agentex/lib/cli/templates/sync-claude-code/.dockerignore.j2 new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-claude-code/.dockerignore.j2 @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/src/agentex/lib/cli/templates/sync-claude-code/.env.example.j2 b/src/agentex/lib/cli/templates/sync-claude-code/.env.example.j2 new file mode 100644 index 000000000..5aff34a60 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-claude-code/.env.example.j2 @@ -0,0 +1,13 @@ +# {{ agent_name }} - Environment Variables +# Copy this file to .env and fill in the values + +# API key for the Claude Code CLI (the `claude` subprocess this agent spawns) +ANTHROPIC_API_KEY= + +# LLM base URL (optional - override to use a different provider) +# OPENAI_BASE_URL= + +# SGP Configuration (optional - for tracing) +# SGP_API_KEY= +# SGP_ACCOUNT_ID= +# SGP_CLIENT_BASE_URL= 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 new file mode 100644 index 000000000..93d0f82d1 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 @@ -0,0 +1,51 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +# Install the Claude Code CLI: the agent shells out to `claude` on every turn, +# so the binary must be present in the runtime image. +RUN npm install -g @anthropic-ai/claude-code + +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +ENV UV_HTTP_TIMEOUT=1000 + +WORKDIR /app/{{ project_path_from_build_root }} + +# Copy dependency files for layer caching +COPY {{ project_path_from_build_root }}/pyproject.toml ./ + +# Install dependencies (without project itself, for layer caching) +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-install-project --no-dev + +# Copy the project code +COPY {{ project_path_from_build_root }}/project ./project + +# Install the project +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.j2 new file mode 100644 index 000000000..6cdc70799 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.j2 @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install the Claude Code CLI: the agent shells out to `claude` on every turn, +# so the binary must be present in the runtime image. +RUN npm install -g @anthropic-ai/claude-code + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy just the requirements file to optimize caching +COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt + +WORKDIR /app/{{ project_path_from_build_root }} + +# Install the required Python packages +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 + + +# Set environment variables +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-claude-code/README.md.j2 b/src/agentex/lib/cli/templates/sync-claude-code/README.md.j2 new file mode 100644 index 000000000..7e38eddec --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-claude-code/README.md.j2 @@ -0,0 +1,64 @@ +# {{ agent_name }} - AgentEx Sync Claude Code Agent + +This template builds a **synchronous** agent that drives the **Claude Code CLI** +through the unified harness surface on AgentEx: +- Spawns `claude -p --output-format stream-json --verbose` as a local subprocess +- Wraps the CLI's stdout stream in a `ClaudeCodeTurn` +- Delivers canonical `StreamTaskMessage*` events via `UnifiedEmitter.yield_turn` + (the sync HTTP yield path) +- Tracing integration to SGP / AgentEx + +## Prerequisites + +- The `claude` CLI installed and on your `PATH` +- An `ANTHROPIC_API_KEY` (or equivalent credential) in your environment + +## Running the Agent + +```bash +agentex agents run --manifest manifest.yaml +``` + +## Project Structure + +``` +{{ project_name }}/ +├── project/ +│ ├── __init__.py +│ └── acp.py # ACP server, subprocess spawn, and message handler +├── Dockerfile +├── manifest.yaml +├── dev.ipynb +{% if use_uv %} +└── pyproject.toml +{% else %} +└── requirements.txt +{% endif %} +``` + +## Key Concepts + +### Sync ACP with the harness +The sync ACP model uses HTTP request/response. The `@acp.on_message_send` +handler spawns the Claude Code CLI and yields the harness events back to the +client as they arrive. + +### The unified harness surface +`ClaudeCodeTurn` + `UnifiedEmitter` are the unified harness surface. The turn +normalizes CLI output into canonical AgentEx events; the emitter traces and +delivers them. + +## Development + +### 1. Customize the subprocess +Edit `_spawn_claude` in `project/acp.py` to change the CLI flags, working +directory, or how the prompt is delivered. + +### 2. Configure Credentials +Set your credentials via `manifest.yaml`, an exported environment variable, or a +`.env` file in the project directory. + +### 3. Run Locally +```bash +export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml +``` diff --git a/src/agentex/lib/cli/templates/sync-claude-code/dev.ipynb.j2 b/src/agentex/lib/cli/templates/sync-claude-code/dev.ipynb.j2 new file mode 100644 index 000000000..b0691b1b1 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-claude-code/dev.ipynb.j2 @@ -0,0 +1,167 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"{{ agent_name }}\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [], + "source": [ + "# # (Optional) Create a new task. If you don't create a new task, each message will be sent to a new task. The server will create the task for you.\n", + "\n", + "# import uuid\n", + "\n", + "# TASK_ID = str(uuid.uuid4())[:8]\n", + "\n", + "# rpc_response = client.agents.rpc_by_name(\n", + "# agent_name=AGENT_NAME,\n", + "# method=\"task/create\",\n", + "# params={\n", + "# \"name\": f\"{TASK_ID}-task\",\n", + "# \"params\": {}\n", + "# }\n", + "# )\n", + "\n", + "# task = rpc_response.result\n", + "# print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b03b0d37", + "metadata": {}, + "outputs": [], + "source": [ + "# Test non streaming response\n", + "from agentex.types import TextContent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_message(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": False\n", + " }\n", + ")\n", + "\n", + "if not rpc_response or not rpc_response.result:\n", + " raise ValueError(\"No result in response\")\n", + "\n", + "# Extract and print just the text content from the response\n", + "for task_message in rpc_response.result:\n", + " content = task_message.content\n", + " if isinstance(content, TextContent):\n", + " text = content.content\n", + " print(text)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "79688331", + "metadata": {}, + "outputs": [], + "source": [ + "# Test streaming response\n", + "from agentex.types.task_message_update import StreamTaskMessageDelta, StreamTaskMessageFull\n", + "from agentex.types.text_delta import TextDelta\n", + "\n", + "\n", + "# The result object of message/send will be a TaskMessageUpdate which is a union of the following types:\n", + "# - StreamTaskMessageStart: \n", + "# - An indicator that a streaming message was started, doesn't contain any useful content\n", + "# - StreamTaskMessageDelta: \n", + "# - A delta of a streaming message, contains the text delta to aggregate\n", + "# - StreamTaskMessageDone: \n", + "# - An indicator that a streaming message was done, doesn't contain any useful content\n", + "# - StreamTaskMessageFull: \n", + "# - A non-streaming message, there is nothing to aggregate, since this contains the full message, not deltas\n", + "\n", + "# Whenn processing StreamTaskMessageDelta, if you are expecting more than TextDeltas, such as DataDelta, ToolRequestDelta, or ToolResponseDelta, you can process them as well\n", + "# Whenn processing StreamTaskMessageFull, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "for agent_rpc_response_chunk in client.agents.send_message_stream(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": True\n", + " }\n", + "):\n", + " # We know that the result of the message/send when stream is set to True will be a TaskMessageUpdate\n", + " task_message_update = agent_rpc_response_chunk.result\n", + " # Print oly the text deltas as they arrive or any full messages\n", + " if isinstance(task_message_update, StreamTaskMessageDelta):\n", + " delta = task_message_update.delta\n", + " if isinstance(delta, TextDelta):\n", + " print(delta.text_delta, end=\"\", flush=True)\n", + " else:\n", + " print(f\"Found non-text {type(task_message_update)} object in streaming message.\")\n", + " elif isinstance(task_message_update, StreamTaskMessageFull):\n", + " content = task_message_update.content\n", + " if isinstance(content, TextContent):\n", + " print(content.content)\n", + " else:\n", + " print(f\"Found non-text {type(task_message_update)} object in full message.\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c5e7e042", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/agentex/lib/cli/templates/sync-claude-code/environments.yaml.j2 b/src/agentex/lib/cli/templates/sync-claude-code/environments.yaml.j2 new file mode 100644 index 000000000..73924abdd --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-claude-code/environments.yaml.j2 @@ -0,0 +1,53 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-{{agent_name}}" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + diff --git a/src/agentex/lib/cli/templates/sync-claude-code/manifest.yaml.j2 b/src/agentex/lib/cli/templates/sync-claude-code/manifest.yaml.j2 new file mode 100644 index 000000000..4432d1a33 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-claude-code/manifest.yaml.j2 @@ -0,0 +1,120 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../ # Keep this as the default root + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - {{ project_path_from_build_root }} + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: {{ project_path_from_build_root }}/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: {{ project_path_from_build_root }}/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + +# Agent Configuration +# ----------------- +agent: + acp_type: sync + # Unique name for your agent + # Used for task routing and monitoring + name: {{ agent_name }} + + # Description of what your agent does + # Helps with documentation and discovery + description: {{ description | tojson }} + + # Temporal workflow configuration + # Set enabled: true to use Temporal workflows for long-running tasks + temporal: + enabled: false + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + credentials: + # The Claude Code CLI authenticates with ANTHROPIC_API_KEY (LITELLM_API_KEY + # is not read by the `claude` subprocess this agent spawns). + - env_var_name: ANTHROPIC_API_KEY + secret_name: anthropic-api-key + secret_key: api-key + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on. ANTHROPIC_API_KEY is supplied via the credential + # mapping above (deploy) or your local .env (load_dotenv). Do NOT set it to an + # empty string here — that would shadow the real key at runtime. + env: {} + # ANTHROPIC_API_KEY: "" # uncomment only to hardcode for local runs + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: [] # Update with your image pull secret names + # - name: my-registry-secret + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific environments (environments.yaml) + global: + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-claude-code/project/acp.py.j2 b/src/agentex/lib/cli/templates/sync-claude-code/project/acp.py.j2 new file mode 100644 index 000000000..33a89a51e --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-claude-code/project/acp.py.j2 @@ -0,0 +1,155 @@ +"""ACP handler for {{ agent_name }} — a sync Claude Code agent. + +Spawns ``claude -p --output-format stream-json --verbose`` as a LOCAL +asyncio subprocess (no Scale sandbox — that is a production concern). Stdout +lines are fed into ``ClaudeCodeTurn``, which wraps +``convert_claude_code_to_agentex_events``. Events are delivered via +``UnifiedEmitter.yield_turn``, the sync HTTP yield path. + +Live runs require the ``claude`` CLI to be installed and an +ANTHROPIC_API_KEY (or equivalent credential) to be in the environment. +""" + +from __future__ import annotations + +import os +import asyncio +from typing import AsyncIterator, AsyncGenerator +from collections import deque + +from dotenv import load_dotenv + +load_dotenv() + +import agentex.lib.adk as adk +from agentex.lib.adk import ClaudeCodeTurn +from agentex.lib.types.acp import SendMessageParams +from agentex.lib.core.harness import UnifiedEmitter +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.types.task_message_update import TaskMessageUpdate +from agentex.types.task_message_content import TaskMessageContent +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config + +logger = make_logger(__name__) + +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) +) + +acp = FastACP.create(acp_type="sync") + + +async def _spawn_claude(prompt: str) -> AsyncIterator[str]: + """Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines. + + This is a seam: tests can replace it with a fake async iterator of + pre-recorded lines so no real CLI invocation is needed offline. + """ + proc = await asyncio.create_subprocess_exec( + "claude", + "-p", + "--output-format", + "stream-json", + "--verbose", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + assert proc.stdout is not None + assert proc.stdin is not None + + proc.stdin.write(prompt.encode()) + await proc.stdin.drain() + proc.stdin.close() + + # Drain stderr concurrently. With --verbose, Claude Code can write enough to + # stderr to fill the OS pipe buffer; if we only read stdout, the CLI blocks + # on its stderr write while we block reading stdout — a deadlock. A + # background task keeps stderr flowing so stdout never stalls. We keep a + # bounded tail so a non-zero exit can be surfaced with context instead of + # silently completing the turn. + stderr_tail: deque[str] = deque(maxlen=20) + + async def _drain_stderr() -> None: + assert proc.stderr is not None + async for raw in proc.stderr: + text = raw.decode("utf-8", errors="replace").rstrip() + if text: + stderr_tail.append(text) + + stderr_task = asyncio.create_task(_drain_stderr()) + + try: + buffer = "" + async for chunk in proc.stdout: + buffer += chunk.decode("utf-8", errors="replace") + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if line: + yield line + + if buffer.strip(): + yield buffer.strip() + + await proc.wait() + if proc.returncode: + # The CLI failed (missing binary/auth, bad command). Raise so the + # turn surfaces as failed instead of completing with no output. + tail = "\n".join(stderr_tail) + raise RuntimeError( + f"claude CLI exited with status {proc.returncode}:\n{tail}" + ) + finally: + # Release the subprocess and stderr drain task even if the consumer + # abandons the generator early (task cancellation / client disconnect): + # cancel the drain task and terminate+reap the process if it is still + # running, so neither is leaked. + stderr_task.cancel() + try: + await stderr_task + except asyncio.CancelledError: + pass + if proc.returncode is None: + try: + proc.terminate() + except ProcessLookupError: + pass + await proc.wait() + + +@acp.on_message_send +async def handle_message_send( + params: SendMessageParams, +) -> TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]: + """Handle an incoming message: run Claude Code locally and stream events.""" + task_id = params.task.id + content = params.content + if not isinstance(content, TextContent): + logger.warning("Ignoring non-text message content (type=%s)", getattr(content, "type", "?")) + return + prompt = content.content + logger.info("Processing message for task %s", task_id) + + async with adk.tracing.span( + trace_id=task_id, + task_id=task_id, + name="message", + input={"message": prompt}, + data={"__span_type__": "AGENT_WORKFLOW"}, + ) as turn_span: + emitter = UnifiedEmitter( + task_id=task_id, + trace_id=task_id, + parent_span_id=turn_span.id if turn_span else None, + ) + turn = ClaudeCodeTurn(_spawn_claude(prompt)) + async for event in emitter.yield_turn(turn): + yield event diff --git a/src/agentex/lib/cli/templates/sync-claude-code/pyproject.toml.j2 b/src/agentex/lib/cli/templates/sync-claude-code/pyproject.toml.j2 new file mode 100644 index 000000000..e499b1dc1 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-claude-code/pyproject.toml.j2 @@ -0,0 +1,33 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ project_name }}" +version = "0.1.0" +description = "{{ description }}" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "python-dotenv>=1.0,<2", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/src/agentex/lib/cli/templates/sync-claude-code/requirements.txt.j2 b/src/agentex/lib/cli/templates/sync-claude-code/requirements.txt.j2 new file mode 100644 index 000000000..8c0630384 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-claude-code/requirements.txt.j2 @@ -0,0 +1,8 @@ +# Install agentex-sdk from local path +agentex-sdk + +# Scale GenAI Platform Python SDK +scale-gp + +# Loads .env files for local development +python-dotenv>=1.0,<2 diff --git a/src/agentex/lib/cli/templates/sync-codex/.dockerignore.j2 b/src/agentex/lib/cli/templates/sync-codex/.dockerignore.j2 new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-codex/.dockerignore.j2 @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/src/agentex/lib/cli/templates/sync-codex/.env.example.j2 b/src/agentex/lib/cli/templates/sync-codex/.env.example.j2 new file mode 100644 index 000000000..5d621a83e --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-codex/.env.example.j2 @@ -0,0 +1,13 @@ +# {{ agent_name }} - Environment Variables +# Copy this file to .env and fill in the values + +# API key used by the codex CLI (`codex exec` reads OPENAI_API_KEY directly) +OPENAI_API_KEY= + +# LLM base URL (optional - override to use a different provider) +# OPENAI_BASE_URL= + +# SGP Configuration (optional - for tracing) +# SGP_API_KEY= +# SGP_ACCOUNT_ID= +# SGP_CLIENT_BASE_URL= diff --git a/src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 new file mode 100644 index 000000000..02860b9b9 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 @@ -0,0 +1,51 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +# Install the codex CLI: the agent shells out to `codex` on every turn, so the +# binary must be present in the runtime image. +RUN npm install -g @openai/codex + +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +ENV UV_HTTP_TIMEOUT=1000 + +WORKDIR /app/{{ project_path_from_build_root }} + +# Copy dependency files for layer caching +COPY {{ project_path_from_build_root }}/pyproject.toml ./ + +# Install dependencies (without project itself, for layer caching) +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-install-project --no-dev + +# Copy the project code +COPY {{ project_path_from_build_root }}/project ./project + +# Install the project +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-codex/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-codex/Dockerfile.j2 new file mode 100644 index 000000000..afa4470d9 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-codex/Dockerfile.j2 @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install the codex CLI: the agent shells out to `codex` on every turn, so the +# binary must be present in the runtime image. +RUN npm install -g @openai/codex + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy just the requirements file to optimize caching +COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt + +WORKDIR /app/{{ project_path_from_build_root }} + +# Install the required Python packages +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 + + +# Set environment variables +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-codex/README.md.j2 b/src/agentex/lib/cli/templates/sync-codex/README.md.j2 new file mode 100644 index 000000000..4ca1aeccf --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-codex/README.md.j2 @@ -0,0 +1,67 @@ +# {{ agent_name }} - AgentEx Sync Codex Agent + +This template builds a **synchronous** agent that drives the **Codex CLI** +through the unified harness surface on AgentEx: +- Spawns `codex exec --json` as a local subprocess +- Wraps the CLI's stdout stream in a `CodexTurn` +- Delivers canonical `StreamTaskMessage*` events via `UnifiedEmitter.yield_turn` + (the sync HTTP yield path) +- Tracing integration to SGP / AgentEx + +## Prerequisites + +- The `codex` CLI installed and on your `PATH` (`npm install -g @openai/codex`) +- An `OPENAI_API_KEY` in your environment + +## Running the Agent + +```bash +agentex agents run --manifest manifest.yaml +``` + +## Project Structure + +``` +{{ project_name }}/ +├── project/ +│ ├── __init__.py +│ └── acp.py # ACP server, subprocess spawn, and message handler +├── Dockerfile +├── manifest.yaml +├── dev.ipynb +{% if use_uv %} +└── pyproject.toml +{% else %} +└── requirements.txt +{% endif %} +``` + +## Key Concepts + +### Sync ACP with the harness +The sync ACP model uses HTTP request/response. The `@acp.on_message_send` +handler spawns the Codex CLI and yields the harness events back to the client +as they arrive. + +### The unified harness surface +`CodexTurn` + `UnifiedEmitter` are the unified harness surface. The turn +normalizes CLI output into canonical AgentEx events; the emitter traces and +delivers them. + +## Development + +### 1. Choose a model +Set `CODEX_MODEL` (defaults to `o4-mini`) to control which model codex uses. + +### 2. Customize the subprocess +Edit `_spawn_codex` in `project/acp.py` to change the CLI flags or how the +prompt is delivered. + +### 3. Configure Credentials +Set your credentials via `manifest.yaml`, an exported environment variable, or a +`.env` file in the project directory. + +### 4. Run Locally +```bash +export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml +``` diff --git a/src/agentex/lib/cli/templates/sync-codex/dev.ipynb.j2 b/src/agentex/lib/cli/templates/sync-codex/dev.ipynb.j2 new file mode 100644 index 000000000..b0691b1b1 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-codex/dev.ipynb.j2 @@ -0,0 +1,167 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"{{ agent_name }}\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [], + "source": [ + "# # (Optional) Create a new task. If you don't create a new task, each message will be sent to a new task. The server will create the task for you.\n", + "\n", + "# import uuid\n", + "\n", + "# TASK_ID = str(uuid.uuid4())[:8]\n", + "\n", + "# rpc_response = client.agents.rpc_by_name(\n", + "# agent_name=AGENT_NAME,\n", + "# method=\"task/create\",\n", + "# params={\n", + "# \"name\": f\"{TASK_ID}-task\",\n", + "# \"params\": {}\n", + "# }\n", + "# )\n", + "\n", + "# task = rpc_response.result\n", + "# print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b03b0d37", + "metadata": {}, + "outputs": [], + "source": [ + "# Test non streaming response\n", + "from agentex.types import TextContent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_message(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": False\n", + " }\n", + ")\n", + "\n", + "if not rpc_response or not rpc_response.result:\n", + " raise ValueError(\"No result in response\")\n", + "\n", + "# Extract and print just the text content from the response\n", + "for task_message in rpc_response.result:\n", + " content = task_message.content\n", + " if isinstance(content, TextContent):\n", + " text = content.content\n", + " print(text)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "79688331", + "metadata": {}, + "outputs": [], + "source": [ + "# Test streaming response\n", + "from agentex.types.task_message_update import StreamTaskMessageDelta, StreamTaskMessageFull\n", + "from agentex.types.text_delta import TextDelta\n", + "\n", + "\n", + "# The result object of message/send will be a TaskMessageUpdate which is a union of the following types:\n", + "# - StreamTaskMessageStart: \n", + "# - An indicator that a streaming message was started, doesn't contain any useful content\n", + "# - StreamTaskMessageDelta: \n", + "# - A delta of a streaming message, contains the text delta to aggregate\n", + "# - StreamTaskMessageDone: \n", + "# - An indicator that a streaming message was done, doesn't contain any useful content\n", + "# - StreamTaskMessageFull: \n", + "# - A non-streaming message, there is nothing to aggregate, since this contains the full message, not deltas\n", + "\n", + "# Whenn processing StreamTaskMessageDelta, if you are expecting more than TextDeltas, such as DataDelta, ToolRequestDelta, or ToolResponseDelta, you can process them as well\n", + "# Whenn processing StreamTaskMessageFull, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "for agent_rpc_response_chunk in client.agents.send_message_stream(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": True\n", + " }\n", + "):\n", + " # We know that the result of the message/send when stream is set to True will be a TaskMessageUpdate\n", + " task_message_update = agent_rpc_response_chunk.result\n", + " # Print oly the text deltas as they arrive or any full messages\n", + " if isinstance(task_message_update, StreamTaskMessageDelta):\n", + " delta = task_message_update.delta\n", + " if isinstance(delta, TextDelta):\n", + " print(delta.text_delta, end=\"\", flush=True)\n", + " else:\n", + " print(f\"Found non-text {type(task_message_update)} object in streaming message.\")\n", + " elif isinstance(task_message_update, StreamTaskMessageFull):\n", + " content = task_message_update.content\n", + " if isinstance(content, TextContent):\n", + " print(content.content)\n", + " else:\n", + " print(f\"Found non-text {type(task_message_update)} object in full message.\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c5e7e042", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/agentex/lib/cli/templates/sync-codex/environments.yaml.j2 b/src/agentex/lib/cli/templates/sync-codex/environments.yaml.j2 new file mode 100644 index 000000000..73924abdd --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-codex/environments.yaml.j2 @@ -0,0 +1,53 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-{{agent_name}}" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + diff --git a/src/agentex/lib/cli/templates/sync-codex/manifest.yaml.j2 b/src/agentex/lib/cli/templates/sync-codex/manifest.yaml.j2 new file mode 100644 index 000000000..4e3cc0c3a --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-codex/manifest.yaml.j2 @@ -0,0 +1,120 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../ # Keep this as the default root + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - {{ project_path_from_build_root }} + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: {{ project_path_from_build_root }}/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: {{ project_path_from_build_root }}/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + +# Agent Configuration +# ----------------- +agent: + acp_type: sync + # Unique name for your agent + # Used for task routing and monitoring + name: {{ agent_name }} + + # Description of what your agent does + # Helps with documentation and discovery + description: {{ description | tojson }} + + # Temporal workflow configuration + # Set enabled: true to use Temporal workflows for long-running tasks + temporal: + enabled: false + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + credentials: + # The codex CLI (`codex exec`) reads OPENAI_API_KEY directly; it does not + # use a LiteLLM key. + - env_var_name: OPENAI_API_KEY + secret_name: openai-api-key + secret_key: api-key + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on. OPENAI_API_KEY is supplied via the credential + # mapping above (deploy) or your local .env. Do NOT set it to an empty string + # here — that would shadow the real key at runtime. + env: {} + # OPENAI_BASE_URL: "" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: [] # Update with your image pull secret names + # - name: my-registry-secret + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific environments (environments.yaml) + global: + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-codex/project/acp.py.j2 b/src/agentex/lib/cli/templates/sync-codex/project/acp.py.j2 new file mode 100644 index 000000000..0bc5d66a7 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-codex/project/acp.py.j2 @@ -0,0 +1,185 @@ +"""Sync ACP handler for {{ agent_name }} — a Codex CLI harness agent. + +Demonstrates the ``convert_codex_to_agentex_events`` tap + ``CodexTurn`` + +``UnifiedEmitter`` for a sync (HTTP-yield) ACP agent. + +The handler: +1. Spawns ``codex exec --json`` as a LOCAL asyncio subprocess (no sandbox). + This is correct for local development; production isolation is a separate + concern. +2. Wraps the stdout line stream in a ``CodexTurn``. +3. Delivers every canonical ``StreamTaskMessage*`` event via + ``UnifiedEmitter.yield_turn``, which traces + yields each event back to + the HTTP caller in one pass. + +Live runs require: +- ``codex`` CLI on PATH (``npm install -g @openai/codex``) +- ``OPENAI_API_KEY`` set in the environment +""" + +from __future__ import annotations + +import os +import time +import codecs +import asyncio +from typing import AsyncGenerator +from collections.abc import AsyncIterator + +from dotenv import load_dotenv + +load_dotenv() + +import agentex.lib.adk as adk +from agentex.lib.adk import CodexTurn +from agentex.lib.types.acp import SendMessageParams +from agentex.lib.core.harness import UnifiedEmitter +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.types.task_message_update import TaskMessageUpdate +from agentex.types.task_message_content import TaskMessageContent +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config + +logger = make_logger(__name__) + +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) +) + +acp = FastACP.create(acp_type="sync") + +MODEL = os.environ.get("CODEX_MODEL", "o4-mini") + + +async def _spawn_codex(model: str) -> asyncio.subprocess.Process: + """Spawn ``codex exec --json`` locally and return the live process. + + Injection seam: tests replace this function with a fake that returns a + mock process whose stdout yields pre-recorded event lines. + + The flags: + --json machine-readable newline-delimited events + --skip-git-repo-check safe to run outside a git repo + --dangerously-bypass-approvals-and-sandbox + skip interactive approval prompts in a + non-interactive (server) context + --model which OpenAI model to use + + The caller writes the prompt to stdin after the process starts, then + closes stdin so codex knows input is complete. + """ + cmd = [ + "codex", + "exec", + "--json", + "--skip-git-repo-check", + "--dangerously-bypass-approvals-and-sandbox", + "--model", + model, + "-", # read prompt from stdin + ] + return await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + # Discard stderr: codex --json writes events to stdout; its stderr is + # progress/debug noise. Capturing it with PIPE but never reading it + # would deadlock once codex fills the OS pipe buffer (~64 KB). + stderr=asyncio.subprocess.DEVNULL, + env={**os.environ}, + ) + + +async def _process_stdout(process: asyncio.subprocess.Process) -> AsyncIterator[str]: + """Yield newline-delimited JSON lines from the process stdout. + + Uses an incremental UTF-8 decoder so a multibyte character split across two + 4 KB reads is decoded correctly instead of being corrupted at the boundary. + """ + assert process.stdout is not None + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + buffer = "" + while True: + chunk = await process.stdout.read(4096) + if not chunk: + break + buffer += decoder.decode(chunk) + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if line: + yield line + buffer += decoder.decode(b"", final=True) + if buffer.strip(): + yield buffer.strip() + + +@acp.on_message_send +async def handle_message_send( + params: SendMessageParams, +) -> TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]: + """Handle each message by running ``codex exec`` locally and streaming events.""" + task_id = params.task.id + content = params.content + if not isinstance(content, TextContent): + logger.warning("Ignoring non-text message content (type=%s)", getattr(content, "type", "?")) + return + user_message = content.content + logger.info("Processing message for task %s", task_id) + + start_ms = int(time.monotonic() * 1000) + + async with adk.tracing.span( + trace_id=task_id, + task_id=task_id, + name="message", + input={"message": user_message}, + data={"__span_type__": "AGENT_WORKFLOW"}, + ) as turn_span: + process = await _spawn_codex(MODEL) + + # Write prompt to stdin then close it so codex knows input is done. + assert process.stdin is not None + process.stdin.write(user_message.encode("utf-8")) + await process.stdin.drain() + process.stdin.close() + + turn = CodexTurn( + events=_process_stdout(process), + model=MODEL, + ) + + emitter = UnifiedEmitter( + task_id=task_id, + trace_id=task_id, + parent_span_id=turn_span.id if turn_span else None, + ) + + # Guarantee the subprocess is reaped even if the generator is abandoned + # (client disconnect / GC) or yield_turn raises; otherwise codex stays + # blocked writing to a full stdout pipe buffer and the process leaks. + try: + async for event in emitter.yield_turn(turn): + yield event + finally: + if process.returncode is None: + process.kill() + await process.wait() + + # Record the real wall-clock duration AFTER streaming completes; setting + # it before the stream ran would capture only subprocess spawn overhead. + turn.duration_ms = int(time.monotonic() * 1000) - start_ms + + if turn_span: + usage = turn.usage() + turn_span.output = { + "model": usage.model, + "input_tokens": usage.input_tokens, + "output_tokens": usage.output_tokens, + } diff --git a/src/agentex/lib/cli/templates/sync-codex/pyproject.toml.j2 b/src/agentex/lib/cli/templates/sync-codex/pyproject.toml.j2 new file mode 100644 index 000000000..e499b1dc1 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-codex/pyproject.toml.j2 @@ -0,0 +1,33 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ project_name }}" +version = "0.1.0" +description = "{{ description }}" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "python-dotenv>=1.0,<2", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/src/agentex/lib/cli/templates/sync-codex/requirements.txt.j2 b/src/agentex/lib/cli/templates/sync-codex/requirements.txt.j2 new file mode 100644 index 000000000..8c0630384 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-codex/requirements.txt.j2 @@ -0,0 +1,8 @@ +# Install agentex-sdk from local path +agentex-sdk + +# Scale GenAI Platform Python SDK +scale-gp + +# Loads .env files for local development +python-dotenv>=1.0,<2 diff --git a/src/agentex/lib/cli/templates/sync-langgraph/.dockerignore.j2 b/src/agentex/lib/cli/templates/sync-langgraph/.dockerignore.j2 new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-langgraph/.dockerignore.j2 @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/src/agentex/lib/cli/templates/sync-langgraph/.env.example.j2 b/src/agentex/lib/cli/templates/sync-langgraph/.env.example.j2 new file mode 100644 index 000000000..015f49ef7 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-langgraph/.env.example.j2 @@ -0,0 +1,13 @@ +# {{ agent_name }} - Environment Variables +# Copy this file to .env and fill in the values + +# API key for your LLM provider +LITELLM_API_KEY= + +# LLM base URL (optional - override to use a different provider) +# OPENAI_BASE_URL= + +# SGP Configuration (optional - for tracing) +# SGP_API_KEY= +# SGP_ACCOUNT_ID= +# SGP_CLIENT_BASE_URL= diff --git a/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile-uv.j2 new file mode 100644 index 000000000..dd3035f7b --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile-uv.j2 @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +ENV UV_HTTP_TIMEOUT=1000 + +WORKDIR /app/{{ project_path_from_build_root }} + +# Copy dependency files for layer caching +COPY {{ project_path_from_build_root }}/pyproject.toml ./ + +# Install dependencies (without project itself, for layer caching) +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-install-project --no-dev + +# Copy the project code +COPY {{ project_path_from_build_root }}/project ./project + +# Install the project +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile.j2 new file mode 100644 index 000000000..4d9f41d45 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile.j2 @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + node \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy just the requirements file to optimize caching +COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt + +WORKDIR /app/{{ project_path_from_build_root }} + +# Install the required Python packages +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 + + +# Set environment variables +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-langgraph/README.md.j2 b/src/agentex/lib/cli/templates/sync-langgraph/README.md.j2 new file mode 100644 index 000000000..d0620a302 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-langgraph/README.md.j2 @@ -0,0 +1,83 @@ +# {{ agent_name }} - AgentEx Sync LangGraph Agent + +This template builds a **synchronous** LangGraph agent on AgentEx with: +- Tool calling (ReAct pattern) +- Streaming token output +- Multi-turn conversation memory via AgentEx checkpointer +- Tracing integration + +## Graph Structure + +``` +START --> agent --> [has tool calls?] --> tools --> agent + --> [no tool calls?] --> END +``` + +## Running the Agent + +```bash +agentex agents run --manifest manifest.yaml +``` + +## Project Structure + +``` +{{ project_name }}/ +├── project/ +│ ├── __init__.py +│ ├── acp.py # ACP server and message handler +│ ├── graph.py # LangGraph state graph definition +│ └── tools.py # Tool definitions +├── Dockerfile +├── manifest.yaml +├── dev.ipynb +{% if use_uv %} +└── pyproject.toml +{% else %} +└── requirements.txt +{% endif %} +``` + +## Key Concepts + +### Sync ACP with LangGraph +The sync ACP model uses HTTP request/response. The `@acp.on_message_send` handler receives a message and yields streaming events from the LangGraph graph back to the client. + +### LangGraph Integration +- **StateGraph**: Defines the agent's state machine with `AgentState` (message history) +- **ToolNode**: Automatically executes tool calls from the LLM +- **tools_condition**: Routes between tool execution and final response +- **Checkpointer**: Uses AgentEx's HTTP checkpointer for cross-request memory + +### Streaming +Tokens are streamed as they're generated using `convert_langgraph_to_agentex_events()`, which converts LangGraph's stream events into AgentEx `TaskMessageUpdate` events. + +## Development + +### 1. Add Your Own Tools +Edit `project/tools.py` to define custom tools: + +```python +from langchain_core.tools import Tool + +def my_tool(query: str) -> str: + """Your tool description.""" + return "result" + +my_tool = Tool(name="my_tool", func=my_tool, description="...") +TOOLS = [my_tool] +``` + +### 2. Customize the Graph +Edit `project/graph.py` to modify the model, system prompt, or graph structure. + +### 3. Configure Credentials +Set your LLM API key: +1. In `manifest.yaml` under `env.LITELLM_API_KEY` +2. Or export: `export LITELLM_API_KEY=...` +3. Or create a `.env` file in the project directory + +### 4. Run Locally +```bash +export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml +``` diff --git a/src/agentex/lib/cli/templates/sync-langgraph/dev.ipynb.j2 b/src/agentex/lib/cli/templates/sync-langgraph/dev.ipynb.j2 new file mode 100644 index 000000000..d8c10a65a --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-langgraph/dev.ipynb.j2 @@ -0,0 +1,167 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"{{ agent_name }}\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [], + "source": [ + "# # (Optional) Create a new task. If you don't create a new task, each message will be sent to a new task. The server will create the task for you.\n", + "\n", + "# import uuid\n", + "\n", + "# TASK_ID = str(uuid.uuid4())[:8]\n", + "\n", + "# rpc_response = client.agents.rpc_by_name(\n", + "# agent_name=AGENT_NAME,\n", + "# method=\"task/create\",\n", + "# params={\n", + "# \"name\": f\"{TASK_ID}-task\",\n", + "# \"params\": {}\n", + "# }\n", + "# )\n", + "\n", + "# task = rpc_response.result\n", + "# print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b03b0d37", + "metadata": {}, + "outputs": [], + "source": [ + "# Test non streaming response\n", + "from agentex.types import TextContent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_message(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": False\n", + " }\n", + ")\n", + "\n", + "if not rpc_response or not rpc_response.result:\n", + " raise ValueError(\"No result in response\")\n", + "\n", + "# Extract and print just the text content from the response\n", + "for task_message in rpc_response.result:\n", + " content = task_message.content\n", + " if isinstance(content, TextContent):\n", + " text = content.content\n", + " print(text)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "79688331", + "metadata": {}, + "outputs": [], + "source": [ + "# Test streaming response\n", + "from agentex.types.task_message_update import StreamTaskMessageDelta, StreamTaskMessageFull\n", + "from agentex.types.text_delta import TextDelta\n", + "\n", + "\n", + "# The result object of message/send will be a TaskMessageUpdate which is a union of the following types:\n", + "# - StreamTaskMessageStart: \n", + "# - An indicator that a streaming message was started, doesn't contain any useful content\n", + "# - StreamTaskMessageDelta: \n", + "# - A delta of a streaming message, contains the text delta to aggregate\n", + "# - StreamTaskMessageDone: \n", + "# - An indicator that a streaming message was done, doesn't contain any useful content\n", + "# - StreamTaskMessageFull: \n", + "# - A non-streaming message, there is nothing to aggregate, since this contains the full message, not deltas\n", + "\n", + "# Whenn processing StreamTaskMessageDelta, if you are expecting more than TextDeltas, such as DataDelta, ToolRequestDelta, or ToolResponseDelta, you can process them as well\n", + "# Whenn processing StreamTaskMessageFull, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "for agent_rpc_response_chunk in client.agents.send_message_stream(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": True\n", + " }\n", + "):\n", + " # We know that the result of the message/send when stream is set to True will be a TaskMessageUpdate\n", + " task_message_update = agent_rpc_response_chunk.result\n", + " # Print oly the text deltas as they arrive or any full messages\n", + " if isinstance(task_message_update, StreamTaskMessageDelta):\n", + " delta = task_message_update.delta\n", + " if isinstance(delta, TextDelta):\n", + " print(delta.text_delta, end=\"\", flush=True)\n", + " else:\n", + " print(f\"Found non-text {type(task_message)} object in streaming message.\")\n", + " elif isinstance(task_message_update, StreamTaskMessageFull):\n", + " content = task_message_update.content\n", + " if isinstance(content, TextContent):\n", + " print(content.content)\n", + " else:\n", + " print(f\"Found non-text {type(task_message)} object in full message.\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c5e7e042", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/agentex/lib/cli/templates/sync-langgraph/environments.yaml.j2 b/src/agentex/lib/cli/templates/sync-langgraph/environments.yaml.j2 new file mode 100644 index 000000000..73924abdd --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-langgraph/environments.yaml.j2 @@ -0,0 +1,53 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-{{agent_name}}" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + diff --git a/src/agentex/lib/cli/templates/sync-langgraph/manifest.yaml.j2 b/src/agentex/lib/cli/templates/sync-langgraph/manifest.yaml.j2 new file mode 100644 index 000000000..33f2d7b67 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-langgraph/manifest.yaml.j2 @@ -0,0 +1,117 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../ # Keep this as the default root + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - {{ project_path_from_build_root }} + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: {{ project_path_from_build_root }}/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: {{ project_path_from_build_root }}/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + +# Agent Configuration +# ----------------- +agent: + acp_type: sync + # Unique name for your agent + # Used for task routing and monitoring + name: {{ agent_name }} + + # Description of what your agent does + # Helps with documentation and discovery + description: {{ description | tojson }} + + # Temporal workflow configuration + # Set enabled: true to use Temporal workflows for long-running tasks + temporal: + enabled: false + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + credentials: + - env_var_name: LITELLM_API_KEY + secret_name: litellm-api-key + secret_key: api-key + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + env: + LITELLM_API_KEY: "" # Set your LLM API key + # OPENAI_BASE_URL: "" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: [] # Update with your image pull secret names + # - name: my-registry-secret + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific environments (environments.yaml) + global: + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-langgraph/project/acp.py.j2 b/src/agentex/lib/cli/templates/sync-langgraph/project/acp.py.j2 new file mode 100644 index 000000000..32d261093 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-langgraph/project/acp.py.j2 @@ -0,0 +1,103 @@ +""" +ACP (Agent Communication Protocol) handler for Agentex. + +This is the API layer — it manages the graph lifecycle and streams +tokens and tool calls from the LangGraph graph to the Agentex frontend. +""" + +from typing import AsyncGenerator + +import agentex.lib.adk as adk +from agentex.lib.core.harness import UnifiedEmitter +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.protocol.acp import SendMessageParams +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.adk import LangGraphTurn +from agentex.types.task_message_content import TaskMessageContent +from agentex.types.task_message_delta import TextDelta +from agentex.types.task_message_update import TaskMessageUpdate +from dotenv import load_dotenv + +load_dotenv() +import os + +# LiteLLM proxy auth: copy LITELLM_API_KEY to OPENAI_API_KEY for OpenAI client compatibility +_litellm_key = os.environ.get("LITELLM_API_KEY") +if _litellm_key: + os.environ["OPENAI_API_KEY"] = _litellm_key + +from project.graph import create_graph + +logger = make_logger(__name__) + +# Register the Agentex tracing processor so spans are shipped to the backend +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + )) + +# Create ACP server +acp = FastACP.create(acp_type="sync") + +# Compiled graph (lazy-initialized on first request) +_graph = None + + +async def get_graph(): + """Get or create the compiled graph instance.""" + global _graph + if _graph is None: + _graph = await create_graph() + return _graph + + +@acp.on_message_send +async def handle_message_send( + params: SendMessageParams, +) -> TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]: + """Handle incoming messages from Agentex, streaming tokens and tool calls.""" + graph = await get_graph() + + thread_id = params.task.id + content = params.content + if not isinstance(content, TextContent): + logger.warning("Ignoring non-text message content (type=%s)", getattr(content, "type", "?")) + return + user_message = content.content + + logger.info(f"Processing message for thread {thread_id}") + + async with adk.tracing.span( + trace_id=thread_id, + name="message", + input={"message": user_message}, + data={"__span_type__": "AGENT_WORKFLOW"}, + ) as turn_span: + stream = graph.astream( + {"messages": [{"role": "user", "content": user_message}]}, + config={"configurable": {"thread_id": thread_id}}, + stream_mode=["messages", "updates"], + ) + + turn = LangGraphTurn(stream, model=None) + emitter = UnifiedEmitter( + task_id=thread_id, + trace_id=thread_id, + parent_span_id=turn_span.id if turn_span else None, + ) + + final_text = "" + async for event in emitter.yield_turn(turn): + # Accumulate text deltas for span output + delta = getattr(event, "delta", None) + if isinstance(delta, TextDelta) and delta.text_delta: + final_text += delta.text_delta + yield event + + if turn_span: + turn_span.output = {"final_output": final_text} diff --git a/src/agentex/lib/cli/templates/sync-langgraph/project/graph.py.j2 b/src/agentex/lib/cli/templates/sync-langgraph/project/graph.py.j2 new file mode 100644 index 000000000..8b1f6297f --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-langgraph/project/graph.py.j2 @@ -0,0 +1,68 @@ +""" +LangGraph graph definition. + +Defines the state, nodes, edges, and compiles the graph. +The compiled graph is the boundary between this module and the API layer. +""" + +from datetime import datetime +from typing import Annotated, Any + +from agentex.lib.adk import create_checkpointer +from langchain_core.messages import SystemMessage +from langchain_openai import ChatOpenAI +from langgraph.graph import START, StateGraph +from langgraph.graph.message import add_messages +from langgraph.prebuilt import ToolNode, tools_condition +from typing_extensions import TypedDict + +from project.tools import TOOLS + +MODEL_NAME = "gpt-4o" +SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. + +Current date and time: {timestamp} + +Guidelines: +- Be concise and helpful +- Use tools when they would help answer the user's question +- If you're unsure, ask clarifying questions +- Always provide accurate information +""" + + +class AgentState(TypedDict): + """State schema for the agent graph.""" + messages: Annotated[list[Any], add_messages] + + +async def create_graph(): + """Create and compile the agent graph with checkpointer. + + Returns: + A compiled LangGraph StateGraph ready for invocation. + """ + llm = ChatOpenAI(model=MODEL_NAME) + llm_with_tools = llm.bind_tools(TOOLS) + + checkpointer = await create_checkpointer() + + def agent_node(state: AgentState) -> dict[str, Any]: + """Process the current state and generate a response.""" + messages = state["messages"] + if not messages or not isinstance(messages[0], SystemMessage): + system_content = SYSTEM_PROMPT.format( + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ) + messages = [SystemMessage(content=system_content)] + messages + response = llm_with_tools.invoke(messages) + return {"messages": [response]} + + builder = StateGraph(AgentState) + builder.add_node("agent", agent_node) + builder.add_node("tools", ToolNode(tools=TOOLS)) + builder.add_edge(START, "agent") + builder.add_conditional_edges("agent", tools_condition, "tools") + builder.add_edge("tools", "agent") + + return builder.compile(checkpointer=checkpointer) diff --git a/src/agentex/lib/cli/templates/sync-langgraph/project/tools.py.j2 b/src/agentex/lib/cli/templates/sync-langgraph/project/tools.py.j2 new file mode 100644 index 000000000..1b402a906 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-langgraph/project/tools.py.j2 @@ -0,0 +1,32 @@ +""" +Tool definitions for the LangGraph agent. + +Add your custom tools here. Each tool should be a function decorated with @tool +or created using the Tool class. +""" + +from langchain_core.tools import Tool + + +def get_weather(city: str) -> str: + """Get the current weather for a city. + + Args: + city: The name of the city to get weather for. + + Returns: + A string describing the weather conditions. + """ + # TODO: Replace with actual weather API call + return f"The weather in {city} is sunny and 72°F" + + +# Define tools +weather_tool = Tool( + name="get_weather", + func=get_weather, + description="Get the current weather for a city. Input should be a city name.", +) + +# Export all tools as a list +TOOLS = [weather_tool] diff --git a/src/agentex/lib/cli/templates/sync-langgraph/pyproject.toml.j2 b/src/agentex/lib/cli/templates/sync-langgraph/pyproject.toml.j2 new file mode 100644 index 000000000..3c752f025 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-langgraph/pyproject.toml.j2 @@ -0,0 +1,35 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ project_name }}" +version = "0.1.0" +description = "{{ description }}" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "langgraph", + "langchain-openai", + "python-dotenv", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/src/agentex/lib/cli/templates/sync-langgraph/requirements.txt.j2 b/src/agentex/lib/cli/templates/sync-langgraph/requirements.txt.j2 new file mode 100644 index 000000000..4a148e901 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-langgraph/requirements.txt.j2 @@ -0,0 +1,10 @@ +# Install agentex-sdk from local path +agentex-sdk + +# Scale GenAI Platform Python SDK +scale-gp + +# LangGraph and LangChain +langgraph +langchain-openai +python-dotenv diff --git a/src/agentex/lib/cli/templates/sync-langgraph/test_agent.py.j2 b/src/agentex/lib/cli/templates/sync-langgraph/test_agent.py.j2 new file mode 100644 index 000000000..7de4684f4 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-langgraph/test_agent.py.j2 @@ -0,0 +1,70 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming message sending +- Streaming message sending +- Task creation via RPC + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: {{ agent_name }}) +""" + +import os +import pytest +from agentex import Agentex + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "{{ agent_name }}") + + +@pytest.fixture +def client(): + """Create an AgentEx client instance for testing.""" + return Agentex(base_url=AGENTEX_API_BASE_URL) + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest.fixture +def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingMessages: + """Test non-streaming message sending.""" + + def test_send_message(self, client: Agentex, _agent_name: str): + """Test sending a message and receiving a response.""" + # TODO: Fill in the test based on what data your agent is expected to handle + ... + + +class TestStreamingMessages: + """Test streaming message sending.""" + + def test_send_stream_message(self, client: Agentex, _agent_name: str): + """Test streaming a message and aggregating deltas.""" + # TODO: Fill in the test based on what data your agent is expected to handle + ... + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/.dockerignore.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/.dockerignore.j2 new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/.dockerignore.j2 @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/.env.example.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/.env.example.j2 new file mode 100644 index 000000000..015f49ef7 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/.env.example.j2 @@ -0,0 +1,13 @@ +# {{ agent_name }} - Environment Variables +# Copy this file to .env and fill in the values + +# API key for your LLM provider +LITELLM_API_KEY= + +# LLM base URL (optional - override to use a different provider) +# OPENAI_BASE_URL= + +# SGP Configuration (optional - for tracing) +# SGP_API_KEY= +# SGP_ACCOUNT_ID= +# SGP_CLIENT_BASE_URL= 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 new file mode 100644 index 000000000..dd3035f7b --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile-uv.j2 @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +ENV UV_HTTP_TIMEOUT=1000 + +WORKDIR /app/{{ project_path_from_build_root }} + +# Copy dependency files for layer caching +COPY {{ project_path_from_build_root }}/pyproject.toml ./ + +# Install dependencies (without project itself, for layer caching) +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-install-project --no-dev + +# Copy the project code +COPY {{ project_path_from_build_root }}/project ./project + +# Install the project +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file 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 new file mode 100644 index 000000000..4d9f41d45 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile.j2 @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + node \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy just the requirements file to optimize caching +COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt + +WORKDIR /app/{{ project_path_from_build_root }} + +# Install the required Python packages +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 + + +# Set environment variables +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/README.md.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/README.md.j2 new file mode 100644 index 000000000..c49f0f56f --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/README.md.j2 @@ -0,0 +1,327 @@ +# {{ agent_name }} - AgentEx Sync ACP + OpenAI Agents SDK (Local Sandbox) + +This is a starter template for building a **synchronous** AgentEx agent powered by the +[OpenAI Agents SDK](https://developers.openai.com/api/docs/guides/agents) and its +**sandbox** runtime, running with the **local** (`unix_local`) backend. + +The agent is a "local sandbox assistant": it answers questions by actually running real +shell commands (e.g. `python3 --version`, `ls /tmp`, `python3 -c "..."`) instead of +guessing. The local sandbox runs those commands **ON THE HOST** — the agent's own +process/container — so there is **no Docker, no Temporal, and no remote sandbox infra** +involved. + +## What You'll Learn + +- **Tasks**: A task is a grouping mechanism for related messages. Think of it as a conversation thread or a session. +- **Messages**: Messages are communication objects within a task. They can contain text, data, or instructions. +- **Sync ACP**: Synchronous Agent Communication Protocol that returns the agent's final answer per message. +- **OpenAI Agents SDK Sandbox**: Give an agent **capabilities** (e.g. `Shell`) that the runtime turns into real tools backed by a sandbox. +- **Local Sandbox (`UnixLocalSandboxClient`)**: Run those tools directly on the host with no extra infrastructure. + +## Running the Agent + +1. Run the agent locally: +```bash +agentex agents run --manifest manifest.yaml +``` + +The agent will start on port 8000 and respond immediately to any messages it receives. + +## What's Inside + +This template: +- Sets up a basic sync ACP server +- Handles incoming messages with immediate responses +- Provides a foundation for building real-time agents +- Can include streaming support for long responses + +## Next Steps + +For more advanced agent development, check out the AgentEx tutorials: + +- **Tutorials 00-08**: Learn about building synchronous agents with ACP +- **Tutorials 09-10**: Learn how to use Temporal to power asynchronous agents + - Tutorial 09: Basic Temporal workflow setup + - Tutorial 10: Advanced Temporal patterns and best practices + +These tutorials will help you understand: +- How to handle long-running tasks +- Implementing state machines +- Managing complex workflows +- Best practices for async agent development + +## The Manifest File + +The `manifest.yaml` file is your agent's configuration file. It defines: +- How your agent should be built and packaged +- What files are included in your agent's Docker image +- Your agent's name and description +- Local development settings (like the port your agent runs on) + +This file is essential for both local development and deployment of your agent. + +## Project Structure + +``` +{{ project_name }}/ +├── project/ # Your agent's code +│ ├── __init__.py +│ ├── acp.py # ACP server and message handler (runs the sandbox agent) +│ ├── agent.py # SandboxAgent + RunConfig(sandbox=...) wiring + run_agent +│ └── tools.py # Sandbox capability factory (Shell) +├── Dockerfile # Container definition +├── manifest.yaml # Deployment config +├── dev.ipynb # Development notebook for testing +{% if use_uv %} +└── pyproject.toml # Dependencies (uv) +{% else %} +└── requirements.txt # Dependencies (pip) +{% endif %} +``` + +## Development + +### 1. Customize Message Handlers +- Modify the handlers in `acp.py` to implement your agent's logic +- Add your own tools and capabilities +- Implement custom response generation + +### 2. Test Your Agent with the Development Notebook +Use the included `dev.ipynb` Jupyter notebook to test your agent interactively: + +```bash +# Start Jupyter notebook (make sure you have jupyter installed) +jupyter notebook dev.ipynb + +# Or use VS Code to open the notebook directly +code dev.ipynb +``` + +The notebook includes: +- **Setup**: Connect to your local AgentEx backend +- **Non-streaming tests**: Send messages and get complete responses +- **Streaming tests**: Test real-time streaming responses +- **Task management**: Optional task creation and management + +The notebook automatically uses your agent name (`{{ agent_name }}`) and provides examples for both streaming and non-streaming message handling. + +### 3. Manage Dependencies + +{% if use_uv %} +You chose **uv** for package management. Here's how to work with dependencies: + +```bash +# Add new dependencies +agentex uv add requests openai anthropic + +# Install/sync dependencies +agentex uv sync + +# Run commands with uv +uv run agentex agents run --manifest manifest.yaml +``` + +**Benefits of uv:** +- Faster dependency resolution and installation +- Better dependency isolation +- Modern Python packaging standards + +{% else %} +You chose **pip** for package management. Here's how to work with dependencies: + +```bash +# Edit requirements.txt manually to add dependencies +echo "requests" >> requirements.txt +echo "openai" >> requirements.txt + +# Install dependencies +pip install -r requirements.txt +``` + +**Benefits of pip:** +- Familiar workflow for most Python developers +- Simple requirements.txt management +- Wide compatibility +{% endif %} + +### 4. Configure Credentials +Options: +1. Add any required credentials to your manifest.yaml via the `env` section +2. Export them in your shell: `export LITELLM_API_KEY=...` +3. For local development, create a `.env.local` file in the project directory + +## Local Development + +### 1. Start the Agentex Backend +```bash +# Navigate to the backend directory +cd agentex + +# Start all services using Docker Compose +make dev + +# Optional: In a separate terminal, use lazydocker for a better UI (everything should say "healthy") +lzd +``` + +### 3. Run Your Agent +```bash +# From this directory +export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml +``` + +### 4. Interact with Your Agent + +**Option 1: Web UI (Recommended)** +```bash +# Start the local web interface +cd agentex-web +make dev + +# Then open http://localhost:3000 in your browser to chat with your agent +``` + +**Option 2: CLI (Deprecated)** +```bash +# Submit a task via CLI +agentex tasks submit --agent {{ agent_name }} --task "Your task here" +``` + +## Development Tips + +### Environment Variables +- Set environment variables in project/.env for any required credentials +- Or configure them in the manifest.yaml under the `env` section +- The `.env` file is automatically loaded in development mode + +### Local Testing +- Use `export ENVIRONMENT=development` before running your agent +- This enables local service discovery and debugging features +- Your agent will automatically connect to locally running services + +### Sync ACP Considerations +- Responses must be immediate (no long-running operations) +- Use streaming for longer responses +- Keep processing lightweight and fast +- Consider caching for frequently accessed data + +### Debugging +- Check agent logs in the terminal where you ran the agent +- Use the web UI to inspect task history and responses +- Monitor backend services with `lzd` (LazyDocker) +- Test response times and optimize for speed + +### To build the agent Docker image locally (normally not necessary): + +1. Build the agent image: +```bash +agentex agents build --manifest manifest.yaml +``` +{% if use_uv %} +```bash +# Build with uv +agentex agents build --manifest manifest.yaml --push +``` +{% else %} +```bash +# Build with pip +agentex agents build --manifest manifest.yaml --push +``` +{% endif %} + + +## Advanced Features + +### Streaming Responses +Handle long responses with streaming: + +```python +# In project/acp.py +@acp.on_message_send +async def handle_message_send(params: SendMessageParams): + # For streaming responses + async def stream_response(): + for chunk in generate_response_chunks(): + yield TaskMessageUpdate( + content=chunk, + is_complete=False + ) + yield TaskMessageUpdate( + content="", + is_complete=True + ) + + return stream_response() +``` + +### Custom Response Logic +Add sophisticated response generation: + +```python +# In project/acp.py +@acp.on_message_send +async def handle_message_send(params: SendMessageParams): + # Analyze input + content = params.content + if not isinstance(content, TextContent): + return TextContent(author="agent", content="Sorry, I can only handle text messages right now.") + user_message = content.content + + # Generate response + response = await generate_intelligent_response(user_message) + + return TextContent( + author=MessageAuthor.AGENT, + content=response + ) +``` + +### Integration with External Services +{% if use_uv %} +```bash +# Add service clients +agentex uv add httpx requests-oauthlib + +# Add AI/ML libraries +agentex uv add openai anthropic transformers + +# Add fast processing libraries +agentex uv add numpy pandas +``` +{% else %} +```bash +# Add to requirements.txt +echo "httpx" >> requirements.txt +echo "openai" >> requirements.txt +echo "numpy" >> requirements.txt +pip install -r requirements.txt +``` +{% endif %} + +## Troubleshooting + +### Common Issues + +1. **Agent not appearing in web UI** + - Check if agent is running on port 8000 + - Verify `ENVIRONMENT=development` is set + - Check agent logs for errors + +2. **Slow response times** + - Profile your message handling code + - Consider caching expensive operations + - Optimize database queries and API calls + +3. **Dependency issues** +{% if use_uv %} + - Run `agentex uv sync` to ensure all dependencies are installed +{% else %} + - Run `pip install -r requirements.txt` + - Check if all dependencies are correctly listed in requirements.txt +{% endif %} + +4. **Port conflicts** + - Check if another service is using port 8000 + - Use `lsof -i :8000` to find conflicting processes + +Happy building with Sync ACP! 🚀⚡ \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/dev.ipynb.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/dev.ipynb.j2 new file mode 100644 index 000000000..d8c10a65a --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/dev.ipynb.j2 @@ -0,0 +1,167 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"{{ agent_name }}\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [], + "source": [ + "# # (Optional) Create a new task. If you don't create a new task, each message will be sent to a new task. The server will create the task for you.\n", + "\n", + "# import uuid\n", + "\n", + "# TASK_ID = str(uuid.uuid4())[:8]\n", + "\n", + "# rpc_response = client.agents.rpc_by_name(\n", + "# agent_name=AGENT_NAME,\n", + "# method=\"task/create\",\n", + "# params={\n", + "# \"name\": f\"{TASK_ID}-task\",\n", + "# \"params\": {}\n", + "# }\n", + "# )\n", + "\n", + "# task = rpc_response.result\n", + "# print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b03b0d37", + "metadata": {}, + "outputs": [], + "source": [ + "# Test non streaming response\n", + "from agentex.types import TextContent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_message(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": False\n", + " }\n", + ")\n", + "\n", + "if not rpc_response or not rpc_response.result:\n", + " raise ValueError(\"No result in response\")\n", + "\n", + "# Extract and print just the text content from the response\n", + "for task_message in rpc_response.result:\n", + " content = task_message.content\n", + " if isinstance(content, TextContent):\n", + " text = content.content\n", + " print(text)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "79688331", + "metadata": {}, + "outputs": [], + "source": [ + "# Test streaming response\n", + "from agentex.types.task_message_update import StreamTaskMessageDelta, StreamTaskMessageFull\n", + "from agentex.types.text_delta import TextDelta\n", + "\n", + "\n", + "# The result object of message/send will be a TaskMessageUpdate which is a union of the following types:\n", + "# - StreamTaskMessageStart: \n", + "# - An indicator that a streaming message was started, doesn't contain any useful content\n", + "# - StreamTaskMessageDelta: \n", + "# - A delta of a streaming message, contains the text delta to aggregate\n", + "# - StreamTaskMessageDone: \n", + "# - An indicator that a streaming message was done, doesn't contain any useful content\n", + "# - StreamTaskMessageFull: \n", + "# - A non-streaming message, there is nothing to aggregate, since this contains the full message, not deltas\n", + "\n", + "# Whenn processing StreamTaskMessageDelta, if you are expecting more than TextDeltas, such as DataDelta, ToolRequestDelta, or ToolResponseDelta, you can process them as well\n", + "# Whenn processing StreamTaskMessageFull, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "for agent_rpc_response_chunk in client.agents.send_message_stream(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": True\n", + " }\n", + "):\n", + " # We know that the result of the message/send when stream is set to True will be a TaskMessageUpdate\n", + " task_message_update = agent_rpc_response_chunk.result\n", + " # Print oly the text deltas as they arrive or any full messages\n", + " if isinstance(task_message_update, StreamTaskMessageDelta):\n", + " delta = task_message_update.delta\n", + " if isinstance(delta, TextDelta):\n", + " print(delta.text_delta, end=\"\", flush=True)\n", + " else:\n", + " print(f\"Found non-text {type(task_message)} object in streaming message.\")\n", + " elif isinstance(task_message_update, StreamTaskMessageFull):\n", + " content = task_message_update.content\n", + " if isinstance(content, TextContent):\n", + " print(content.content)\n", + " else:\n", + " print(f\"Found non-text {type(task_message)} object in full message.\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c5e7e042", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/environments.yaml.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/environments.yaml.j2 new file mode 100644 index 000000000..73924abdd --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/environments.yaml.j2 @@ -0,0 +1,53 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-{{agent_name}}" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/manifest.yaml.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/manifest.yaml.j2 new file mode 100644 index 000000000..6377d01cd --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/manifest.yaml.j2 @@ -0,0 +1,118 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../ # Keep this as the default root + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - {{ project_path_from_build_root }} + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: {{ project_path_from_build_root }}/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: {{ project_path_from_build_root }}/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + +# Agent Configuration +# ----------------- +agent: + acp_type: sync + # Unique name for your agent + # Used for task routing and monitoring + name: {{ agent_name }} + + # Description of what your agent does + # Helps with documentation and discovery + description: {{ description | tojson }} + + # Temporal workflow configuration + # Set enabled: true to use Temporal workflows for long-running tasks + temporal: + enabled: false + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + credentials: [] # Update with your credentials + # - env_var_name: LITELLM_API_KEY + # secret_name: litellm-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + env: + # Disable the OpenAI Agents SDK's native tracer (it would otherwise try to + # ship traces to api.openai.com and 401 behind a LiteLLM/proxy key). + OPENAI_AGENTS_DISABLE_TRACING: "1" + # LITELLM_API_KEY: "" + # OPENAI_BASE_URL: "" + # OPENAI_ORG_ID: "" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: [] # Update with your image pull secret names + # - name: my-registry-secret + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific environments (environments.yaml) + global: + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/acp.py.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/acp.py.j2 new file mode 100644 index 000000000..14af98351 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/acp.py.j2 @@ -0,0 +1,84 @@ +"""ACP (Agent Communication Protocol) handler for Agentex. + +This is the API layer — it owns the agent lifecycle and runs the OpenAI Agents +SDK *sandbox* agent for each incoming message, returning the agent's final +answer to the Agentex frontend. + +The agent uses the LOCAL sandbox backend (``UnixLocalSandboxClient``), which runs +shell commands on the host (this process/container). The OpenAI Agents SDK runs +its tool-call loop internally via ``Runner.run`` and returns the final output, so +this sync handler returns a single ``TextContent`` rather than streaming tokens. +""" + +from __future__ import annotations + +import os + +from dotenv import load_dotenv + +load_dotenv() + +from agentex.lib import adk +from project.agent import run_agent +from agentex.protocol.acp import SendMessageParams +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.types.task_message_content import TaskMessageContent +from agentex.lib.core.tracing.tracing_processor_manager import ( + add_tracing_processor_config, +) + +logger = make_logger(__name__) + +# LiteLLM proxy auth: copy LITELLM_API_KEY to OPENAI_API_KEY for OpenAI client +# compatibility, so the same agent works behind the Scale LiteLLM gateway. +_litellm_key = os.environ.get("LITELLM_API_KEY") +if _litellm_key and not os.environ.get("OPENAI_API_KEY"): + os.environ["OPENAI_API_KEY"] = _litellm_key + +SGP_API_KEY = os.environ.get("SGP_API_KEY", "") +SGP_ACCOUNT_ID = os.environ.get("SGP_ACCOUNT_ID", "") +SGP_CLIENT_BASE_URL = os.environ.get("SGP_CLIENT_BASE_URL", "") + +if SGP_API_KEY and SGP_ACCOUNT_ID: + add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=SGP_API_KEY, + sgp_account_id=SGP_ACCOUNT_ID, + sgp_base_url=SGP_CLIENT_BASE_URL, + ) + ) + +AGENT_NAME = "{{ agent_name }}" + +# Create an ACP server +acp = FastACP.create(acp_type="sync") + + +@acp.on_message_send +async def handle_message_send( + params: SendMessageParams, +) -> TaskMessageContent: + """Handle incoming messages by running the local-sandbox agent.""" + task_id = params.task.id + content = params.content + if not isinstance(content, TextContent): + logger.warning("Ignoring non-text message content (type=%s)", getattr(content, "type", "?")) + return TextContent(author="agent", content="Sorry, I can only handle text messages right now.") + user_message = content.content + logger.info(f"Processing message for task {task_id}") + + async with adk.tracing.span( + trace_id=task_id, + task_id=task_id, + name="message", + input={"message": user_message}, + data={"__span_type__": "AGENT_WORKFLOW"}, + ) as turn_span: + final_output = await run_agent(user_message) + if turn_span: + turn_span.output = {"final_output": final_output} + + return TextContent(author="agent", content=final_output) 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 new file mode 100644 index 000000000..07546bffb --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/agent.py.j2 @@ -0,0 +1,91 @@ +"""OpenAI Agents SDK local-sandbox agent definition. + +The agent is the boundary between this module and the API layer (acp.py). The +runtime is the OpenAI Agents SDK ``SandboxAgent`` together with the **local** +sandbox backend (``UnixLocalSandboxClient``). + +The local sandbox runs shell commands ON THE HOST — the agent's own +container/process. There is no Docker, no Temporal, and no remote sandbox +infrastructure. The OpenAI Agents SDK runs its own tool-call loop internally: +when the model decides to run a shell command, the sandbox executes it locally +and feeds the output back to the model until it produces a final answer. +""" + +from __future__ import annotations + +from datetime import datetime + +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 ( + UnixLocalSandboxClient, + UnixLocalSandboxClientOptions, +) + +from project.tools import get_capabilities + +# 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. + +Current date and time: {timestamp} + +You have access to shell tools that run real commands on the local machine. + +Guidelines: +- ALWAYS use the shell tools to actually run commands — never guess or make up + output. If the user asks for the Python version, run `python3 --version`. If + they ask to list files, run `ls`. If they ask you to compute something, use + `python3 -c "..."`. +- Run the minimal command(s) needed to answer the question. +- Report the real command output back to the user, concisely. +""" + + +def create_agent() -> SandboxAgent: + """Build and return the OpenAI Agents SDK sandbox agent. + + The agent is granted shell capabilities (see ``project.tools``). The actual + sandbox backend (where the shell commands run) is supplied at run time via + the ``RunConfig`` returned by ``create_run_config``. + """ + return SandboxAgent( + name="{{ agent_name }}", + model=MODEL_NAME, + instructions=INSTRUCTIONS.format( + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ), + capabilities=get_capabilities(), + ) + + +def create_run_config() -> RunConfig: + """Build the RunConfig that points the agent at the LOCAL sandbox backend. + + ``UnixLocalSandboxClient`` (backend_id="unix_local") runs shell commands on + the host — the agent's own process — so no Docker or remote infra is needed. + """ + return RunConfig( + sandbox=SandboxRunConfig( + client=UnixLocalSandboxClient(), + options=UnixLocalSandboxClientOptions(), + ) + ) + + +async def run_agent(user_message: str) -> str: + """Run the sandbox agent on a single user message and return the final text. + + The OpenAI Agents SDK handles the full tool-call loop internally: the model + issues shell commands, the local sandbox runs them on the host, and the + output is fed back until the model produces a final answer. + """ + agent = create_agent() + run_config = create_run_config() + result = await Runner.run(agent, input=user_message, run_config=run_config, max_turns=10) + return result.final_output diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/tools.py.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/tools.py.j2 new file mode 100644 index 000000000..8c4a173d0 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/tools.py.j2 @@ -0,0 +1,29 @@ +"""Sandbox capabilities for the OpenAI Agents SDK local-sandbox agent. + +This agent does not register hand-written Python functions as tools. Instead it +is given *capabilities* — the OpenAI Agents SDK sandbox runtime turns each +capability into a real set of tools (run a shell command, read a file, etc.) +backed by an actual sandbox backend. + +Here we use the ``Shell`` capability, which lets the model run real shell commands. +With the local (``unix_local``) backend those commands execute ON THE HOST — the +agent's own process/container — so there is no Docker, Temporal, or remote infra +involved. This module hosts the capability factory so the agent wiring in +``project.agent`` stays readable and the capability set is easy to extend +(e.g. add ``Filesystem()`` or ``Memory()``). +""" + +from __future__ import annotations + +from agents.sandbox.capabilities import Shell + + +def get_capabilities() -> list: + """Return the sandbox capabilities the agent is allowed to use. + + Returns: + A list of OpenAI Agents SDK sandbox capabilities. We grant ``Shell`` so + the agent can run real shell commands on the local machine. Add + ``Filesystem()`` or ``Memory()`` here to expand what the agent can do. + """ + return [Shell()] diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/pyproject.toml.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/pyproject.toml.j2 new file mode 100644 index 000000000..79e35cf0b --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/pyproject.toml.j2 @@ -0,0 +1,36 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ project_name }}" +version = "0.1.0" +description = "{{ description }}" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "openai-agents>=0.14.3,<0.15", + "python-dotenv", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/requirements.txt.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/requirements.txt.j2 new file mode 100644 index 000000000..6f73c3ae3 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/requirements.txt.j2 @@ -0,0 +1,11 @@ +# Install agentex-sdk from local path +agentex-sdk + +# Scale GenAI Platform Python SDK +scale-gp + +# OpenAI Agents SDK (provides agents.sandbox + UnixLocalSandboxClient) +openai-agents>=0.14.3,<0.15 + +# Loads .env for local development +python-dotenv diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/test_agent.py.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/test_agent.py.j2 new file mode 100644 index 000000000..8fa89bff8 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/test_agent.py.j2 @@ -0,0 +1,135 @@ +"""Tests for the sync OpenAI Agents SDK local-sandbox agent. + +This test suite validates: +- Sending a message that requires the agent to actually run a shell command in + the LOCAL sandbox (unix_local backend) and receiving a non-empty response. + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: {{ agent_name }}) +""" + +import os + +import pytest + +from agentex import Agentex +from agentex.types import TextContentParam +from agentex.types.agent_rpc_params import ParamsSendMessageRequest + + +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "{{ agent_name }}") + + +@pytest.fixture +def client(): + """Create an AgentEx client instance for testing.""" + return Agentex(base_url=AGENTEX_API_BASE_URL) + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest.fixture +def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +def _response_text(result) -> str: + """Flatten a send_message result into a single string for assertions.""" + parts = [] + for content in result: + text = getattr(content, "content", None) + if isinstance(text, str): + parts.append(text) + return "\n".join(parts) + + +class TestLocalSandboxMessages: + """Test the local-sandbox OpenAI Agents SDK agent.""" + + def test_send_simple_message(self, client: Agentex, agent_name: str): + """Test sending a simple message and receiving a response.""" + response = client.agents.send_message( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content="Hello! What can you help me with?", + type="text", + ) + ), + ) + result = response.result + assert result is not None + assert len(result) >= 1 + + def test_shell_python_version(self, client: Agentex, agent_name: str): + """Test that the agent uses its shell to run a real command. + + We ask it to print the Python version. The agent should run + `python3 --version` in the local sandbox and report the real output, + which always starts with "Python 3". + """ + response = client.agents.send_message( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content=( + "Use your shell to print the Python version on this " + "machine, then tell me what it is." + ), + type="text", + ) + ), + ) + result = response.result + assert result is not None + assert len(result) >= 1 + + text = _response_text(result) + assert text, "Expected a non-empty response from the sandbox agent." + # The sandbox runs on Python 3.12, so the real output contains "Python 3". + assert "Python 3" in text + + def test_shell_compute(self, client: Agentex, agent_name: str): + """Test that the agent uses python3 in the sandbox to compute a value.""" + response = client.agents.send_message( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content=( + "Use python3 in your shell to compute 21 * 2 and tell me " + "the result." + ), + type="text", + ) + ), + ) + result = response.result + assert result is not None + assert len(result) >= 1 + + text = _response_text(result) + assert text, "Expected a non-empty response from the sandbox agent." + assert "42" in text + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/src/agentex/lib/cli/templates/sync-openai-agents/.dockerignore.j2 b/src/agentex/lib/cli/templates/sync-openai-agents/.dockerignore.j2 new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents/.dockerignore.j2 @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/src/agentex/lib/cli/templates/sync-openai-agents/.env.example.j2 b/src/agentex/lib/cli/templates/sync-openai-agents/.env.example.j2 new file mode 100644 index 000000000..015f49ef7 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents/.env.example.j2 @@ -0,0 +1,13 @@ +# {{ agent_name }} - Environment Variables +# Copy this file to .env and fill in the values + +# API key for your LLM provider +LITELLM_API_KEY= + +# LLM base URL (optional - override to use a different provider) +# OPENAI_BASE_URL= + +# SGP Configuration (optional - for tracing) +# SGP_API_KEY= +# SGP_ACCOUNT_ID= +# SGP_CLIENT_BASE_URL= 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 new file mode 100644 index 000000000..dd3035f7b --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile-uv.j2 @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +ENV UV_HTTP_TIMEOUT=1000 + +WORKDIR /app/{{ project_path_from_build_root }} + +# Copy dependency files for layer caching +COPY {{ project_path_from_build_root }}/pyproject.toml ./ + +# Install dependencies (without project itself, for layer caching) +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-install-project --no-dev + +# Copy the project code +COPY {{ project_path_from_build_root }}/project ./project + +# Install the project +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile.j2 new file mode 100644 index 000000000..4d9f41d45 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile.j2 @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + node \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy just the requirements file to optimize caching +COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt + +WORKDIR /app/{{ project_path_from_build_root }} + +# Install the required Python packages +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 + + +# Set environment variables +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-openai-agents/README.md.j2 b/src/agentex/lib/cli/templates/sync-openai-agents/README.md.j2 new file mode 100644 index 000000000..7711969cd --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents/README.md.j2 @@ -0,0 +1,316 @@ +# {{ agent_name }} - AgentEx Sync ACP Template + +This is a starter template for building synchronous agents with the AgentEx framework. It provides a basic implementation of the Agent 2 Client Protocol (ACP) with immediate response capabilities to help you get started quickly. + +## What You'll Learn + +- **Tasks**: A task is a grouping mechanism for related messages. Think of it as a conversation thread or a session. +- **Messages**: Messages are communication objects within a task. They can contain text, data, or instructions. +- **Sync ACP**: Synchronous Agent Communication Protocol that requires immediate responses +- **Message Handling**: How to process and respond to messages in real-time + +## Running the Agent + +1. Run the agent locally: +```bash +agentex agents run --manifest manifest.yaml +``` + +The agent will start on port 8000 and respond immediately to any messages it receives. + +## What's Inside + +This template: +- Sets up a basic sync ACP server +- Handles incoming messages with immediate responses +- Provides a foundation for building real-time agents +- Can include streaming support for long responses + +## Next Steps + +For more advanced agent development, check out the AgentEx tutorials: + +- **Tutorials 00-08**: Learn about building synchronous agents with ACP +- **Tutorials 09-10**: Learn how to use Temporal to power asynchronous agents + - Tutorial 09: Basic Temporal workflow setup + - Tutorial 10: Advanced Temporal patterns and best practices + +These tutorials will help you understand: +- How to handle long-running tasks +- Implementing state machines +- Managing complex workflows +- Best practices for async agent development + +## The Manifest File + +The `manifest.yaml` file is your agent's configuration file. It defines: +- How your agent should be built and packaged +- What files are included in your agent's Docker image +- Your agent's name and description +- Local development settings (like the port your agent runs on) + +This file is essential for both local development and deployment of your agent. + +## Project Structure + +``` +{{ project_name }}/ +├── project/ # Your agent's code +│ ├── __init__.py +│ └── acp.py # ACP server and event handlers +├── Dockerfile # Container definition +├── manifest.yaml # Deployment config +├── dev.ipynb # Development notebook for testing +{% if use_uv %} +└── pyproject.toml # Dependencies (uv) +{% else %} +└── requirements.txt # Dependencies (pip) +{% endif %} +``` + +## Development + +### 1. Customize Message Handlers +- Modify the handlers in `acp.py` to implement your agent's logic +- Add your own tools and capabilities +- Implement custom response generation + +### 2. Test Your Agent with the Development Notebook +Use the included `dev.ipynb` Jupyter notebook to test your agent interactively: + +```bash +# Start Jupyter notebook (make sure you have jupyter installed) +jupyter notebook dev.ipynb + +# Or use VS Code to open the notebook directly +code dev.ipynb +``` + +The notebook includes: +- **Setup**: Connect to your local AgentEx backend +- **Non-streaming tests**: Send messages and get complete responses +- **Streaming tests**: Test real-time streaming responses +- **Task management**: Optional task creation and management + +The notebook automatically uses your agent name (`{{ agent_name }}`) and provides examples for both streaming and non-streaming message handling. + +### 3. Manage Dependencies + +{% if use_uv %} +You chose **uv** for package management. Here's how to work with dependencies: + +```bash +# Add new dependencies +agentex uv add requests openai anthropic + +# Install/sync dependencies +agentex uv sync + +# Run commands with uv +uv run agentex agents run --manifest manifest.yaml +``` + +**Benefits of uv:** +- Faster dependency resolution and installation +- Better dependency isolation +- Modern Python packaging standards + +{% else %} +You chose **pip** for package management. Here's how to work with dependencies: + +```bash +# Edit requirements.txt manually to add dependencies +echo "requests" >> requirements.txt +echo "openai" >> requirements.txt + +# Install dependencies +pip install -r requirements.txt +``` + +**Benefits of pip:** +- Familiar workflow for most Python developers +- Simple requirements.txt management +- Wide compatibility +{% endif %} + +### 4. Configure Credentials +Options: +1. Add any required credentials to your manifest.yaml via the `env` section +2. Export them in your shell: `export LITELLM_API_KEY=...` +3. For local development, create a `.env.local` file in the project directory + +## Local Development + +### 1. Start the Agentex Backend +```bash +# Navigate to the backend directory +cd agentex + +# Start all services using Docker Compose +make dev + +# Optional: In a separate terminal, use lazydocker for a better UI (everything should say "healthy") +lzd +``` + +### 3. Run Your Agent +```bash +# From this directory +export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml +``` + +### 4. Interact with Your Agent + +**Option 1: Web UI (Recommended)** +```bash +# Start the local web interface +cd agentex-web +make dev + +# Then open http://localhost:3000 in your browser to chat with your agent +``` + +**Option 2: CLI (Deprecated)** +```bash +# Submit a task via CLI +agentex tasks submit --agent {{ agent_name }} --task "Your task here" +``` + +## Development Tips + +### Environment Variables +- Set environment variables in project/.env for any required credentials +- Or configure them in the manifest.yaml under the `env` section +- The `.env` file is automatically loaded in development mode + +### Local Testing +- Use `export ENVIRONMENT=development` before running your agent +- This enables local service discovery and debugging features +- Your agent will automatically connect to locally running services + +### Sync ACP Considerations +- Responses must be immediate (no long-running operations) +- Use streaming for longer responses +- Keep processing lightweight and fast +- Consider caching for frequently accessed data + +### Debugging +- Check agent logs in the terminal where you ran the agent +- Use the web UI to inspect task history and responses +- Monitor backend services with `lzd` (LazyDocker) +- Test response times and optimize for speed + +### To build the agent Docker image locally (normally not necessary): + +1. Build the agent image: +```bash +agentex agents build --manifest manifest.yaml +``` +{% if use_uv %} +```bash +# Build with uv +agentex agents build --manifest manifest.yaml --push +``` +{% else %} +```bash +# Build with pip +agentex agents build --manifest manifest.yaml --push +``` +{% endif %} + + +## Advanced Features + +### Streaming Responses +Handle long responses with streaming: + +```python +# In project/acp.py +@acp.on_message_send +async def handle_message_send(params: SendMessageParams): + # For streaming responses + async def stream_response(): + for chunk in generate_response_chunks(): + yield TaskMessageUpdate( + content=chunk, + is_complete=False + ) + yield TaskMessageUpdate( + content="", + is_complete=True + ) + + return stream_response() +``` + +### Custom Response Logic +Add sophisticated response generation: + +```python +# In project/acp.py +@acp.on_message_send +async def handle_message_send(params: SendMessageParams): + # Analyze input + content = params.content + if not isinstance(content, TextContent): + return TextContent(author="agent", content="Sorry, I can only handle text messages right now.") + user_message = content.content + + # Generate response + response = await generate_intelligent_response(user_message) + + return TextContent( + author=MessageAuthor.AGENT, + content=response + ) +``` + +### Integration with External Services +{% if use_uv %} +```bash +# Add service clients +agentex uv add httpx requests-oauthlib + +# Add AI/ML libraries +agentex uv add openai anthropic transformers + +# Add fast processing libraries +agentex uv add numpy pandas +``` +{% else %} +```bash +# Add to requirements.txt +echo "httpx" >> requirements.txt +echo "openai" >> requirements.txt +echo "numpy" >> requirements.txt +pip install -r requirements.txt +``` +{% endif %} + +## Troubleshooting + +### Common Issues + +1. **Agent not appearing in web UI** + - Check if agent is running on port 8000 + - Verify `ENVIRONMENT=development` is set + - Check agent logs for errors + +2. **Slow response times** + - Profile your message handling code + - Consider caching expensive operations + - Optimize database queries and API calls + +3. **Dependency issues** +{% if use_uv %} + - Run `agentex uv sync` to ensure all dependencies are installed +{% else %} + - Run `pip install -r requirements.txt` + - Check if all dependencies are correctly listed in requirements.txt +{% endif %} + +4. **Port conflicts** + - Check if another service is using port 8000 + - Use `lsof -i :8000` to find conflicting processes + +Happy building with Sync ACP! 🚀⚡ \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-openai-agents/dev.ipynb.j2 b/src/agentex/lib/cli/templates/sync-openai-agents/dev.ipynb.j2 new file mode 100644 index 000000000..d8c10a65a --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents/dev.ipynb.j2 @@ -0,0 +1,167 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"{{ agent_name }}\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [], + "source": [ + "# # (Optional) Create a new task. If you don't create a new task, each message will be sent to a new task. The server will create the task for you.\n", + "\n", + "# import uuid\n", + "\n", + "# TASK_ID = str(uuid.uuid4())[:8]\n", + "\n", + "# rpc_response = client.agents.rpc_by_name(\n", + "# agent_name=AGENT_NAME,\n", + "# method=\"task/create\",\n", + "# params={\n", + "# \"name\": f\"{TASK_ID}-task\",\n", + "# \"params\": {}\n", + "# }\n", + "# )\n", + "\n", + "# task = rpc_response.result\n", + "# print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b03b0d37", + "metadata": {}, + "outputs": [], + "source": [ + "# Test non streaming response\n", + "from agentex.types import TextContent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_message(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": False\n", + " }\n", + ")\n", + "\n", + "if not rpc_response or not rpc_response.result:\n", + " raise ValueError(\"No result in response\")\n", + "\n", + "# Extract and print just the text content from the response\n", + "for task_message in rpc_response.result:\n", + " content = task_message.content\n", + " if isinstance(content, TextContent):\n", + " text = content.content\n", + " print(text)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "79688331", + "metadata": {}, + "outputs": [], + "source": [ + "# Test streaming response\n", + "from agentex.types.task_message_update import StreamTaskMessageDelta, StreamTaskMessageFull\n", + "from agentex.types.text_delta import TextDelta\n", + "\n", + "\n", + "# The result object of message/send will be a TaskMessageUpdate which is a union of the following types:\n", + "# - StreamTaskMessageStart: \n", + "# - An indicator that a streaming message was started, doesn't contain any useful content\n", + "# - StreamTaskMessageDelta: \n", + "# - A delta of a streaming message, contains the text delta to aggregate\n", + "# - StreamTaskMessageDone: \n", + "# - An indicator that a streaming message was done, doesn't contain any useful content\n", + "# - StreamTaskMessageFull: \n", + "# - A non-streaming message, there is nothing to aggregate, since this contains the full message, not deltas\n", + "\n", + "# Whenn processing StreamTaskMessageDelta, if you are expecting more than TextDeltas, such as DataDelta, ToolRequestDelta, or ToolResponseDelta, you can process them as well\n", + "# Whenn processing StreamTaskMessageFull, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "for agent_rpc_response_chunk in client.agents.send_message_stream(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": True\n", + " }\n", + "):\n", + " # We know that the result of the message/send when stream is set to True will be a TaskMessageUpdate\n", + " task_message_update = agent_rpc_response_chunk.result\n", + " # Print oly the text deltas as they arrive or any full messages\n", + " if isinstance(task_message_update, StreamTaskMessageDelta):\n", + " delta = task_message_update.delta\n", + " if isinstance(delta, TextDelta):\n", + " print(delta.text_delta, end=\"\", flush=True)\n", + " else:\n", + " print(f\"Found non-text {type(task_message)} object in streaming message.\")\n", + " elif isinstance(task_message_update, StreamTaskMessageFull):\n", + " content = task_message_update.content\n", + " if isinstance(content, TextContent):\n", + " print(content.content)\n", + " else:\n", + " print(f\"Found non-text {type(task_message)} object in full message.\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c5e7e042", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/agentex/lib/cli/templates/sync-openai-agents/environments.yaml.j2 b/src/agentex/lib/cli/templates/sync-openai-agents/environments.yaml.j2 new file mode 100644 index 000000000..73924abdd --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents/environments.yaml.j2 @@ -0,0 +1,53 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-{{agent_name}}" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + diff --git a/src/agentex/lib/cli/templates/sync-openai-agents/manifest.yaml.j2 b/src/agentex/lib/cli/templates/sync-openai-agents/manifest.yaml.j2 new file mode 100644 index 000000000..875fcc5e0 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents/manifest.yaml.j2 @@ -0,0 +1,115 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../ # Keep this as the default root + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - {{ project_path_from_build_root }} + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: {{ project_path_from_build_root }}/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: {{ project_path_from_build_root }}/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + +# Agent Configuration +# ----------------- +agent: + acp_type: sync + # Unique name for your agent + # Used for task routing and monitoring + name: {{ agent_name }} + + # Description of what your agent does + # Helps with documentation and discovery + description: {{ description | tojson }} + + # Temporal workflow configuration + # Set enabled: true to use Temporal workflows for long-running tasks + temporal: + enabled: false + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + credentials: [] # Update with your credentials + # - env_var_name: LITELLM_API_KEY + # secret_name: litellm-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + env: {} # Update with your environment variables + # LITELLM_API_KEY: "" + # OPENAI_BASE_URL: "" + # OPENAI_ORG_ID: "" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: [] # Update with your image pull secret names + # - name: my-registry-secret + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific environments (environments.yaml) + global: + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file 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 new file mode 100644 index 000000000..41029f2ce --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents/project/acp.py.j2 @@ -0,0 +1,156 @@ +import os +from typing import AsyncGenerator, List + +from agentex.lib import adk +from agentex.lib.adk.providers._modules.sync_provider import SyncStreamingProvider, convert_openai_to_agentex_events +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.protocol.acp import SendMessageParams +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.model_utils import BaseModel + +from agentex.types.task_message_update import TaskMessageUpdate, StreamTaskMessageFull +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_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__) + +# LiteLLM proxy auth: copy LITELLM_API_KEY to OPENAI_API_KEY for OpenAI client compatibility +_litellm_key = os.environ.get("LITELLM_API_KEY") +if _litellm_key: + os.environ["OPENAI_API_KEY"] = _litellm_key + +SGP_API_KEY = os.environ.get("SGP_API_KEY", "") +SGP_ACCOUNT_ID = os.environ.get("SGP_ACCOUNT_ID", "") +SGP_CLIENT_BASE_URL = os.environ.get("SGP_CLIENT_BASE_URL", "") + +if SGP_API_KEY and SGP_ACCOUNT_ID: + add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=SGP_API_KEY, + sgp_account_id=SGP_ACCOUNT_ID, + sgp_base_url=SGP_CLIENT_BASE_URL, + ) + ) + + +MODEL = "gpt-4o-mini" + +SYSTEM_PROMPT = """ + +You are a helpful assistant. Use your tools to help the user. + + + +Communicate in a witty and friendly manner + +""" + +AGENT_NAME = "{{ agent_name }}" + + +@function_tool +async def get_weather() -> str: + """ + Get the current weather. + + This is a dummy activity that returns a hardcoded string for demo purposes. + Replace this with a real weather API call in your implementation. + + Returns: + A string describing the current weather conditions. + """ + logger.info("get_weather activity called") + return "Sunny, 72°F" + + + +# Create an ACP server +acp = FastACP.create( + acp_type="sync", +) + +class StateModel(BaseModel): + input_list: List[dict] + turn_number: int + + +@acp.on_message_send +async def handle_message_send( + params: SendMessageParams +) -> TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]: + if not os.environ.get("LITELLM_API_KEY"): + yield StreamTaskMessageFull( + index=0, + type="full", + content=TextContent( + author="agent", + content="Hey, sorry I'm unable to respond to your message because you're running this example without a LiteLLM API key. Please set the LITELLM_API_KEY environment variable to run this example. Do this by either adding a .env file to the project/ directory or by setting the environment variable in your terminal.", + ), + ) + return + + content = params.content + if not isinstance(content, TextContent): + logger.warning("Ignoring non-text message content (type=%s)", getattr(content, "type", "?")) + return + + user_prompt = content.content + + # Retrieve the task state. Each event is handled as a new turn, so we need to get the state for the current turn. + task_state = await adk.state.get_by_task_and_agent(task_id=params.task.id, agent_id=params.agent.id) + if not task_state: + # If the state doesn't exist, create it. + state = StateModel(input_list=[], turn_number=0) + task_state = await adk.state.create(task_id=params.task.id, agent_id=params.agent.id, state=state) + else: + state = StateModel.model_validate(task_state.state) + + state.turn_number += 1 + state.input_list.append({"role": "user", "content": user_prompt}) + + # Initialize the sync provider and run config to allow for tracing + provider = SyncStreamingProvider( + trace_id=params.task.id, + ) + + run_config = RunConfig( + model_provider=provider, + ) + + # Initialize the agent + agent = Agent( + name=AGENT_NAME, + instructions=SYSTEM_PROMPT, + model=MODEL, + tools=[get_weather], + ) + + # Run the agent with the conversation history from state + result = Runner.run_streamed( + agent, + state.input_list, + run_config=run_config + ) + + # Convert the OpenAI events to Agentex events and stream them back to the client + async for agentex_event in convert_openai_to_agentex_events(result.stream_events()): + yield agentex_event + + # After streaming is complete, update state with the full conversation history + state.input_list = result.to_input_list() + await adk.state.update( + state_id=task_state.id, + task_id=params.task.id, + agent_id=params.agent.id, + state=state, + trace_id=params.task.id, + ) \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-openai-agents/pyproject.toml.j2 b/src/agentex/lib/cli/templates/sync-openai-agents/pyproject.toml.j2 new file mode 100644 index 000000000..34e04e6a4 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents/pyproject.toml.j2 @@ -0,0 +1,32 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ project_name }}" +version = "0.1.0" +description = "{{ description }}" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/src/agentex/lib/cli/templates/sync-openai-agents/requirements.txt.j2 b/src/agentex/lib/cli/templates/sync-openai-agents/requirements.txt.j2 new file mode 100644 index 000000000..0b8ae19b3 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents/requirements.txt.j2 @@ -0,0 +1,5 @@ +# Install agentex-sdk from local path +agentex-sdk + +# Scale GenAI Platform Python SDK +scale-gp diff --git a/src/agentex/lib/cli/templates/sync-openai-agents/test_agent.py.j2 b/src/agentex/lib/cli/templates/sync-openai-agents/test_agent.py.j2 new file mode 100644 index 000000000..7de4684f4 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents/test_agent.py.j2 @@ -0,0 +1,70 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming message sending +- Streaming message sending +- Task creation via RPC + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: {{ agent_name }}) +""" + +import os +import pytest +from agentex import Agentex + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "{{ agent_name }}") + + +@pytest.fixture +def client(): + """Create an AgentEx client instance for testing.""" + return Agentex(base_url=AGENTEX_API_BASE_URL) + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest.fixture +def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingMessages: + """Test non-streaming message sending.""" + + def test_send_message(self, client: Agentex, _agent_name: str): + """Test sending a message and receiving a response.""" + # TODO: Fill in the test based on what data your agent is expected to handle + ... + + +class TestStreamingMessages: + """Test streaming message sending.""" + + def test_send_stream_message(self, client: Agentex, _agent_name: str): + """Test streaming a message and aggregating deltas.""" + # TODO: Fill in the test based on what data your agent is expected to handle + ... + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/.dockerignore.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/.dockerignore.j2 new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-pydantic-ai/.dockerignore.j2 @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/.env.example.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/.env.example.j2 new file mode 100644 index 000000000..1e81b15dd --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-pydantic-ai/.env.example.j2 @@ -0,0 +1,12 @@ +# {{ agent_name }} - Environment Variables +# Copy this file to .env and fill in the values + +# API key for your LLM provider +LITELLM_API_KEY= + +# LLM base URL (optional - override to use a different provider) +# OPENAI_BASE_URL= + +# SGP Configuration (optional - for tracing) +# SGP_API_KEY= +# SGP_ACCOUNT_ID= 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 new file mode 100644 index 000000000..dd3035f7b --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile-uv.j2 @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +ENV UV_HTTP_TIMEOUT=1000 + +WORKDIR /app/{{ project_path_from_build_root }} + +# Copy dependency files for layer caching +COPY {{ project_path_from_build_root }}/pyproject.toml ./ + +# Install dependencies (without project itself, for layer caching) +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-install-project --no-dev + +# Copy the project code +COPY {{ project_path_from_build_root }}/project ./project + +# Install the project +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile.j2 new file mode 100644 index 000000000..4d9f41d45 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile.j2 @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + node \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy just the requirements file to optimize caching +COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt + +WORKDIR /app/{{ project_path_from_build_root }} + +# Install the required Python packages +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 + + +# Set environment variables +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/README.md.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/README.md.j2 new file mode 100644 index 000000000..7711969cd --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-pydantic-ai/README.md.j2 @@ -0,0 +1,316 @@ +# {{ agent_name }} - AgentEx Sync ACP Template + +This is a starter template for building synchronous agents with the AgentEx framework. It provides a basic implementation of the Agent 2 Client Protocol (ACP) with immediate response capabilities to help you get started quickly. + +## What You'll Learn + +- **Tasks**: A task is a grouping mechanism for related messages. Think of it as a conversation thread or a session. +- **Messages**: Messages are communication objects within a task. They can contain text, data, or instructions. +- **Sync ACP**: Synchronous Agent Communication Protocol that requires immediate responses +- **Message Handling**: How to process and respond to messages in real-time + +## Running the Agent + +1. Run the agent locally: +```bash +agentex agents run --manifest manifest.yaml +``` + +The agent will start on port 8000 and respond immediately to any messages it receives. + +## What's Inside + +This template: +- Sets up a basic sync ACP server +- Handles incoming messages with immediate responses +- Provides a foundation for building real-time agents +- Can include streaming support for long responses + +## Next Steps + +For more advanced agent development, check out the AgentEx tutorials: + +- **Tutorials 00-08**: Learn about building synchronous agents with ACP +- **Tutorials 09-10**: Learn how to use Temporal to power asynchronous agents + - Tutorial 09: Basic Temporal workflow setup + - Tutorial 10: Advanced Temporal patterns and best practices + +These tutorials will help you understand: +- How to handle long-running tasks +- Implementing state machines +- Managing complex workflows +- Best practices for async agent development + +## The Manifest File + +The `manifest.yaml` file is your agent's configuration file. It defines: +- How your agent should be built and packaged +- What files are included in your agent's Docker image +- Your agent's name and description +- Local development settings (like the port your agent runs on) + +This file is essential for both local development and deployment of your agent. + +## Project Structure + +``` +{{ project_name }}/ +├── project/ # Your agent's code +│ ├── __init__.py +│ └── acp.py # ACP server and event handlers +├── Dockerfile # Container definition +├── manifest.yaml # Deployment config +├── dev.ipynb # Development notebook for testing +{% if use_uv %} +└── pyproject.toml # Dependencies (uv) +{% else %} +└── requirements.txt # Dependencies (pip) +{% endif %} +``` + +## Development + +### 1. Customize Message Handlers +- Modify the handlers in `acp.py` to implement your agent's logic +- Add your own tools and capabilities +- Implement custom response generation + +### 2. Test Your Agent with the Development Notebook +Use the included `dev.ipynb` Jupyter notebook to test your agent interactively: + +```bash +# Start Jupyter notebook (make sure you have jupyter installed) +jupyter notebook dev.ipynb + +# Or use VS Code to open the notebook directly +code dev.ipynb +``` + +The notebook includes: +- **Setup**: Connect to your local AgentEx backend +- **Non-streaming tests**: Send messages and get complete responses +- **Streaming tests**: Test real-time streaming responses +- **Task management**: Optional task creation and management + +The notebook automatically uses your agent name (`{{ agent_name }}`) and provides examples for both streaming and non-streaming message handling. + +### 3. Manage Dependencies + +{% if use_uv %} +You chose **uv** for package management. Here's how to work with dependencies: + +```bash +# Add new dependencies +agentex uv add requests openai anthropic + +# Install/sync dependencies +agentex uv sync + +# Run commands with uv +uv run agentex agents run --manifest manifest.yaml +``` + +**Benefits of uv:** +- Faster dependency resolution and installation +- Better dependency isolation +- Modern Python packaging standards + +{% else %} +You chose **pip** for package management. Here's how to work with dependencies: + +```bash +# Edit requirements.txt manually to add dependencies +echo "requests" >> requirements.txt +echo "openai" >> requirements.txt + +# Install dependencies +pip install -r requirements.txt +``` + +**Benefits of pip:** +- Familiar workflow for most Python developers +- Simple requirements.txt management +- Wide compatibility +{% endif %} + +### 4. Configure Credentials +Options: +1. Add any required credentials to your manifest.yaml via the `env` section +2. Export them in your shell: `export LITELLM_API_KEY=...` +3. For local development, create a `.env.local` file in the project directory + +## Local Development + +### 1. Start the Agentex Backend +```bash +# Navigate to the backend directory +cd agentex + +# Start all services using Docker Compose +make dev + +# Optional: In a separate terminal, use lazydocker for a better UI (everything should say "healthy") +lzd +``` + +### 3. Run Your Agent +```bash +# From this directory +export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml +``` + +### 4. Interact with Your Agent + +**Option 1: Web UI (Recommended)** +```bash +# Start the local web interface +cd agentex-web +make dev + +# Then open http://localhost:3000 in your browser to chat with your agent +``` + +**Option 2: CLI (Deprecated)** +```bash +# Submit a task via CLI +agentex tasks submit --agent {{ agent_name }} --task "Your task here" +``` + +## Development Tips + +### Environment Variables +- Set environment variables in project/.env for any required credentials +- Or configure them in the manifest.yaml under the `env` section +- The `.env` file is automatically loaded in development mode + +### Local Testing +- Use `export ENVIRONMENT=development` before running your agent +- This enables local service discovery and debugging features +- Your agent will automatically connect to locally running services + +### Sync ACP Considerations +- Responses must be immediate (no long-running operations) +- Use streaming for longer responses +- Keep processing lightweight and fast +- Consider caching for frequently accessed data + +### Debugging +- Check agent logs in the terminal where you ran the agent +- Use the web UI to inspect task history and responses +- Monitor backend services with `lzd` (LazyDocker) +- Test response times and optimize for speed + +### To build the agent Docker image locally (normally not necessary): + +1. Build the agent image: +```bash +agentex agents build --manifest manifest.yaml +``` +{% if use_uv %} +```bash +# Build with uv +agentex agents build --manifest manifest.yaml --push +``` +{% else %} +```bash +# Build with pip +agentex agents build --manifest manifest.yaml --push +``` +{% endif %} + + +## Advanced Features + +### Streaming Responses +Handle long responses with streaming: + +```python +# In project/acp.py +@acp.on_message_send +async def handle_message_send(params: SendMessageParams): + # For streaming responses + async def stream_response(): + for chunk in generate_response_chunks(): + yield TaskMessageUpdate( + content=chunk, + is_complete=False + ) + yield TaskMessageUpdate( + content="", + is_complete=True + ) + + return stream_response() +``` + +### Custom Response Logic +Add sophisticated response generation: + +```python +# In project/acp.py +@acp.on_message_send +async def handle_message_send(params: SendMessageParams): + # Analyze input + content = params.content + if not isinstance(content, TextContent): + return TextContent(author="agent", content="Sorry, I can only handle text messages right now.") + user_message = content.content + + # Generate response + response = await generate_intelligent_response(user_message) + + return TextContent( + author=MessageAuthor.AGENT, + content=response + ) +``` + +### Integration with External Services +{% if use_uv %} +```bash +# Add service clients +agentex uv add httpx requests-oauthlib + +# Add AI/ML libraries +agentex uv add openai anthropic transformers + +# Add fast processing libraries +agentex uv add numpy pandas +``` +{% else %} +```bash +# Add to requirements.txt +echo "httpx" >> requirements.txt +echo "openai" >> requirements.txt +echo "numpy" >> requirements.txt +pip install -r requirements.txt +``` +{% endif %} + +## Troubleshooting + +### Common Issues + +1. **Agent not appearing in web UI** + - Check if agent is running on port 8000 + - Verify `ENVIRONMENT=development` is set + - Check agent logs for errors + +2. **Slow response times** + - Profile your message handling code + - Consider caching expensive operations + - Optimize database queries and API calls + +3. **Dependency issues** +{% if use_uv %} + - Run `agentex uv sync` to ensure all dependencies are installed +{% else %} + - Run `pip install -r requirements.txt` + - Check if all dependencies are correctly listed in requirements.txt +{% endif %} + +4. **Port conflicts** + - Check if another service is using port 8000 + - Use `lsof -i :8000` to find conflicting processes + +Happy building with Sync ACP! 🚀⚡ \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/dev.ipynb.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/dev.ipynb.j2 new file mode 100644 index 000000000..d8c10a65a --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-pydantic-ai/dev.ipynb.j2 @@ -0,0 +1,167 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"{{ agent_name }}\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [], + "source": [ + "# # (Optional) Create a new task. If you don't create a new task, each message will be sent to a new task. The server will create the task for you.\n", + "\n", + "# import uuid\n", + "\n", + "# TASK_ID = str(uuid.uuid4())[:8]\n", + "\n", + "# rpc_response = client.agents.rpc_by_name(\n", + "# agent_name=AGENT_NAME,\n", + "# method=\"task/create\",\n", + "# params={\n", + "# \"name\": f\"{TASK_ID}-task\",\n", + "# \"params\": {}\n", + "# }\n", + "# )\n", + "\n", + "# task = rpc_response.result\n", + "# print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b03b0d37", + "metadata": {}, + "outputs": [], + "source": [ + "# Test non streaming response\n", + "from agentex.types import TextContent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_message(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": False\n", + " }\n", + ")\n", + "\n", + "if not rpc_response or not rpc_response.result:\n", + " raise ValueError(\"No result in response\")\n", + "\n", + "# Extract and print just the text content from the response\n", + "for task_message in rpc_response.result:\n", + " content = task_message.content\n", + " if isinstance(content, TextContent):\n", + " text = content.content\n", + " print(text)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "79688331", + "metadata": {}, + "outputs": [], + "source": [ + "# Test streaming response\n", + "from agentex.types.task_message_update import StreamTaskMessageDelta, StreamTaskMessageFull\n", + "from agentex.types.text_delta import TextDelta\n", + "\n", + "\n", + "# The result object of message/send will be a TaskMessageUpdate which is a union of the following types:\n", + "# - StreamTaskMessageStart: \n", + "# - An indicator that a streaming message was started, doesn't contain any useful content\n", + "# - StreamTaskMessageDelta: \n", + "# - A delta of a streaming message, contains the text delta to aggregate\n", + "# - StreamTaskMessageDone: \n", + "# - An indicator that a streaming message was done, doesn't contain any useful content\n", + "# - StreamTaskMessageFull: \n", + "# - A non-streaming message, there is nothing to aggregate, since this contains the full message, not deltas\n", + "\n", + "# Whenn processing StreamTaskMessageDelta, if you are expecting more than TextDeltas, such as DataDelta, ToolRequestDelta, or ToolResponseDelta, you can process them as well\n", + "# Whenn processing StreamTaskMessageFull, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "for agent_rpc_response_chunk in client.agents.send_message_stream(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": True\n", + " }\n", + "):\n", + " # We know that the result of the message/send when stream is set to True will be a TaskMessageUpdate\n", + " task_message_update = agent_rpc_response_chunk.result\n", + " # Print oly the text deltas as they arrive or any full messages\n", + " if isinstance(task_message_update, StreamTaskMessageDelta):\n", + " delta = task_message_update.delta\n", + " if isinstance(delta, TextDelta):\n", + " print(delta.text_delta, end=\"\", flush=True)\n", + " else:\n", + " print(f\"Found non-text {type(task_message)} object in streaming message.\")\n", + " elif isinstance(task_message_update, StreamTaskMessageFull):\n", + " content = task_message_update.content\n", + " if isinstance(content, TextContent):\n", + " print(content.content)\n", + " else:\n", + " print(f\"Found non-text {type(task_message)} object in full message.\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c5e7e042", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/environments.yaml.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/environments.yaml.j2 new file mode 100644 index 000000000..73924abdd --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-pydantic-ai/environments.yaml.j2 @@ -0,0 +1,53 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-{{agent_name}}" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/manifest.yaml.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/manifest.yaml.j2 new file mode 100644 index 000000000..875fcc5e0 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-pydantic-ai/manifest.yaml.j2 @@ -0,0 +1,115 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../ # Keep this as the default root + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - {{ project_path_from_build_root }} + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: {{ project_path_from_build_root }}/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: {{ project_path_from_build_root }}/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + +# Agent Configuration +# ----------------- +agent: + acp_type: sync + # Unique name for your agent + # Used for task routing and monitoring + name: {{ agent_name }} + + # Description of what your agent does + # Helps with documentation and discovery + description: {{ description | tojson }} + + # Temporal workflow configuration + # Set enabled: true to use Temporal workflows for long-running tasks + temporal: + enabled: false + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + credentials: [] # Update with your credentials + # - env_var_name: LITELLM_API_KEY + # secret_name: litellm-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + env: {} # Update with your environment variables + # LITELLM_API_KEY: "" + # OPENAI_BASE_URL: "" + # OPENAI_ORG_ID: "" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: [] # Update with your image pull secret names + # - name: my-registry-secret + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific environments (environments.yaml) + global: + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/project/acp.py.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/project/acp.py.j2 new file mode 100644 index 000000000..1a3c6f0a9 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-pydantic-ai/project/acp.py.j2 @@ -0,0 +1,98 @@ +"""ACP (Agent Communication Protocol) handler for {{ agent_name }}. + +API layer — owns the agent lifecycle and streams tokens and tool calls +from the Pydantic AI agent to the Agentex frontend. Wraps each message in +an Agentex tracing span so the per-message turn (and any tool calls +underneath it) show up in the AgentEx UI / SGP. +""" + +from __future__ import annotations + +import os +from typing import AsyncGenerator + +from dotenv import load_dotenv + +load_dotenv() + +from project.agent import MODEL_NAME, create_agent + +import agentex.lib.adk as adk +from agentex.protocol.acp import SendMessageParams +from agentex.lib.core.harness import UnifiedEmitter +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.types.task_message_update import TaskMessageUpdate +from agentex.types.task_message_content import TaskMessageContent +from agentex.lib.adk import PydanticAITurn +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config + +logger = make_logger(__name__) + +# Register the SGP tracing exporter. Spans also reach the AgentEx backend +# (and surface in the per-task spans dropdown) via the default Agentex +# processor that's lazy-initialised on first span. +SGP_API_KEY = os.environ.get("SGP_API_KEY", "") +SGP_ACCOUNT_ID = os.environ.get("SGP_ACCOUNT_ID", "") +if SGP_API_KEY and SGP_ACCOUNT_ID: + add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=SGP_API_KEY, + sgp_account_id=SGP_ACCOUNT_ID, + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) + ) + +acp = FastACP.create(acp_type="sync") + +# Lazy-initialised agent instance so the Pydantic AI Agent is constructed +# inside the running event loop on the first request, not at import time. +_agent = None + + +def get_agent(): + """Return the cached Pydantic AI agent, creating it on first use.""" + global _agent + if _agent is None: + _agent = create_agent() + return _agent + + +@acp.on_message_send +async def handle_message_send( + params: SendMessageParams, +) -> TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]: + """Handle each incoming user message, streaming tokens and tool calls back.""" + agent = get_agent() + task_id = params.task.id + + content = params.content + if not isinstance(content, TextContent): + logger.warning("Ignoring non-text message content (type=%s)", getattr(content, "type", "?")) + return + + user_message = content.content + logger.info(f"Processing message for task {task_id}") + + # Open a per-message turn span. Tool calls below nest underneath this + # span via the emitter's parent_span_id wiring. + async with adk.tracing.span( + trace_id=task_id, + task_id=task_id, + name="message", + input={"message": user_message}, + data={"__span_type__": "AGENT_WORKFLOW"}, + ) as turn_span: + # Construct the UnifiedEmitter from the ACP/streaming context so tracing + # is automatic: tool spans nest under this turn's span. + emitter = UnifiedEmitter( + task_id=task_id, + trace_id=task_id, + parent_span_id=turn_span.id if turn_span else None, + ) + async with agent.run_stream_events(user_message) as stream: + turn = PydanticAITurn(stream, model=MODEL_NAME) + async for ev in emitter.yield_turn(turn): + yield ev diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/project/agent.py.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/project/agent.py.j2 new file mode 100644 index 000000000..b5b43f7ff --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-pydantic-ai/project/agent.py.j2 @@ -0,0 +1,42 @@ +"""Pydantic AI agent definition for {{ agent_name }}. + +The Agent is the boundary between this module and the API layer (acp.py). +Pydantic AI handles its own tool-call loop internally — no graph required. +""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic_ai import Agent +from project.tools import get_weather + +# Swap this for any Pydantic AI-supported model identifier +# (e.g. "anthropic:claude-3-5-sonnet-latest", "openai:gpt-4o"). +MODEL_NAME = "openai:gpt-4o-mini" + +SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. + +Current date and time: {timestamp} + +Guidelines: +- Be concise and helpful +- Use tools when they would help answer the user's question +- If you're unsure, ask clarifying questions +- Always provide accurate information +""" + + +def create_agent() -> Agent: + """Build and return the Pydantic AI agent with tools registered.""" + agent = Agent( + MODEL_NAME, + system_prompt=SYSTEM_PROMPT.format( + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + ), + ) + + # Register additional tools by adding more `agent.tool_plain(...)` calls. + agent.tool_plain(get_weather) + + return agent diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/project/tools.py.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/project/tools.py.j2 new file mode 100644 index 000000000..bab87942a --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-pydantic-ai/project/tools.py.j2 @@ -0,0 +1,20 @@ +"""Tool definitions for the Pydantic AI agent. + +Pydantic AI tools are registered directly on the Agent via decorators +(see project.agent). This module hosts the bare functions so they're +easy to unit-test in isolation. +""" + +from __future__ import annotations + + +def get_weather(city: str) -> str: + """Get the current weather for a city. + + Args: + city: The name of the city to get weather for. + + Returns: + A string describing the weather conditions. + """ + return f"The weather in {city} is sunny and 72°F" diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/pyproject.toml.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/pyproject.toml.j2 new file mode 100644 index 000000000..e3c57647f --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-pydantic-ai/pyproject.toml.j2 @@ -0,0 +1,33 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ project_name }}" +version = "0.1.0" +description = "{{ description }}" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "pydantic-ai-slim[openai]>=1.0,<2", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/requirements.txt.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/requirements.txt.j2 new file mode 100644 index 000000000..5a812a218 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-pydantic-ai/requirements.txt.j2 @@ -0,0 +1,8 @@ +# Install agentex-sdk from local path +agentex-sdk + +# Scale GenAI Platform Python SDK +scale-gp + +# Pydantic AI agent framework +pydantic-ai-slim[openai]>=1.0,<2 diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/test_agent.py.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/test_agent.py.j2 new file mode 100644 index 000000000..7de4684f4 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-pydantic-ai/test_agent.py.j2 @@ -0,0 +1,70 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming message sending +- Streaming message sending +- Task creation via RPC + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: {{ agent_name }}) +""" + +import os +import pytest +from agentex import Agentex + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "{{ agent_name }}") + + +@pytest.fixture +def client(): + """Create an AgentEx client instance for testing.""" + return Agentex(base_url=AGENTEX_API_BASE_URL) + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest.fixture +def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingMessages: + """Test non-streaming message sending.""" + + def test_send_message(self, client: Agentex, _agent_name: str): + """Test sending a message and receiving a response.""" + # TODO: Fill in the test based on what data your agent is expected to handle + ... + + +class TestStreamingMessages: + """Test streaming message sending.""" + + def test_send_stream_message(self, client: Agentex, _agent_name: str): + """Test streaming a message and aggregating deltas.""" + # TODO: Fill in the test based on what data your agent is expected to handle + ... + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/src/agentex/lib/cli/templates/sync/.dockerignore.j2 b/src/agentex/lib/cli/templates/sync/.dockerignore.j2 new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/src/agentex/lib/cli/templates/sync/.dockerignore.j2 @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/src/agentex/lib/cli/templates/sync/.env.example.j2 b/src/agentex/lib/cli/templates/sync/.env.example.j2 new file mode 100644 index 000000000..015f49ef7 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync/.env.example.j2 @@ -0,0 +1,13 @@ +# {{ agent_name }} - Environment Variables +# Copy this file to .env and fill in the values + +# API key for your LLM provider +LITELLM_API_KEY= + +# LLM base URL (optional - override to use a different provider) +# OPENAI_BASE_URL= + +# SGP Configuration (optional - for tracing) +# SGP_API_KEY= +# SGP_ACCOUNT_ID= +# SGP_CLIENT_BASE_URL= diff --git a/src/agentex/lib/cli/templates/sync/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync/Dockerfile-uv.j2 new file mode 100644 index 000000000..dd3035f7b --- /dev/null +++ b/src/agentex/lib/cli/templates/sync/Dockerfile-uv.j2 @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +ENV UV_HTTP_TIMEOUT=1000 + +WORKDIR /app/{{ project_path_from_build_root }} + +# Copy dependency files for layer caching +COPY {{ project_path_from_build_root }}/pyproject.toml ./ + +# Install dependencies (without project itself, for layer caching) +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-install-project --no-dev + +# Copy the project code +COPY {{ project_path_from_build_root }}/project ./project + +# Install the project +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync/Dockerfile.j2 new file mode 100644 index 000000000..4d9f41d45 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync/Dockerfile.j2 @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + node \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy just the requirements file to optimize caching +COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt + +WORKDIR /app/{{ project_path_from_build_root }} + +# Install the required Python packages +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 + + +# Set environment variables +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync/README.md.j2 b/src/agentex/lib/cli/templates/sync/README.md.j2 new file mode 100644 index 000000000..7711969cd --- /dev/null +++ b/src/agentex/lib/cli/templates/sync/README.md.j2 @@ -0,0 +1,316 @@ +# {{ agent_name }} - AgentEx Sync ACP Template + +This is a starter template for building synchronous agents with the AgentEx framework. It provides a basic implementation of the Agent 2 Client Protocol (ACP) with immediate response capabilities to help you get started quickly. + +## What You'll Learn + +- **Tasks**: A task is a grouping mechanism for related messages. Think of it as a conversation thread or a session. +- **Messages**: Messages are communication objects within a task. They can contain text, data, or instructions. +- **Sync ACP**: Synchronous Agent Communication Protocol that requires immediate responses +- **Message Handling**: How to process and respond to messages in real-time + +## Running the Agent + +1. Run the agent locally: +```bash +agentex agents run --manifest manifest.yaml +``` + +The agent will start on port 8000 and respond immediately to any messages it receives. + +## What's Inside + +This template: +- Sets up a basic sync ACP server +- Handles incoming messages with immediate responses +- Provides a foundation for building real-time agents +- Can include streaming support for long responses + +## Next Steps + +For more advanced agent development, check out the AgentEx tutorials: + +- **Tutorials 00-08**: Learn about building synchronous agents with ACP +- **Tutorials 09-10**: Learn how to use Temporal to power asynchronous agents + - Tutorial 09: Basic Temporal workflow setup + - Tutorial 10: Advanced Temporal patterns and best practices + +These tutorials will help you understand: +- How to handle long-running tasks +- Implementing state machines +- Managing complex workflows +- Best practices for async agent development + +## The Manifest File + +The `manifest.yaml` file is your agent's configuration file. It defines: +- How your agent should be built and packaged +- What files are included in your agent's Docker image +- Your agent's name and description +- Local development settings (like the port your agent runs on) + +This file is essential for both local development and deployment of your agent. + +## Project Structure + +``` +{{ project_name }}/ +├── project/ # Your agent's code +│ ├── __init__.py +│ └── acp.py # ACP server and event handlers +├── Dockerfile # Container definition +├── manifest.yaml # Deployment config +├── dev.ipynb # Development notebook for testing +{% if use_uv %} +└── pyproject.toml # Dependencies (uv) +{% else %} +└── requirements.txt # Dependencies (pip) +{% endif %} +``` + +## Development + +### 1. Customize Message Handlers +- Modify the handlers in `acp.py` to implement your agent's logic +- Add your own tools and capabilities +- Implement custom response generation + +### 2. Test Your Agent with the Development Notebook +Use the included `dev.ipynb` Jupyter notebook to test your agent interactively: + +```bash +# Start Jupyter notebook (make sure you have jupyter installed) +jupyter notebook dev.ipynb + +# Or use VS Code to open the notebook directly +code dev.ipynb +``` + +The notebook includes: +- **Setup**: Connect to your local AgentEx backend +- **Non-streaming tests**: Send messages and get complete responses +- **Streaming tests**: Test real-time streaming responses +- **Task management**: Optional task creation and management + +The notebook automatically uses your agent name (`{{ agent_name }}`) and provides examples for both streaming and non-streaming message handling. + +### 3. Manage Dependencies + +{% if use_uv %} +You chose **uv** for package management. Here's how to work with dependencies: + +```bash +# Add new dependencies +agentex uv add requests openai anthropic + +# Install/sync dependencies +agentex uv sync + +# Run commands with uv +uv run agentex agents run --manifest manifest.yaml +``` + +**Benefits of uv:** +- Faster dependency resolution and installation +- Better dependency isolation +- Modern Python packaging standards + +{% else %} +You chose **pip** for package management. Here's how to work with dependencies: + +```bash +# Edit requirements.txt manually to add dependencies +echo "requests" >> requirements.txt +echo "openai" >> requirements.txt + +# Install dependencies +pip install -r requirements.txt +``` + +**Benefits of pip:** +- Familiar workflow for most Python developers +- Simple requirements.txt management +- Wide compatibility +{% endif %} + +### 4. Configure Credentials +Options: +1. Add any required credentials to your manifest.yaml via the `env` section +2. Export them in your shell: `export LITELLM_API_KEY=...` +3. For local development, create a `.env.local` file in the project directory + +## Local Development + +### 1. Start the Agentex Backend +```bash +# Navigate to the backend directory +cd agentex + +# Start all services using Docker Compose +make dev + +# Optional: In a separate terminal, use lazydocker for a better UI (everything should say "healthy") +lzd +``` + +### 3. Run Your Agent +```bash +# From this directory +export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml +``` + +### 4. Interact with Your Agent + +**Option 1: Web UI (Recommended)** +```bash +# Start the local web interface +cd agentex-web +make dev + +# Then open http://localhost:3000 in your browser to chat with your agent +``` + +**Option 2: CLI (Deprecated)** +```bash +# Submit a task via CLI +agentex tasks submit --agent {{ agent_name }} --task "Your task here" +``` + +## Development Tips + +### Environment Variables +- Set environment variables in project/.env for any required credentials +- Or configure them in the manifest.yaml under the `env` section +- The `.env` file is automatically loaded in development mode + +### Local Testing +- Use `export ENVIRONMENT=development` before running your agent +- This enables local service discovery and debugging features +- Your agent will automatically connect to locally running services + +### Sync ACP Considerations +- Responses must be immediate (no long-running operations) +- Use streaming for longer responses +- Keep processing lightweight and fast +- Consider caching for frequently accessed data + +### Debugging +- Check agent logs in the terminal where you ran the agent +- Use the web UI to inspect task history and responses +- Monitor backend services with `lzd` (LazyDocker) +- Test response times and optimize for speed + +### To build the agent Docker image locally (normally not necessary): + +1. Build the agent image: +```bash +agentex agents build --manifest manifest.yaml +``` +{% if use_uv %} +```bash +# Build with uv +agentex agents build --manifest manifest.yaml --push +``` +{% else %} +```bash +# Build with pip +agentex agents build --manifest manifest.yaml --push +``` +{% endif %} + + +## Advanced Features + +### Streaming Responses +Handle long responses with streaming: + +```python +# In project/acp.py +@acp.on_message_send +async def handle_message_send(params: SendMessageParams): + # For streaming responses + async def stream_response(): + for chunk in generate_response_chunks(): + yield TaskMessageUpdate( + content=chunk, + is_complete=False + ) + yield TaskMessageUpdate( + content="", + is_complete=True + ) + + return stream_response() +``` + +### Custom Response Logic +Add sophisticated response generation: + +```python +# In project/acp.py +@acp.on_message_send +async def handle_message_send(params: SendMessageParams): + # Analyze input + content = params.content + if not isinstance(content, TextContent): + return TextContent(author="agent", content="Sorry, I can only handle text messages right now.") + user_message = content.content + + # Generate response + response = await generate_intelligent_response(user_message) + + return TextContent( + author=MessageAuthor.AGENT, + content=response + ) +``` + +### Integration with External Services +{% if use_uv %} +```bash +# Add service clients +agentex uv add httpx requests-oauthlib + +# Add AI/ML libraries +agentex uv add openai anthropic transformers + +# Add fast processing libraries +agentex uv add numpy pandas +``` +{% else %} +```bash +# Add to requirements.txt +echo "httpx" >> requirements.txt +echo "openai" >> requirements.txt +echo "numpy" >> requirements.txt +pip install -r requirements.txt +``` +{% endif %} + +## Troubleshooting + +### Common Issues + +1. **Agent not appearing in web UI** + - Check if agent is running on port 8000 + - Verify `ENVIRONMENT=development` is set + - Check agent logs for errors + +2. **Slow response times** + - Profile your message handling code + - Consider caching expensive operations + - Optimize database queries and API calls + +3. **Dependency issues** +{% if use_uv %} + - Run `agentex uv sync` to ensure all dependencies are installed +{% else %} + - Run `pip install -r requirements.txt` + - Check if all dependencies are correctly listed in requirements.txt +{% endif %} + +4. **Port conflicts** + - Check if another service is using port 8000 + - Use `lsof -i :8000` to find conflicting processes + +Happy building with Sync ACP! 🚀⚡ \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync/dev.ipynb.j2 b/src/agentex/lib/cli/templates/sync/dev.ipynb.j2 new file mode 100644 index 000000000..d8c10a65a --- /dev/null +++ b/src/agentex/lib/cli/templates/sync/dev.ipynb.j2 @@ -0,0 +1,167 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"{{ agent_name }}\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [], + "source": [ + "# # (Optional) Create a new task. If you don't create a new task, each message will be sent to a new task. The server will create the task for you.\n", + "\n", + "# import uuid\n", + "\n", + "# TASK_ID = str(uuid.uuid4())[:8]\n", + "\n", + "# rpc_response = client.agents.rpc_by_name(\n", + "# agent_name=AGENT_NAME,\n", + "# method=\"task/create\",\n", + "# params={\n", + "# \"name\": f\"{TASK_ID}-task\",\n", + "# \"params\": {}\n", + "# }\n", + "# )\n", + "\n", + "# task = rpc_response.result\n", + "# print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b03b0d37", + "metadata": {}, + "outputs": [], + "source": [ + "# Test non streaming response\n", + "from agentex.types import TextContent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_message(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": False\n", + " }\n", + ")\n", + "\n", + "if not rpc_response or not rpc_response.result:\n", + " raise ValueError(\"No result in response\")\n", + "\n", + "# Extract and print just the text content from the response\n", + "for task_message in rpc_response.result:\n", + " content = task_message.content\n", + " if isinstance(content, TextContent):\n", + " text = content.content\n", + " print(text)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "79688331", + "metadata": {}, + "outputs": [], + "source": [ + "# Test streaming response\n", + "from agentex.types.task_message_update import StreamTaskMessageDelta, StreamTaskMessageFull\n", + "from agentex.types.text_delta import TextDelta\n", + "\n", + "\n", + "# The result object of message/send will be a TaskMessageUpdate which is a union of the following types:\n", + "# - StreamTaskMessageStart: \n", + "# - An indicator that a streaming message was started, doesn't contain any useful content\n", + "# - StreamTaskMessageDelta: \n", + "# - A delta of a streaming message, contains the text delta to aggregate\n", + "# - StreamTaskMessageDone: \n", + "# - An indicator that a streaming message was done, doesn't contain any useful content\n", + "# - StreamTaskMessageFull: \n", + "# - A non-streaming message, there is nothing to aggregate, since this contains the full message, not deltas\n", + "\n", + "# Whenn processing StreamTaskMessageDelta, if you are expecting more than TextDeltas, such as DataDelta, ToolRequestDelta, or ToolResponseDelta, you can process them as well\n", + "# Whenn processing StreamTaskMessageFull, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "for agent_rpc_response_chunk in client.agents.send_message_stream(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": True\n", + " }\n", + "):\n", + " # We know that the result of the message/send when stream is set to True will be a TaskMessageUpdate\n", + " task_message_update = agent_rpc_response_chunk.result\n", + " # Print oly the text deltas as they arrive or any full messages\n", + " if isinstance(task_message_update, StreamTaskMessageDelta):\n", + " delta = task_message_update.delta\n", + " if isinstance(delta, TextDelta):\n", + " print(delta.text_delta, end=\"\", flush=True)\n", + " else:\n", + " print(f\"Found non-text {type(task_message)} object in streaming message.\")\n", + " elif isinstance(task_message_update, StreamTaskMessageFull):\n", + " content = task_message_update.content\n", + " if isinstance(content, TextContent):\n", + " print(content.content)\n", + " else:\n", + " print(f\"Found non-text {type(task_message)} object in full message.\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c5e7e042", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/agentex/lib/cli/templates/sync/environments.yaml.j2 b/src/agentex/lib/cli/templates/sync/environments.yaml.j2 new file mode 100644 index 000000000..73924abdd --- /dev/null +++ b/src/agentex/lib/cli/templates/sync/environments.yaml.j2 @@ -0,0 +1,53 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-{{agent_name}}" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + diff --git a/src/agentex/lib/cli/templates/sync/manifest.yaml.j2 b/src/agentex/lib/cli/templates/sync/manifest.yaml.j2 new file mode 100644 index 000000000..875fcc5e0 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync/manifest.yaml.j2 @@ -0,0 +1,115 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../ # Keep this as the default root + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - {{ project_path_from_build_root }} + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: {{ project_path_from_build_root }}/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: {{ project_path_from_build_root }}/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + +# Agent Configuration +# ----------------- +agent: + acp_type: sync + # Unique name for your agent + # Used for task routing and monitoring + name: {{ agent_name }} + + # Description of what your agent does + # Helps with documentation and discovery + description: {{ description | tojson }} + + # Temporal workflow configuration + # Set enabled: true to use Temporal workflows for long-running tasks + temporal: + enabled: false + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + credentials: [] # Update with your credentials + # - env_var_name: LITELLM_API_KEY + # secret_name: litellm-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + env: {} # Update with your environment variables + # LITELLM_API_KEY: "" + # OPENAI_BASE_URL: "" + # OPENAI_ORG_ID: "" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: [] # Update with your image pull secret names + # - name: my-registry-secret + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific environments (environments.yaml) + global: + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync/project/acp.py.j2 b/src/agentex/lib/cli/templates/sync/project/acp.py.j2 new file mode 100644 index 000000000..d7d6f51d2 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync/project/acp.py.j2 @@ -0,0 +1,32 @@ +from typing import AsyncGenerator, Union +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.protocol.acp import SendMessageParams + +from agentex.types.task_message_update import TaskMessageUpdate +from agentex.types.task_message_content import TaskMessageContent +from agentex.types.text_content import TextContent +from agentex.lib.utils.logging import make_logger + +logger = make_logger(__name__) + + +# Create an ACP server +acp = FastACP.create( + acp_type="sync", +) + +@acp.on_message_send +async def handle_message_send( + params: SendMessageParams +) -> TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]: + """Default message handler with streaming support""" + content = params.content + if not isinstance(content, TextContent): + return TextContent( + author="agent", + content="Sorry, I can only handle text messages right now.", + ) + return TextContent( + author="agent", + content=f"Hello! I've received your message. Here's a generic response, but in future tutorials we'll see how you can get me to intelligently respond to your message. This is what I heard you say: {content.content}", + ) \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync/pyproject.toml.j2 b/src/agentex/lib/cli/templates/sync/pyproject.toml.j2 new file mode 100644 index 000000000..34e04e6a4 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync/pyproject.toml.j2 @@ -0,0 +1,32 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ project_name }}" +version = "0.1.0" +description = "{{ description }}" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/src/agentex/lib/cli/templates/sync/requirements.txt.j2 b/src/agentex/lib/cli/templates/sync/requirements.txt.j2 new file mode 100644 index 000000000..0b8ae19b3 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync/requirements.txt.j2 @@ -0,0 +1,5 @@ +# Install agentex-sdk from local path +agentex-sdk + +# Scale GenAI Platform Python SDK +scale-gp diff --git a/src/agentex/lib/cli/templates/sync/test_agent.py.j2 b/src/agentex/lib/cli/templates/sync/test_agent.py.j2 new file mode 100644 index 000000000..7de4684f4 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync/test_agent.py.j2 @@ -0,0 +1,70 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming message sending +- Streaming message sending +- Task creation via RPC + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: {{ agent_name }}) +""" + +import os +import pytest +from agentex import Agentex + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "{{ agent_name }}") + + +@pytest.fixture +def client(): + """Create an AgentEx client instance for testing.""" + return Agentex(base_url=AGENTEX_API_BASE_URL) + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest.fixture +def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingMessages: + """Test non-streaming message sending.""" + + def test_send_message(self, client: Agentex, _agent_name: str): + """Test sending a message and receiving a response.""" + # TODO: Fill in the test based on what data your agent is expected to handle + ... + + +class TestStreamingMessages: + """Test streaming message sending.""" + + def test_send_stream_message(self, client: Agentex, _agent_name: str): + """Test streaming a message and aggregating deltas.""" + # TODO: Fill in the test based on what data your agent is expected to handle + ... + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/.dockerignore.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/.dockerignore.j2 new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-claude-code/.dockerignore.j2 @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/.env.example.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/.env.example.j2 new file mode 100644 index 000000000..5aff34a60 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-claude-code/.env.example.j2 @@ -0,0 +1,13 @@ +# {{ agent_name }} - Environment Variables +# Copy this file to .env and fill in the values + +# API key for the Claude Code CLI (the `claude` subprocess this agent spawns) +ANTHROPIC_API_KEY= + +# LLM base URL (optional - override to use a different provider) +# OPENAI_BASE_URL= + +# SGP Configuration (optional - for tracing) +# SGP_API_KEY= +# SGP_ACCOUNT_ID= +# SGP_CLIENT_BASE_URL= 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 new file mode 100644 index 000000000..f8746c573 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 @@ -0,0 +1,61 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +# Install the Claude Code CLI: the activity shells out to `claude` on every +# turn, so the binary must be present in the runtime image. +RUN npm install -g @anthropic-ai/claude-code + +# Install tctl (Temporal CLI) +RUN ARCH="$(uname -m)" && \ + case "$ARCH" in x86_64) TCTL_ARCH=amd64 ;; aarch64|arm64) TCTL_ARCH=arm64 ;; *) TCTL_ARCH=amd64 ;; esac && \ + curl -L "https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_${TCTL_ARCH}.tar.gz" -o /tmp/tctl.tar.gz && \ + tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ + chmod +x /usr/local/bin/tctl && \ + rm /tmp/tctl.tar.gz + +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +ENV UV_HTTP_TIMEOUT=1000 + +WORKDIR /app/{{ project_path_from_build_root }} + +# Copy dependency files for layer caching +COPY {{ project_path_from_build_root }}/pyproject.toml ./ + +# Install dependencies (without project itself, for layer caching) +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-install-project --no-dev + +# Copy the project code +COPY {{ project_path_from_build_root }}/project ./project + +# Install the project +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" + +# Run the ACP server using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 new file mode 100644 index 000000000..225863607 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 @@ -0,0 +1,54 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install the Claude Code CLI: the activity shells out to `claude` on every +# turn, so the binary must be present in the runtime image. +RUN npm install -g @anthropic-ai/claude-code + +# Install tctl (Temporal CLI) +RUN ARCH="$(uname -m)" && \ + case "$ARCH" in x86_64) TCTL_ARCH=amd64 ;; aarch64|arm64) TCTL_ARCH=arm64 ;; *) TCTL_ARCH=amd64 ;; esac && \ + curl -L "https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_${TCTL_ARCH}.tar.gz" -o /tmp/tctl.tar.gz && \ + tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ + chmod +x /usr/local/bin/tctl && \ + rm /tmp/tctl.tar.gz + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy just the requirements file to optimize caching +COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt + +WORKDIR /app/{{ project_path_from_build_root }} + +# Install the required Python packages +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 + +# Run the ACP server using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/README.md.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/README.md.j2 new file mode 100644 index 000000000..35ac019b5 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-claude-code/README.md.j2 @@ -0,0 +1,73 @@ +# {{ agent_name }} — AgentEx Temporal + Claude Code + +This template builds a **Temporal-durable** agent that drives the **Claude Code +CLI** through the unified harness surface on AgentEx: +- A Temporal workflow holds conversation state (the Claude Code `session_id`) + durably across worker crashes +- Each turn delegates to the `run_claude_code_turn` activity, which spawns the + CLI (subprocess I/O is not permitted on the workflow event loop) +- The activity wraps the CLI's stdout stream in a `ClaudeCodeTurn` and delivers + canonical `StreamTaskMessage*` events via `UnifiedEmitter.auto_send_turn` +- Tracing integration to SGP / AgentEx + +## Prerequisites + +- The `claude` CLI installed and on your `PATH` +- An `ANTHROPIC_API_KEY` (or equivalent credential) in your environment +- A running Temporal service (provided automatically by the local dev stack) + +## Running the Agent + +```bash +agentex agents run --manifest manifest.yaml +``` + +This starts both the ACP HTTP server and the Temporal worker. + +## Project Structure + +``` +{{ project_name }}/ +├── project/ +│ ├── __init__.py +│ ├── acp.py # Thin ACP server; FastACP auto-wires to the workflow +│ ├── workflow.py # Temporal workflow (durable conversation state) +│ ├── activities.py # run_claude_code_turn activity (CLI subprocess) +│ └── run_worker.py # Temporal worker entrypoint +├── Dockerfile +├── manifest.yaml +├── dev.ipynb +{% if use_uv %} +└── pyproject.toml +{% else %} +└── requirements.txt +{% endif %} +``` + +## Key Concepts + +### Subprocess must run in an activity +Temporal runs workflow + signal-handler bodies on a deterministic sandbox event +loop that does not implement `subprocess_exec`. The workflow therefore delegates +each turn to the `run_claude_code_turn` activity, which also gains Temporal's +retry + timeout guarantees. + +### The unified harness surface +`ClaudeCodeTurn` + `UnifiedEmitter` are the unified harness surface. The turn +normalizes CLI output into canonical AgentEx events; the emitter traces and +delivers them. + +## Development + +### 1. Customize the subprocess +Edit `_spawn_claude` in `project/activities.py` to change the CLI flags, working +directory, or how the prompt is delivered. + +### 2. Configure Credentials +Set your credentials via `manifest.yaml`, an exported environment variable, or a +`.env` file in the project directory. + +### 3. Run Locally +```bash +export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml +``` diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/dev.ipynb.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/dev.ipynb.j2 new file mode 100644 index 000000000..d3a68303f --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-claude-code/dev.ipynb.j2 @@ -0,0 +1,126 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"{{ agent_name }}\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [], + "source": [ + "# (REQUIRED) Create a new task. For Async agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b03b0d37", + "metadata": {}, + "outputs": [], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a6927cc0", + "metadata": {}, + "outputs": [], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4864e354", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/environments.yaml.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/environments.yaml.j2 new file mode 100644 index 000000000..a3df5e228 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-claude-code/environments.yaml.j2 @@ -0,0 +1,64 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-{{agent_name}}" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + # This is used to override the global helm values.yaml file in the agentex-agent helm charts + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + temporal-worker: + enabled: true + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/manifest.yaml.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/manifest.yaml.j2 new file mode 100644 index 000000000..9aa2b2b2f --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-claude-code/manifest.yaml.j2 @@ -0,0 +1,142 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../ # Keep this as the default root + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - {{ project_path_from_build_root }} + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: {{ project_path_from_build_root }}/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: {{ project_path_from_build_root }}/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + # Path to temporal worker file + # Examples: + # project/run_worker.py (standard) + # workers/temporal.py (custom structure) + # ../shared/worker.py (shared across projects) + worker: project/run_worker.py + + +# Agent Configuration +# ----------------- +agent: + # Type of agent - either sync or async + acp_type: async + + # Unique name for your agent + # Used for task routing and monitoring + name: {{ agent_name }} + + # Description of what your agent does + # Helps with documentation and discovery + description: {{ description | tojson }} + + # Temporal workflow configuration + # This enables your agent to run as a Temporal workflow for long-running tasks + temporal: + enabled: true + workflows: + # Name of the workflow class + # Must match the @workflow.defn name in your workflow.py + - name: {{ workflow_name }} + + # Queue name for task distribution + # Used by Temporal to route tasks to your agent + # Convention: _task_queue + queue_name: {{ queue_name }} + + # Optional: Health check port for temporal worker + # Defaults to 80 if not specified + # health_check_port: 80 + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + credentials: + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + # The Claude Code CLI spawned in project/activities.py authenticates with + # ANTHROPIC_API_KEY; without it every turn fails with a CLI auth error. + - env_var_name: ANTHROPIC_API_KEY + secret_name: anthropic-api-key + secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on. ANTHROPIC_API_KEY is supplied via the credential + # mapping above (deploy) or your local .env (load_dotenv). Do NOT set it to an + # empty string here — that would shadow the real key at runtime. + env: {} + # ANTHROPIC_API_KEY: "" # uncomment only to hardcode for local runs + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: [] # Update with your image pull secret name + # - name: my-registry-secret + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific environments (environments.yaml) + global: + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/project/acp.py.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/project/acp.py.j2 new file mode 100644 index 000000000..0515efeeb --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-claude-code/project/acp.py.j2 @@ -0,0 +1,31 @@ +"""ACP server for {{ agent_name }} — a Temporal Claude Code agent. + +This file is intentionally thin. When ``acp_type="async"`` is combined +with ``TemporalACPConfig``, FastACP auto-wires: + + HTTP task/create -> @workflow.run on the workflow class + HTTP task/event/send -> @workflow.signal(SignalName.RECEIVE_EVENT) + HTTP task/cancel -> workflow cancellation via the Temporal client + +The actual agent code lives in ``project/workflow.py`` and is executed by +the Temporal worker (``project/run_worker.py``), not by this HTTP process. +""" + +from __future__ import annotations + +import os + +from dotenv import load_dotenv + +load_dotenv() + +from agentex.lib.types.fastacp import TemporalACPConfig +from agentex.lib.sdk.fastacp.fastacp import FastACP + +acp = FastACP.create( + acp_type="async", + config=TemporalACPConfig( + type="temporal", + temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), + ), +) diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/project/activities.py.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/project/activities.py.j2 new file mode 100644 index 000000000..94055c7df --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-claude-code/project/activities.py.j2 @@ -0,0 +1,155 @@ +"""Temporal activity for {{ agent_name }} — Claude Code harness. + +Subprocess spawning (and any other I/O) must run inside a Temporal *activity*, +not in workflow code. Temporal runs workflow + signal-handler bodies on a +deterministic sandbox event loop that does not implement ``subprocess_exec`` +(or threads / sockets), so spawning the CLI directly in the signal handler +raises ``NotImplementedError``. This activity runs the Claude Code CLI, drives +the ``ClaudeCodeTurn`` through ``UnifiedEmitter.auto_send_turn`` (the async +Redis push path), and returns the turn result to the workflow. + +The ``_spawn_claude`` async generator is an injectable seam: offline tests +can provide a fake that yields pre-recorded stdout lines so no real CLI runs. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, AsyncIterator +from datetime import datetime +from collections import deque + +from temporalio import activity + +from agentex.lib.adk import ClaudeCodeTurn +from agentex.lib.core.harness import UnifiedEmitter +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.model_utils import BaseModel + +logger = make_logger(__name__) + +RUN_CLAUDE_CODE_TURN_ACTIVITY = "run_claude_code_turn" + + +class RunClaudeCodeTurnParams(BaseModel): + """Arguments for one Claude Code turn run inside an activity.""" + + task_id: str + prompt: str + trace_id: str | None = None + parent_span_id: str | None = None + session_id: str | None = None + created_at: datetime | None = None + + +class RunClaudeCodeTurnResult(BaseModel): + """Result returned from the activity to the workflow.""" + + final_text: str + session_id: str | None = None + + +async def _spawn_claude(prompt: str, session_id: str | None = None) -> AsyncIterator[str]: + """Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines. + + Pass ``session_id`` to resume a previous Claude Code session (multi-turn + memory via ``-r ``). + + Injectable seam: tests can monkeypatch this with a fake async iterator so no + real CLI invocation is needed offline. + """ + cmd = [ + "claude", + "-p", + "--output-format", + "stream-json", + "--verbose", + ] + if session_id: + cmd.extend(["-r", session_id]) + + proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + assert proc.stdout is not None + assert proc.stdin is not None + + proc.stdin.write(prompt.encode()) + await proc.stdin.drain() + proc.stdin.close() + + # Drain stderr concurrently. With --verbose, Claude Code can write enough to + # stderr to fill the OS pipe buffer; if we only read stdout, the CLI blocks + # on its stderr write while we block reading stdout — a deadlock. A + # background task keeps stderr flowing so stdout never stalls. We keep a + # bounded tail so a non-zero exit can be surfaced with context instead of + # silently completing the turn. + stderr_tail: deque[str] = deque(maxlen=20) + + async def _drain_stderr() -> None: + assert proc.stderr is not None + async for raw in proc.stderr: + text = raw.decode("utf-8", errors="replace").rstrip() + if text: + stderr_tail.append(text) + + stderr_task = asyncio.create_task(_drain_stderr()) + + try: + buffer = "" + async for chunk in proc.stdout: + buffer += chunk.decode("utf-8", errors="replace") + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if line: + yield line + + if buffer.strip(): + yield buffer.strip() + + await proc.wait() + if proc.returncode: + # The CLI failed (missing binary/auth, bad command). Raise so the + # activity (and turn) surfaces as failed instead of completing with + # no output. Temporal will apply the activity's retry policy. + tail = "\n".join(stderr_tail) + raise RuntimeError( + f"claude CLI exited with status {proc.returncode}:\n{tail}" + ) + finally: + # Release the subprocess and stderr drain task even if the consumer + # abandons the generator early (task cancellation / client disconnect): + # cancel the drain task and terminate+reap the process if it is still + # running, so neither is leaked. + stderr_task.cancel() + try: + await stderr_task + except asyncio.CancelledError: + pass + if proc.returncode is None: + try: + proc.terminate() + except ProcessLookupError: + pass + await proc.wait() + + +@activity.defn(name=RUN_CLAUDE_CODE_TURN_ACTIVITY) +async def run_claude_code_turn(params: RunClaudeCodeTurnParams) -> dict[str, Any]: + """Run one Claude Code turn end-to-end and stream events to the task. + + Runs in an activity (real asyncio loop) so subprocess I/O is permitted. + """ + emitter = UnifiedEmitter( + task_id=params.task_id, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) + turn = ClaudeCodeTurn(_spawn_claude(params.prompt, session_id=params.session_id)) + result = await emitter.auto_send_turn(turn, created_at=params.created_at) + + return RunClaudeCodeTurnResult(final_text=result.final_text, session_id=turn.session_id).model_dump() diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/project/run_worker.py.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/project/run_worker.py.j2 new file mode 100644 index 000000000..354326b9d --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-claude-code/project/run_worker.py.j2 @@ -0,0 +1,41 @@ +"""Temporal worker for {{ agent_name }} — Claude Code harness. + +Run as a separate long-lived process alongside the ACP HTTP server. The +worker polls Temporal for workflow + activity tasks and executes them. + +The Claude Code CLI subprocess runs in the ``run_claude_code_turn`` activity +(registered below alongside the built-in Agentex activities), because +subprocess I/O is not permitted on the Temporal workflow event loop. +""" + +import asyncio + +from project.workflow import {{ workflow_class }} +from project.activities import run_claude_code_turn +from agentex.lib.utils.debug import setup_debug_if_enabled +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.activities import get_all_activities +from agentex.lib.core.temporal.workers.worker import AgentexWorker + +environment_variables = EnvironmentVariables.refresh() +logger = make_logger(__name__) + + +async def main(): + setup_debug_if_enabled() + + task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE + if task_queue_name is None: + raise ValueError("WORKFLOW_TASK_QUEUE is not set") + + worker = AgentexWorker(task_queue=task_queue_name) + + await worker.run( + activities=[run_claude_code_turn, *get_all_activities()], + workflow={{ workflow_class }}, + ) + + +if __name__ == "__main__": + asyncio.run(main()) 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 new file mode 100644 index 000000000..8191ad80f --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-claude-code/project/workflow.py.j2 @@ -0,0 +1,148 @@ +"""Temporal workflow for {{ agent_name }} — Claude Code harness. + +Holds conversation state (session_id for multi-turn resume) durably across +crashes. Each user message triggers ``on_task_event_send``, which delegates the +turn to the ``run_claude_code_turn`` activity. The activity spawns the Claude +Code CLI, wraps its stdout in ``ClaudeCodeTurn``, and delivers the turn via +``UnifiedEmitter.auto_send_turn`` (the async Redis push path). + +Note on subprocess inside Temporal +------------------------------------ +Subprocess (and all other) I/O must run in a Temporal *activity*, never in +workflow code. Temporal runs workflow + signal-handler bodies on a +deterministic sandbox event loop that does not implement ``subprocess_exec`` +(spawning the CLI there raises ``NotImplementedError``). The activity also gets +Temporal's retry + timeout guarantees. +""" + +from __future__ import annotations + +import os +import json +import asyncio +from datetime import timedelta + +from temporalio import workflow + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CreateTaskParams +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.types.workflow import SignalName +from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config + +with workflow.unsafe.imports_passed_through(): + from project.activities import RunClaudeCodeTurnParams, run_claude_code_turn + +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) +) + +environment_variables = EnvironmentVariables.refresh() + +if environment_variables.WORKFLOW_NAME is None: + raise ValueError("Environment variable WORKFLOW_NAME is not set") +if environment_variables.AGENT_NAME is None: + raise ValueError("Environment variable AGENT_NAME is not set") + +logger = make_logger(__name__) + + +@workflow.defn(name=environment_variables.WORKFLOW_NAME) +class {{ workflow_class }}(BaseWorkflow): + """Temporal workflow that runs Claude Code locally for each user message. + + Persists the Claude Code session_id across turns so the CLI can resume + the conversation (``-r ``). Temporal's durable state ensures + the session_id survives worker crashes. + """ + + def __init__(self): + super().__init__(display_name=environment_variables.AGENT_NAME) + self._complete_task = False + self._turn_number = 0 + # Claude Code session_id for multi-turn resume. + self._session_id: str | None = None + # Serialize turns: signal handlers can interleave at await points, so two + # quick messages could both read the same stale _session_id and run + # independent Claude Code sessions. The lock keeps turns sequential and + # preserves conversation continuity. + self._turn_lock = asyncio.Lock() + + @workflow.signal(name=SignalName.RECEIVE_EVENT) + async def on_task_event_send(self, params: SendEventParams) -> None: + """Handle a user message: spawn Claude Code and push events to the task stream.""" + async with self._turn_lock: + task_id = params.task.id + content = params.event.content + if not isinstance(content, TextContent): + logger.warning("Ignoring non-text event content (type=%s)", getattr(content, "type", "?")) + return + self._turn_number += 1 + prompt = content.content + logger.info("Turn %d for task %s", self._turn_number, task_id) + + await adk.messages.create(task_id=task_id, content=params.event.content) + + async with adk.tracing.span( + trace_id=task_id, + task_id=task_id, + name=f"Turn {self._turn_number}", + input={"message": prompt}, + ) as span: + # Delegate the subprocess turn to an activity: subprocess I/O is not + # permitted on the Temporal workflow event loop. The activity streams + # events to the task and returns the final text + session_id. + # workflow.now() gives a deterministic timestamp under replay. + result = await workflow.execute_activity( + run_claude_code_turn, + RunClaudeCodeTurnParams( + task_id=task_id, + prompt=prompt, + trace_id=task_id, + parent_span_id=span.id if span else None, + session_id=self._session_id, + created_at=workflow.now(), + ), + # Agentic Claude Code runs (multiple tool calls, large codegen) + # can take a while; tune this to your workload. + start_to_close_timeout=timedelta(minutes=30), + ) + + # Capture session_id to enable Claude Code resume on the next turn. + sid = result.get("session_id") + if sid: + self._session_id = sid + + if span: + span.output = {"final_text": result.get("final_text")} + + @workflow.run + async def on_task_create(self, params: CreateTaskParams) -> str: + logger.info("Task created: %s", params.task.id) + + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=( + f"Task initialized with params:\n{json.dumps(params.params, indent=2)}\n" + "Send me a message and I'll run it through Claude Code locally." + ), + ), + ) + + await workflow.wait_condition(lambda: self._complete_task, timeout=None) + return "Task completed" + + @workflow.signal + async def complete_task_signal(self) -> None: + logger.info("Received complete_task signal") + self._complete_task = True diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/pyproject.toml.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/pyproject.toml.j2 new file mode 100644 index 000000000..2c6ec9c2f --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-claude-code/pyproject.toml.j2 @@ -0,0 +1,37 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ project_name }}" +version = "0.1.0" +description = "{{ description }}" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "temporalio>=1.18.2", + "python-dotenv>=1.0,<2", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", + "debugpy>=1.8.15", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/requirements.txt.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/requirements.txt.j2 new file mode 100644 index 000000000..a060d2331 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-claude-code/requirements.txt.j2 @@ -0,0 +1,11 @@ +# Agentex SDK +agentex-sdk + +# Scale GenAI Platform Python SDK +scale-gp + +# Temporal workflow engine +temporalio>=1.18.2 + +# Loads .env files for local development +python-dotenv>=1.0,<2 diff --git a/src/agentex/lib/cli/templates/temporal-codex/.dockerignore.j2 b/src/agentex/lib/cli/templates/temporal-codex/.dockerignore.j2 new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-codex/.dockerignore.j2 @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/src/agentex/lib/cli/templates/temporal-codex/.env.example.j2 b/src/agentex/lib/cli/templates/temporal-codex/.env.example.j2 new file mode 100644 index 000000000..5d621a83e --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-codex/.env.example.j2 @@ -0,0 +1,13 @@ +# {{ agent_name }} - Environment Variables +# Copy this file to .env and fill in the values + +# API key used by the codex CLI (`codex exec` reads OPENAI_API_KEY directly) +OPENAI_API_KEY= + +# LLM base URL (optional - override to use a different provider) +# OPENAI_BASE_URL= + +# SGP Configuration (optional - for tracing) +# SGP_API_KEY= +# SGP_ACCOUNT_ID= +# SGP_CLIENT_BASE_URL= diff --git a/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 new file mode 100644 index 000000000..7e31387fa --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 @@ -0,0 +1,61 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +# Install the codex CLI: the activity shells out to `codex` on every turn, so +# the binary must be present in the runtime image. +RUN npm install -g @openai/codex + +# Install tctl (Temporal CLI) +RUN ARCH="$(uname -m)" && \ + case "$ARCH" in x86_64) TCTL_ARCH=amd64 ;; aarch64|arm64) TCTL_ARCH=arm64 ;; *) TCTL_ARCH=amd64 ;; esac && \ + curl -L "https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_${TCTL_ARCH}.tar.gz" -o /tmp/tctl.tar.gz && \ + tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ + chmod +x /usr/local/bin/tctl && \ + rm /tmp/tctl.tar.gz + +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +ENV UV_HTTP_TIMEOUT=1000 + +WORKDIR /app/{{ project_path_from_build_root }} + +# Copy dependency files for layer caching +COPY {{ project_path_from_build_root }}/pyproject.toml ./ + +# Install dependencies (without project itself, for layer caching) +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-install-project --no-dev + +# Copy the project code +COPY {{ project_path_from_build_root }}/project ./project + +# Install the project +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" + +# Run the ACP server using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-codex/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-codex/Dockerfile.j2 new file mode 100644 index 000000000..0ae4e2079 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-codex/Dockerfile.j2 @@ -0,0 +1,54 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install the codex CLI: the activity shells out to `codex` on every turn, so +# the binary must be present in the runtime image. +RUN npm install -g @openai/codex + +# Install tctl (Temporal CLI) +RUN ARCH="$(uname -m)" && \ + case "$ARCH" in x86_64) TCTL_ARCH=amd64 ;; aarch64|arm64) TCTL_ARCH=arm64 ;; *) TCTL_ARCH=amd64 ;; esac && \ + curl -L "https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_${TCTL_ARCH}.tar.gz" -o /tmp/tctl.tar.gz && \ + tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ + chmod +x /usr/local/bin/tctl && \ + rm /tmp/tctl.tar.gz + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy just the requirements file to optimize caching +COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt + +WORKDIR /app/{{ project_path_from_build_root }} + +# Install the required Python packages +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 + +# Run the ACP server using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-codex/README.md.j2 b/src/agentex/lib/cli/templates/temporal-codex/README.md.j2 new file mode 100644 index 000000000..794109ff3 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-codex/README.md.j2 @@ -0,0 +1,80 @@ +# {{ agent_name }} — AgentEx Temporal + Codex + +This template builds a **Temporal-durable** agent that drives the **Codex CLI** +through the unified harness surface on AgentEx: +- A Temporal workflow holds conversation state (the codex thread ID) durably + across worker crashes — no external state store needed +- Each turn delegates to the `run_codex_turn` activity, which spawns the CLI + (subprocess I/O is not permitted on the workflow event loop) +- The activity wraps the CLI's stdout stream in a `CodexTurn` and delivers + canonical `StreamTaskMessage*` events via `UnifiedEmitter.auto_send_turn` +- Tracing integration to SGP / AgentEx + +## Prerequisites + +- The `codex` CLI installed and on your `PATH` (`npm install -g @openai/codex`) +- An `OPENAI_API_KEY` in your environment +- A running Temporal service (provided automatically by the local dev stack) + +## Running the Agent + +```bash +agentex agents run --manifest manifest.yaml +``` + +This starts both the ACP HTTP server and the Temporal worker. + +## Project Structure + +``` +{{ project_name }}/ +├── project/ +│ ├── __init__.py +│ ├── acp.py # Thin ACP server; FastACP auto-wires to the workflow +│ ├── workflow.py # Temporal workflow (durable conversation state) +│ ├── activities.py # run_codex_turn activity (CLI subprocess) +│ └── run_worker.py # Temporal worker entrypoint +├── Dockerfile +├── manifest.yaml +├── dev.ipynb +{% if use_uv %} +└── pyproject.toml +{% else %} +└── requirements.txt +{% endif %} +``` + +## Key Concepts + +### Subprocess must run in an activity +Temporal runs workflow + signal-handler bodies on a deterministic sandbox event +loop that does not implement `subprocess_exec`. The workflow therefore delegates +each turn to the `run_codex_turn` activity, which also gains Temporal's retry + +timeout guarantees. + +### Durable multi-turn memory +The codex thread ID is kept on the workflow instance; Temporal's durable replay +reconstructs it after a crash, so the next turn resumes the same codex session. + +### The unified harness surface +`CodexTurn` + `UnifiedEmitter` are the unified harness surface. The turn +normalizes CLI output into canonical AgentEx events; the emitter traces and +delivers them. + +## Development + +### 1. Choose a model +Set `CODEX_MODEL` (defaults to `o4-mini`) to control which model codex uses. + +### 2. Customize the subprocess +Edit `_spawn_codex` in `project/activities.py` to change the CLI flags or how +the prompt is delivered. + +### 3. Configure Credentials +Set your credentials via `manifest.yaml`, an exported environment variable, or a +`.env` file in the project directory. + +### 4. Run Locally +```bash +export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml +``` diff --git a/src/agentex/lib/cli/templates/temporal-codex/dev.ipynb.j2 b/src/agentex/lib/cli/templates/temporal-codex/dev.ipynb.j2 new file mode 100644 index 000000000..d3a68303f --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-codex/dev.ipynb.j2 @@ -0,0 +1,126 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"{{ agent_name }}\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [], + "source": [ + "# (REQUIRED) Create a new task. For Async agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b03b0d37", + "metadata": {}, + "outputs": [], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a6927cc0", + "metadata": {}, + "outputs": [], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4864e354", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/agentex/lib/cli/templates/temporal-codex/environments.yaml.j2 b/src/agentex/lib/cli/templates/temporal-codex/environments.yaml.j2 new file mode 100644 index 000000000..a3df5e228 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-codex/environments.yaml.j2 @@ -0,0 +1,64 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-{{agent_name}}" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + # This is used to override the global helm values.yaml file in the agentex-agent helm charts + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + temporal-worker: + enabled: true + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-codex/manifest.yaml.j2 b/src/agentex/lib/cli/templates/temporal-codex/manifest.yaml.j2 new file mode 100644 index 000000000..067567059 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-codex/manifest.yaml.j2 @@ -0,0 +1,142 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../ # Keep this as the default root + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - {{ project_path_from_build_root }} + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: {{ project_path_from_build_root }}/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: {{ project_path_from_build_root }}/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + # Path to temporal worker file + # Examples: + # project/run_worker.py (standard) + # workers/temporal.py (custom structure) + # ../shared/worker.py (shared across projects) + worker: project/run_worker.py + + +# Agent Configuration +# ----------------- +agent: + # Type of agent - either sync or async + acp_type: async + + # Unique name for your agent + # Used for task routing and monitoring + name: {{ agent_name }} + + # Description of what your agent does + # Helps with documentation and discovery + description: {{ description | tojson }} + + # Temporal workflow configuration + # This enables your agent to run as a Temporal workflow for long-running tasks + temporal: + enabled: true + workflows: + # Name of the workflow class + # Must match the @workflow.defn name in your workflow.py + - name: {{ workflow_name }} + + # Queue name for task distribution + # Used by Temporal to route tasks to your agent + # Convention: _task_queue + queue_name: {{ queue_name }} + + # Optional: Health check port for temporal worker + # Defaults to 80 if not specified + # health_check_port: 80 + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + credentials: + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + # The codex CLI spawned in project/activities.py reads OPENAI_API_KEY + # directly; without it every turn fails with an auth error. + - env_var_name: OPENAI_API_KEY + secret_name: openai-api-key + secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + env: {} + # LITELLM_API_KEY: "" + # OPENAI_BASE_URL: "" + # OPENAI_ORG_ID: "" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: [] # Update with your image pull secret name + # - name: my-registry-secret + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific environments (environments.yaml) + global: + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-codex/project/acp.py.j2 b/src/agentex/lib/cli/templates/temporal-codex/project/acp.py.j2 new file mode 100644 index 000000000..7ef5744f0 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-codex/project/acp.py.j2 @@ -0,0 +1,32 @@ +"""ACP server for {{ agent_name }} — a Temporal Codex harness agent. + +This file is intentionally thin. When ``acp_type="async"`` is combined with +``TemporalACPConfig(type="temporal", ...)``, FastACP auto-wires: + + HTTP task/create -> @workflow.run on the workflow class + HTTP task/event/send -> @workflow.signal(SignalName.RECEIVE_EVENT) + HTTP task/cancel -> workflow cancellation via the Temporal client + +so we don't define any handlers here. The actual agent code lives in +``project/workflow.py`` and is executed by the Temporal worker +(``project/run_worker.py``), not by this HTTP process. +""" + +from __future__ import annotations + +import os + +from dotenv import load_dotenv + +load_dotenv() + +from agentex.lib.types.fastacp import TemporalACPConfig +from agentex.lib.sdk.fastacp.fastacp import FastACP + +acp = FastACP.create( + acp_type="async", + config=TemporalACPConfig( + type="temporal", + temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), + ), +) diff --git a/src/agentex/lib/cli/templates/temporal-codex/project/activities.py.j2 b/src/agentex/lib/cli/templates/temporal-codex/project/activities.py.j2 new file mode 100644 index 000000000..0111794d9 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-codex/project/activities.py.j2 @@ -0,0 +1,151 @@ +"""Temporal activity for {{ agent_name }} — Codex harness. + +Subprocess spawning (and any other I/O) must run inside a Temporal *activity*, +not in workflow code. Temporal runs workflow + signal-handler bodies on a +deterministic sandbox event loop that does not implement ``subprocess_exec`` +(or threads / sockets), so spawning ``codex exec`` directly in the signal +handler raises ``NotImplementedError``. This activity runs codex, drives the +``CodexTurn`` through ``UnifiedEmitter.auto_send_turn`` (the async Redis push +path), and returns the turn result to the workflow. + +The ``_spawn_codex`` / ``_process_stdout`` seams are injectable: offline tests +can replace them with fakes that yield pre-recorded event lines so no real CLI +runs. +""" + +from __future__ import annotations + +import os +import codecs +import asyncio +from typing import Any +from datetime import datetime +from collections.abc import AsyncIterator + +from temporalio import activity + +from agentex.lib.adk import CodexTurn +from agentex.lib.core.harness import UnifiedEmitter +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.model_utils import BaseModel + +logger = make_logger(__name__) + +RUN_CODEX_TURN_ACTIVITY = "run_codex_turn" + + +class RunCodexTurnParams(BaseModel): + """Arguments for one codex turn run inside an activity.""" + + task_id: str + prompt: str + model: str + trace_id: str | None = None + parent_span_id: str | None = None + thread_id: str | None = None + created_at: datetime | None = None + + +class RunCodexTurnResult(BaseModel): + """Result returned from the activity to the workflow.""" + + final_text: str + session_id: str | None = None + model: str | None = None + + +async def _spawn_codex( + model: str, + thread_id: str | None = None, +) -> asyncio.subprocess.Process: + """Spawn ``codex exec --json`` locally and return the live process. + + Injection seam: tests replace this function with a fake that returns a + mock process whose stdout yields pre-recorded event lines. + + The caller writes the prompt to stdin after the process starts, then + closes stdin so codex knows input is complete. + """ + base_flags = [ + "--json", + "--skip-git-repo-check", + "--dangerously-bypass-approvals-and-sandbox", + "--model", + model, + ] + + if thread_id: + cmd = ["codex", "exec", *base_flags, "resume", thread_id, "-"] + else: + cmd = ["codex", "exec", *base_flags, "-"] + + return await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + # Discard stderr: codex --json writes events to stdout; its stderr is + # progress/debug noise. Capturing it with PIPE but never reading it + # would deadlock once codex fills the OS pipe buffer (~64 KB). + stderr=asyncio.subprocess.DEVNULL, + env={**os.environ}, + ) + + +async def _process_stdout(process: asyncio.subprocess.Process) -> AsyncIterator[str]: + """Yield newline-delimited JSON lines from the process stdout. + + Uses an incremental UTF-8 decoder so a multibyte character split across two + 4 KB reads is decoded correctly instead of being corrupted at the boundary. + """ + assert process.stdout is not None + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + buffer = "" + while True: + chunk = await process.stdout.read(4096) + if not chunk: + break + buffer += decoder.decode(chunk) + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if line: + yield line + buffer += decoder.decode(b"", final=True) + if buffer.strip(): + yield buffer.strip() + + +@activity.defn(name=RUN_CODEX_TURN_ACTIVITY) +async def run_codex_turn(params: RunCodexTurnParams) -> dict[str, Any]: + """Run one codex turn end-to-end and stream events to the task. + + Runs in an activity (real asyncio loop) so subprocess I/O is permitted. + """ + process = await _spawn_codex(params.model, thread_id=params.thread_id) + + assert process.stdin is not None + process.stdin.write(params.prompt.encode("utf-8")) + await process.stdin.drain() + process.stdin.close() + + turn = CodexTurn(events=_process_stdout(process), model=params.model) + emitter = UnifiedEmitter( + task_id=params.task_id, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) + # Guarantee the subprocess is reaped even if auto_send_turn raises; + # otherwise codex stays blocked writing to a full stdout pipe buffer and the + # OS process leaks until the worker restarts. + try: + result = await emitter.auto_send_turn(turn, created_at=params.created_at) + finally: + if process.returncode is None: + process.kill() + await process.wait() + + return RunCodexTurnResult( + final_text=result.final_text, + session_id=turn.session_id, + model=turn.usage().model, + ).model_dump() diff --git a/src/agentex/lib/cli/templates/temporal-codex/project/run_worker.py.j2 b/src/agentex/lib/cli/templates/temporal-codex/project/run_worker.py.j2 new file mode 100644 index 000000000..d86519977 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-codex/project/run_worker.py.j2 @@ -0,0 +1,41 @@ +"""Temporal worker for {{ agent_name }} — Codex harness. + +Run as a separate long-lived process alongside the ACP HTTP server. The +worker polls Temporal for workflow + activity tasks and executes them. + +The codex CLI subprocess runs in the ``run_codex_turn`` activity (registered +below alongside the built-in Agentex activities), because subprocess I/O is not +permitted on the Temporal workflow event loop. +""" + +import asyncio + +from project.workflow import {{ workflow_class }} +from project.activities import run_codex_turn +from agentex.lib.utils.debug import setup_debug_if_enabled +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.activities import get_all_activities +from agentex.lib.core.temporal.workers.worker import AgentexWorker + +environment_variables = EnvironmentVariables.refresh() +logger = make_logger(__name__) + + +async def main(): + setup_debug_if_enabled() + + task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE + if task_queue_name is None: + raise ValueError("WORKFLOW_TASK_QUEUE is not set") + + worker = AgentexWorker(task_queue=task_queue_name) + + await worker.run( + activities=[run_codex_turn, *get_all_activities()], + workflow={{ workflow_class }}, + ) + + +if __name__ == "__main__": + asyncio.run(main()) 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 new file mode 100644 index 000000000..1004ebfb8 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-codex/project/workflow.py.j2 @@ -0,0 +1,157 @@ +"""Temporal workflow for {{ agent_name }} — Codex harness. + +Demonstrates the ``convert_codex_to_agentex_events`` tap + ``CodexTurn`` + +``UnifiedEmitter`` for a Temporal-durable ACP agent. + +KEY CONCEPTS DEMONSTRATED: +- Running ``codex exec --json`` in the ``run_codex_turn`` activity. Subprocess + I/O is not permitted on the Temporal workflow event loop (the deterministic + sandbox loop does not implement ``subprocess_exec``), so the signal handler + delegates the turn to an activity, which also gets Temporal's retry + timeout + guarantees. +- Wrapping the stdout line stream in a ``CodexTurn`` (inside the activity). +- Delivering events via ``UnifiedEmitter.auto_send_turn``, which pushes + ``StreamTaskMessage*`` events to Redis so the UI sees tokens in real time. +- Passing ``created_at=workflow.now()`` for deterministic timestamps under + Temporal replay (required for Temporal-safe delivery). +- Persisting the codex thread ID on the workflow instance itself — Temporal's + workflow state is durable, so no external ``adk.state`` round-trip is needed. +""" + +from __future__ import annotations + +import os +import asyncio +from datetime import timedelta + +from temporalio import workflow + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CreateTaskParams +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.types.workflow import SignalName +from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config + +with workflow.unsafe.imports_passed_through(): + from project.activities import RunCodexTurnParams, run_codex_turn + +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) +) + +environment_variables = EnvironmentVariables.refresh() + +if environment_variables.WORKFLOW_NAME is None: + raise ValueError("Environment variable WORKFLOW_NAME is not set") +if environment_variables.AGENT_NAME is None: + raise ValueError("Environment variable AGENT_NAME is not set") + +logger = make_logger(__name__) + +MODEL = os.environ.get("CODEX_MODEL", "o4-mini") + + +@workflow.defn(name=environment_variables.WORKFLOW_NAME) +class {{ workflow_class }}(BaseWorkflow): + """Long-running Temporal workflow that runs codex exec for each turn. + + Conversation state (codex thread ID + turn counter) is kept on the + workflow instance. Temporal's durable replay reconstructs this state if + the worker crashes, so no external ``adk.state`` round-trip is needed. + """ + + def __init__(self): + super().__init__(display_name=environment_variables.AGENT_NAME) + self._complete_task = False + self._turn_number = 0 + self._codex_thread_id: str | None = None + # Serialize turns: signal handlers can interleave at await points, so two + # quick messages could both read the same stale _codex_thread_id and fork + # the codex session. The lock keeps turns sequential and preserves + # conversation continuity. + self._turn_lock = asyncio.Lock() + + @workflow.signal(name=SignalName.RECEIVE_EVENT) + async def on_task_event_send(self, params: SendEventParams) -> None: + """Handle a new user message: spawn codex, stream events via UnifiedEmitter.""" + logger.info("Received task event: %s", params.task.id) + async with self._turn_lock: + content = params.event.content + if not isinstance(content, TextContent): + logger.warning("Ignoring non-text event content (type=%s)", getattr(content, "type", "?")) + return + + self._turn_number += 1 + + await adk.messages.create(task_id=params.task.id, content=params.event.content) + + user_message = content.content + + async with adk.tracing.span( + trace_id=params.task.id, + task_id=params.task.id, + name=f"Turn {self._turn_number}", + input={"message": user_message}, + ) as span: + # Delegate the subprocess turn to an activity: subprocess I/O is not + # permitted on the Temporal workflow event loop. The activity streams + # events to the task and returns the final text + codex thread id. + # workflow.now() gives a deterministic timestamp under replay. + result = await workflow.execute_activity( + run_codex_turn, + RunCodexTurnParams( + task_id=params.task.id, + prompt=user_message, + model=MODEL, + trace_id=params.task.id, + parent_span_id=span.id if span else None, + thread_id=self._codex_thread_id, + created_at=workflow.now(), + ), + start_to_close_timeout=timedelta(minutes=5), + ) + + # Persist the codex thread id so the next turn resumes the session. + session_id = result.get("session_id") + if session_id: + self._codex_thread_id = session_id + + if span: + span.output = { + "final_text": result.get("final_text"), + "model": result.get("model"), + } + + @workflow.run + async def on_task_create(self, params: CreateTaskParams) -> str: + """Workflow entry point — keep the conversation alive for incoming signals.""" + logger.info("Task created: %s", params.task.id) + + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=( + "Task initialized.\n" + "Send me a message and I'll run codex (local subprocess) " + "to answer, streaming events via the unified harness surface." + ), + ), + ) + + await workflow.wait_condition(lambda: self._complete_task, timeout=None) + return "Task completed" + + @workflow.signal + async def complete_task_signal(self) -> None: + """Graceful workflow shutdown signal.""" + logger.info("Received complete_task signal") + self._complete_task = True diff --git a/src/agentex/lib/cli/templates/temporal-codex/pyproject.toml.j2 b/src/agentex/lib/cli/templates/temporal-codex/pyproject.toml.j2 new file mode 100644 index 000000000..2c6ec9c2f --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-codex/pyproject.toml.j2 @@ -0,0 +1,37 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ project_name }}" +version = "0.1.0" +description = "{{ description }}" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "temporalio>=1.18.2", + "python-dotenv>=1.0,<2", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", + "debugpy>=1.8.15", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/src/agentex/lib/cli/templates/temporal-codex/requirements.txt.j2 b/src/agentex/lib/cli/templates/temporal-codex/requirements.txt.j2 new file mode 100644 index 000000000..a060d2331 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-codex/requirements.txt.j2 @@ -0,0 +1,11 @@ +# Agentex SDK +agentex-sdk + +# Scale GenAI Platform Python SDK +scale-gp + +# Temporal workflow engine +temporalio>=1.18.2 + +# Loads .env files for local development +python-dotenv>=1.0,<2 diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/.dockerignore.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/.dockerignore.j2 new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-langgraph/.dockerignore.j2 @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/.env.example.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/.env.example.j2 new file mode 100644 index 000000000..015f49ef7 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-langgraph/.env.example.j2 @@ -0,0 +1,13 @@ +# {{ agent_name }} - Environment Variables +# Copy this file to .env and fill in the values + +# API key for your LLM provider +LITELLM_API_KEY= + +# LLM base URL (optional - override to use a different provider) +# OPENAI_BASE_URL= + +# SGP Configuration (optional - for tracing) +# SGP_API_KEY= +# SGP_ACCOUNT_ID= +# SGP_CLIENT_BASE_URL= diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 new file mode 100644 index 000000000..6746869df --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 @@ -0,0 +1,55 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +# Install tctl (Temporal CLI) +RUN curl -L https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_arm64.tar.gz -o /tmp/tctl.tar.gz && \ + tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ + chmod +x /usr/local/bin/tctl && \ + rm /tmp/tctl.tar.gz + +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +ENV UV_HTTP_TIMEOUT=1000 + +WORKDIR /app/{{ project_path_from_build_root }} + +# Copy dependency files for layer caching +COPY {{ project_path_from_build_root }}/pyproject.toml ./ + +# Install dependencies (without project itself, for layer caching) +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-install-project --no-dev + +# Copy the project code +COPY {{ project_path_from_build_root }}/project ./project + +# Install the project +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" + +# Run the ACP server using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile.j2 new file mode 100644 index 000000000..ba47485a9 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile.j2 @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install tctl (Temporal CLI) +RUN curl -L https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_arm64.tar.gz -o /tmp/tctl.tar.gz && \ + tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ + chmod +x /usr/local/bin/tctl && \ + rm /tmp/tctl.tar.gz + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy just the requirements file to optimize caching +COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt + +WORKDIR /app/{{ project_path_from_build_root }} + +# Install the required Python packages +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 + +# Run the ACP server using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/README.md.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/README.md.j2 new file mode 100644 index 000000000..e8af5a90b --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-langgraph/README.md.j2 @@ -0,0 +1,121 @@ +# {{ agent_name }} — AgentEx Temporal + LangGraph + +A starter template for building AI agents with AgentEx, [LangGraph](https://langchain-ai.github.io/langgraph/), +and Temporal — where **Temporal is the runtime and LangGraph is the agent framework**. + +It uses the official [`temporalio.contrib.langgraph`](https://docs.temporal.io/develop/python/integrations/langgraph) +plugin: each LangGraph node runs either as a durable **Temporal activity** or +inline in the **workflow**, configured per node with `execute_in`. You get +per-node durability, automatic retries, and full visibility in the Temporal UI +— without LangGraph's own runtime or an external checkpoint database. + +> The Temporal LangGraph plugin is currently **experimental**; its API may change. + +## What's in the box + +- **Nodes as activities** — the LLM (`agent`) node runs as a retried, observable + Temporal activity; the `tools` node runs in the workflow (see below). +- **Human-in-the-loop** — approval-gated tools raise a LangGraph `interrupt`; + the workflow pauses on a Temporal signal (`provide_approval`) until a human + approves or rejects, then resumes. +- **Live introspection via Temporal queries** — `get_status`, + `get_pending_approval`, `get_graph_state`, and `get_graph_mermaid` / + `get_graph_ascii` to render the agent graph while it runs. +- **Multi-turn memory** — the running message list is kept on the workflow + instance, durable for free. +- **Tracing/observability** — a per-turn span shipped to SGP/AgentEx. + +## The agent graph + +``` +START --> agent --> (tool calls?) --> tools --> agent + --> (no tool calls?) --> END +``` + +`project/graph.py` defines this graph. The `agent` node is marked +`execute_in="activity"`; the `tools` node is `execute_in="workflow"`. Query +`get_graph_mermaid` at runtime to see it rendered. + +### Why the tools node runs in the workflow + +The `tools` node runs inline in the workflow (not as an activity) for two +reasons: the `AIMessage` with tool calls stays intact without crossing an +activity boundary, and LangGraph `interrupt` (used for human approval) must run +where the workflow can pause on a Temporal signal. For long-running or heavily +side-effecting tools, move that work into its own `execute_in="activity"` node. +The router and tools are `async` so LangGraph awaits them directly (sync +callables are offloaded via `run_in_executor`, which Temporal workflows forbid). + +## Project structure + +``` +{{ project_name }}/ +├── project/ +│ ├── __init__.py +│ ├── acp.py # Thin async ACP server; registers the LangGraphPlugin +│ ├── workflow.py # Temporal runtime: runs the graph, HIL, queries, memory +│ ├── graph.py # LangGraph graph; nodes tagged execute_in activity/workflow +│ ├── tools.py # Async tool definitions + approval set +│ └── run_worker.py # Temporal worker; registers the LangGraphPlugin +├── Dockerfile +├── manifest.yaml +├── environments.yaml +├── dev.ipynb +{% if use_uv %}└── pyproject.toml{% else %}└── requirements.txt{% endif %} +``` + +## Running the agent + +```bash +{% if use_uv %}agentex uv sync +source .venv/bin/activate{% else %}pip install -r requirements.txt{% endif %} + +# Start the agent (ACP server + Temporal worker) +agentex agents run --manifest manifest.yaml +``` + +The agent starts on port 8000. Open the Temporal UI at http://localhost:8080 to +watch workflows and activities execute. Use `dev.ipynb` to create a task and +send messages. + +## Human-in-the-loop + +Tools listed in `TOOLS_REQUIRING_APPROVAL` (in `project/tools.py`) raise a +LangGraph `interrupt` before they run. The workflow surfaces the pending call +(queryable via `get_pending_approval`) and waits — durably, for as long as it +takes — for a `provide_approval` signal carrying the decision: + +```python +# decision: {"approved": true, "approver": "daniel", "reason": "looks good"} +``` + +If rejected, the rejection is fed back to the model so it can adjust. + +## Adding tools + +1. Define an **async** `@tool` function in `project/tools.py` and add it to `TOOLS`. +2. (Optional) add its name to `TOOLS_REQUIRING_APPROVAL` to gate it behind + human approval. + +The model is bound with `TOOLS` and the tool node looks them up by name, so no +other wiring is needed. + +## Configuration + +Tune the model in `project/graph.py` (`MODEL_NAME`) and the system prompt +(`SYSTEM_PROMPT`). Per-node activity timeouts and retry policies live in the +node `metadata` in `build_graph()`. + +## Environment variables + +Create a `.env` file (see `.env.example`): + +```bash +LITELLM_API_KEY=your-litellm-key # copied to OPENAI_API_KEY automatically +# OPENAI_BASE_URL= # optional: point at a different provider +# SGP_API_KEY= # optional: tracing +# SGP_ACCOUNT_ID= # optional: tracing +# SGP_CLIENT_BASE_URL= # optional: tracing +``` + +Happy building with Temporal + LangGraph! diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/dev.ipynb.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/dev.ipynb.j2 new file mode 100644 index 000000000..d3a68303f --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-langgraph/dev.ipynb.j2 @@ -0,0 +1,126 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"{{ agent_name }}\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [], + "source": [ + "# (REQUIRED) Create a new task. For Async agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b03b0d37", + "metadata": {}, + "outputs": [], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a6927cc0", + "metadata": {}, + "outputs": [], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4864e354", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/environments.yaml.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/environments.yaml.j2 new file mode 100644 index 000000000..a3df5e228 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-langgraph/environments.yaml.j2 @@ -0,0 +1,64 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-{{agent_name}}" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + # This is used to override the global helm values.yaml file in the agentex-agent helm charts + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + temporal-worker: + enabled: true + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/manifest.yaml.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/manifest.yaml.j2 new file mode 100644 index 000000000..b9216929f --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-langgraph/manifest.yaml.j2 @@ -0,0 +1,140 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../ # Keep this as the default root + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - {{ project_path_from_build_root }} + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: {{ project_path_from_build_root }}/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: {{ project_path_from_build_root }}/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + # Path to temporal worker file + # Examples: + # project/run_worker.py (standard) + # workers/temporal.py (custom structure) + # ../shared/worker.py (shared across projects) + worker: project/run_worker.py + + +# Agent Configuration +# ----------------- +agent: + # Type of agent - either sync or async + acp_type: async + + # Unique name for your agent + # Used for task routing and monitoring + name: {{ agent_name }} + + # Description of what your agent does + # Helps with documentation and discovery + description: {{ description | tojson }} + + # Temporal workflow configuration + # This enables your agent to run as a Temporal workflow for long-running tasks + temporal: + enabled: true + workflows: + # Name of the workflow class + # Must match the @workflow.defn name in your workflow.py + - name: {{ workflow_name }} + + # Queue name for task distribution + # Used by Temporal to route tasks to your agent + # Convention: _task_queue + queue_name: {{ queue_name }} + + # Optional: Health check port for temporal worker + # Defaults to 80 if not specified + # health_check_port: 80 + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + credentials: + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + # - env_var_name: LITELLM_API_KEY + # secret_name: litellm-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + env: {} + # LITELLM_API_KEY: "" + # OPENAI_BASE_URL: "" + # OPENAI_ORG_ID: "" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: [] # Update with your image pull secret name + # - name: my-registry-secret + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific environments (environments.yaml) + global: + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/project/acp.py.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/project/acp.py.j2 new file mode 100644 index 000000000..c01f8831c --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-langgraph/project/acp.py.j2 @@ -0,0 +1,42 @@ +"""ACP server for the Temporal LangGraph agent. + +This file is intentionally thin. When ``acp_type="async"`` is combined with +``TemporalACPConfig(type="temporal", ...)``, FastACP auto-wires: + + HTTP task/create → @workflow.run on the workflow class + HTTP task/event/send → @workflow.signal(SignalName.RECEIVE_EVENT) + HTTP task/cancel → workflow cancellation via the Temporal client + +so we don't define any handlers here. The agent logic lives in +``project/workflow.py`` (the runtime) and ``project/graph.py`` (the LangGraph +graph whose nodes run as Temporal activities), executed by the Temporal worker +(``project/run_worker.py``), not by this HTTP process. + +The ``LangGraphPlugin`` is registered here too so the Temporal client started +by FastACP shares the same graph registry as the worker. +""" + +from __future__ import annotations + +import os + +from dotenv import load_dotenv + +load_dotenv() + +from temporalio.contrib.langgraph import LangGraphPlugin + +from project.graph import GRAPH_NAME, build_graph +from agentex.lib.types.fastacp import TemporalACPConfig +from agentex.lib.sdk.fastacp.fastacp import FastACP + +acp = FastACP.create( + acp_type="async", + config=TemporalACPConfig( + # When deployed to the cluster, the Temporal address is set automatically. + # Locally we point at the Temporal service from docker compose. + type="temporal", + temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[LangGraphPlugin(graphs={GRAPH_NAME: build_graph()})], + ), +) \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/project/graph.py.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/project/graph.py.j2 new file mode 100644 index 000000000..feb8051bb --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-langgraph/project/graph.py.j2 @@ -0,0 +1,165 @@ +"""LangGraph graph for {{ agent_name }} — nodes run as Temporal activities. + +This is the LangGraph half of the integration. The ``temporalio.contrib.langgraph`` +plugin executes this graph's nodes durably: each node's ``execute_in`` metadata +says whether it runs as a Temporal **activity** or inline in the **workflow**. + + START → agent → (tool calls?) → tools → agent + → (no tool calls?) → END + +- ``agent`` (``execute_in="activity"``): the LLM call. Runs as its own durable, + retried Temporal activity — visible in the Temporal UI. +- ``tools`` (``execute_in="workflow"``): executes tool calls and hosts the + human-in-the-loop gate. It runs inline in the workflow because (a) the + ``AIMessage`` with tool calls stays intact without crossing an activity + boundary, and (b) LangGraph ``interrupt`` (used for approvals) needs to run + where the workflow can pause on a Temporal signal. + +Why these shapes: +- The router (``route_after_agent``) and tools are **async** so LangGraph + awaits them directly; sync callables would be offloaded via + ``run_in_executor``, which Temporal's workflow event loop does not support. +- Tool execution as a workflow node keeps things simple for this template. + For long-running or heavily side-effecting tools, move that work into its + own activity (e.g. mark a dedicated tool node ``execute_in="activity"``). +""" + +from __future__ import annotations + +import os +from typing import Any, Annotated +from datetime import datetime, timedelta + +# Copy the LiteLLM proxy key to OPENAI_API_KEY so langchain-openai authenticates +# against the Scale LiteLLM proxy when one is configured. This runs in the +# worker process (where the agent activity executes). +_litellm_key = os.environ.get("LITELLM_API_KEY") +if _litellm_key: + os.environ.setdefault("OPENAI_API_KEY", _litellm_key) + +from typing_extensions import TypedDict + +from langgraph.graph import END, START, StateGraph +from langgraph.types import interrupt +from langchain_openai import ChatOpenAI +from temporalio.common import RetryPolicy +from langchain_core.messages import ToolMessage, SystemMessage +from langgraph.graph.message import add_messages + +from project.tools import TOOLS, TOOLS_BY_NAME, TOOLS_REQUIRING_APPROVAL + +# The name this graph is registered under in the LangGraphPlugin. The workflow +# retrieves it with ``graph(GRAPH_NAME)``; acp.py and run_worker.py register it. +GRAPH_NAME = "{{ agent_name }}" + +# Swap for any LangChain-supported chat model id, e.g. "gpt-4o", "o3-mini". +MODEL_NAME = "gpt-4o" + +SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. + +Current date and time: {timestamp} + +Guidelines: +- Be concise and helpful +- Use tools when they would help answer the user's question +- If you're unsure, ask clarifying questions +- Always provide accurate information +""" + + +class AgentState(TypedDict): + """State schema for the agent graph.""" + + messages: Annotated[list[Any], add_messages] + + +async def agent_node(state: AgentState) -> dict[str, Any]: + """The 'agent' node — one LLM call. Runs as a durable Temporal activity.""" + llm = ChatOpenAI(model=MODEL_NAME).bind_tools(TOOLS) + messages = state["messages"] + if not messages or not isinstance(messages[0], SystemMessage): + system = SystemMessage( + content=SYSTEM_PROMPT.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")) + ) + messages = [system, *messages] + return {"messages": [await llm.ainvoke(messages)]} + + +async def tools_node(state: AgentState) -> dict[str, Any]: + """The 'tools' node — executes tool calls, with a human-approval gate. + + Runs inline in the workflow. For tools in ``TOOLS_REQUIRING_APPROVAL`` it + raises a LangGraph ``interrupt`` carrying the pending call; the workflow + pauses on a Temporal signal until a human approves or rejects, then resumes. + """ + last_message = state["messages"][-1] + tool_messages: list[ToolMessage] = [] + + for tool_call in last_message.tool_calls: + name = tool_call["name"] + + tool = TOOLS_BY_NAME.get(name) + if tool is None: + # The model hallucinated a tool that isn't registered — tell it so + # it can recover, rather than crashing the workflow. + tool_messages.append( + ToolMessage(content=f"Error: unknown tool {name!r}", tool_call_id=tool_call["id"], name=name) + ) + continue + + if name in TOOLS_REQUIRING_APPROVAL: + # interrupt() pauses the graph; the workflow resumes it with the + # human's decision via Command(resume=...). Durable: it can wait + # minutes, hours, or days and survive worker restarts. + decision = interrupt( + {"tool_call_id": tool_call["id"], "name": name, "args": tool_call["args"]} + ) + if not decision.get("approved"): + rejection = ( + f"Tool call rejected by {decision.get('approver', 'human')}: " + f"{decision.get('reason', 'no reason given')}" + ) + tool_messages.append( + ToolMessage(content=rejection, tool_call_id=tool_call["id"], name=name) + ) + continue + + result = await tool.ainvoke(tool_call["args"]) + tool_messages.append( + ToolMessage(content=str(result), tool_call_id=tool_call["id"], name=name) + ) + + return {"messages": tool_messages} + + +async def route_after_agent(state: AgentState) -> str: + """Route to the tools node when the model requested tools, else finish. + + Async so LangGraph awaits it directly in the workflow (a sync router would + be offloaded via run_in_executor, unsupported in Temporal workflows). + """ + last_message = state["messages"][-1] + return "tools" if getattr(last_message, "tool_calls", None) else END + + +def build_graph() -> StateGraph: + """Build the agent graph with per-node Temporal execution metadata. + + Registered with the ``LangGraphPlugin`` in acp.py / run_worker.py, and used + by the workflow's visualization queries. + """ + builder = StateGraph(AgentState) + builder.add_node( + "agent", + agent_node, + metadata={ + "execute_in": "activity", + "start_to_close_timeout": timedelta(minutes=5), + "retry_policy": RetryPolicy(maximum_attempts=3), + }, + ) + builder.add_node("tools", tools_node, metadata={"execute_in": "workflow"}) + builder.add_edge(START, "agent") + builder.add_conditional_edges("agent", route_after_agent, {"tools": "tools", END: END}) + builder.add_edge("tools", "agent") + return builder \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/project/run_worker.py.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/project/run_worker.py.j2 new file mode 100644 index 000000000..9dc45a4a0 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-langgraph/project/run_worker.py.j2 @@ -0,0 +1,50 @@ +"""Temporal worker for {{ agent_name }}. + +Run as a separate long-lived process alongside the ACP HTTP server. The +worker polls Temporal for workflow + activity tasks and executes them. + +The ``LangGraphPlugin`` is given the graph registry (``{ GRAPH_NAME: graph }``). +At runtime it turns the graph's ``execute_in="activity"`` nodes into Temporal +activities and registers them on the worker automatically — so we don't have +to enumerate node activities by hand. +""" + +import asyncio + +from temporalio.contrib.langgraph import LangGraphPlugin + +from project.graph import GRAPH_NAME, build_graph +from project.workflow import {{ workflow_class }} +from agentex.lib.utils.debug import setup_debug_if_enabled +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.activities import get_all_activities +from agentex.lib.core.temporal.workers.worker import AgentexWorker + +environment_variables = EnvironmentVariables.refresh() +logger = make_logger(__name__) + + +async def main(): + setup_debug_if_enabled() + + task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE + if task_queue_name is None: + raise ValueError("WORKFLOW_TASK_QUEUE is not set") + + # AgentexWorker runs workflows with an unsandboxed runner, so importing + # langchain/langgraph inside the workflow + nodes is fine. The LangGraph + # plugin registers the graph's activity-nodes for us. + worker = AgentexWorker( + task_queue=task_queue_name, + plugins=[LangGraphPlugin(graphs={GRAPH_NAME: build_graph()})], + ) + + await worker.run( + activities=get_all_activities(), + workflow={{ workflow_class }}, + ) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/project/tools.py.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/project/tools.py.j2 new file mode 100644 index 000000000..35660ad9b --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-langgraph/project/tools.py.j2 @@ -0,0 +1,57 @@ +"""Tool definitions for the LangGraph + Temporal agent. + +Each tool is an async LangChain ``@tool``. They're run by the ``tools`` node +(see ``project/graph.py``), which the Temporal LangGraph plugin executes +inside the workflow. Tools must be ``async`` so the in-workflow node awaits +them directly rather than offloading to a thread executor (which Temporal's +workflow event loop does not allow). + +``TOOLS`` is the single source of truth: it's bound to the model (so the LLM +knows the schemas) and looked up by name when the tool node runs. + +``TOOLS_REQUIRING_APPROVAL`` marks tools that pause for human approval before +they run — the tool node raises a LangGraph ``interrupt`` for those, which the +workflow surfaces and resolves via a Temporal signal (human-in-the-loop). +""" + +from __future__ import annotations + +from langchain_core.tools import tool + + +@tool +async def get_weather(city: str) -> str: + """Get the current weather for a city. + + Args: + city: The name of the city to get weather for. + + Returns: + A string describing the weather conditions. + """ + # TODO: Replace with a real weather API call. + return f"The weather in {city} is sunny and 72°F" + + +@tool +async def send_notification(recipient: str, message: str) -> str: + """Send a notification to a recipient. Requires human approval before sending. + + Args: + recipient: Who to notify. + message: The message body to send. + + Returns: + A confirmation string. + """ + # TODO: Replace with a real side-effecting integration (email, Slack, ...). + return f"Notification sent to {recipient}: {message!r}" + + +# All tools available to the agent. Bound to the model and looked up by name +# when the tool node runs. +TOOLS = [get_weather, send_notification] +TOOLS_BY_NAME = {t.name: t for t in TOOLS} + +# Tools in this set pause for a human-approval signal before they run. +TOOLS_REQUIRING_APPROVAL = {"send_notification"} 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 new file mode 100644 index 000000000..14bafabc1 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-langgraph/project/workflow.py.j2 @@ -0,0 +1,263 @@ +"""Temporal workflow for {{ agent_name }} — Temporal as the LangGraph runtime. + +*Temporal replaces the runtime; LangGraph is the agent framework.* This +workflow is that runtime. Each turn it runs the LangGraph graph defined in +``project/graph.py`` via the ``temporalio.contrib.langgraph`` plugin, which +executes the graph's nodes as durable Temporal activities (the ``agent``/LLM +node) or inline in the workflow (the ``tools`` node). + +Showcased here: + +- **Nodes as activities** — the plugin runs the LLM node as a retried, + observable Temporal activity (see ``execute_in`` metadata in graph.py). +- **Human-in-the-loop** — when the graph raises a LangGraph ``interrupt`` for + an approval-gated tool, the workflow pauses on a Temporal signal + (``provide_approval``) and resumes with the human's decision. +- **Live introspection via Temporal queries** — status, the pending approval, + and a Mermaid/ASCII rendering of the agent graph, queryable while it runs. +- **Multi-turn memory** — the running message list is kept on the workflow + instance; durable and replay-safe for free, so no checkpoint DB is needed. +- **Tracing** — a per-turn span shipped to SGP/AgentEx. +""" + +from __future__ import annotations + +import os +import json +from typing import Any + +# LangGraph plugin helper: retrieves the graph registered under GRAPH_NAME. +import langgraph.checkpoint.memory +from temporalio import workflow +from langgraph.types import Command +from temporalio.contrib.langgraph import graph as lg_graph + +from agentex.lib import adk +from project.graph import GRAPH_NAME, build_graph +from agentex.lib.adk import emit_langgraph_messages +from agentex.protocol.acp import SendEventParams, CreateTaskParams +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.types.workflow import SignalName +from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config + +# Register the SGP tracing exporter (spans also reach the AgentEx backend via +# the default processor that is lazy-initialised on first span). +SGP_API_KEY = os.environ.get("SGP_API_KEY", "") +SGP_ACCOUNT_ID = os.environ.get("SGP_ACCOUNT_ID", "") +if SGP_API_KEY and SGP_ACCOUNT_ID: + add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=SGP_API_KEY, + sgp_account_id=SGP_ACCOUNT_ID, + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) + ) + +environment_variables = EnvironmentVariables.refresh() + +if environment_variables.WORKFLOW_NAME is None: + raise ValueError("Environment variable WORKFLOW_NAME is not set") +if environment_variables.AGENT_NAME is None: + raise ValueError("Environment variable AGENT_NAME is not set") + +logger = make_logger(__name__) + + +@workflow.defn(name=environment_variables.WORKFLOW_NAME) +class {{ workflow_class }}(BaseWorkflow): + """Durable runtime that runs the LangGraph agent via the Temporal plugin.""" + + def __init__(self) -> None: + super().__init__(display_name=environment_variables.AGENT_NAME) + self._complete_task = False + self._turn_number = 0 + # Running conversation, as LangGraph message objects. Durable: Temporal + # replays the activity results that produced it, so it survives crashes. + self._messages: list[Any] = [] + # How many messages have already been surfaced to the AgentEx UI. + self._emitted = 0 + self._status = "idle" + self._pending_approval: dict[str, Any] | None = None + self._approval_response: dict[str, Any] | None = None + self._viz_graph: Any = None + + # ------------------------------------------------------------------ # + # Signals + # ------------------------------------------------------------------ # + + @workflow.signal(name=SignalName.RECEIVE_EVENT) + async def on_task_event_send(self, params: SendEventParams) -> None: + """Handle a new user message: echo it, then run the agent graph durably.""" + logger.info(f"Received task event for task {params.task.id}") + content = params.event.content + if not isinstance(content, TextContent): + logger.warning("Ignoring non-text event content (type=%s)", getattr(content, "type", "?")) + return + self._turn_number += 1 + user_text = content.content + + # Echo the user's message so it shows up as a chat bubble. + await adk.messages.create(task_id=params.task.id, content=params.event.content) + self._messages.append({"role": "user", "content": user_text}) + + async with adk.tracing.span( + trace_id=params.task.id, + task_id=params.task.id, + name=f"Turn {self._turn_number}", + input={"message": user_text}, + ) as span: + final_text = await self._run_graph(params.task.id) + if span: + span.output = {"final_output": final_text} + + @workflow.signal + async def provide_approval(self, response: dict[str, Any]) -> None: + """Provide a human approval decision for a pending tool call. + + Args: + response: ``{"approved": bool, "reason": str, "approver": str}``. + """ + logger.info(f"Received approval response: {response}") + self._approval_response = response + + @workflow.signal + async def complete_task_signal(self) -> None: + """Gracefully end the task/workflow.""" + logger.info("Received complete_task signal") + self._complete_task = True + + # ------------------------------------------------------------------ # + # Agent turn — run the LangGraph graph, pausing for approvals. + # ------------------------------------------------------------------ # + + async def _run_graph(self, task_id: str) -> str: + """Run one turn of the graph, handling any human-approval interrupts.""" + # A fresh in-memory checkpointer per turn: it only needs to persist the + # interrupt/resume state within this turn. Temporal provides durability; + # cross-turn memory lives in self._messages. + compiled = lg_graph(GRAPH_NAME).compile( + checkpointer=langgraph.checkpoint.memory.InMemorySaver() + ) + config = {"configurable": {"thread_id": f"{task_id}-{self._turn_number}"}} + + self._status = "processing" + result = await compiled.ainvoke({"messages": self._messages}, config=config) + + # The graph pauses (interrupt) whenever an approval-gated tool is called. + while result.get("__interrupt__"): + interrupt_value = result["__interrupt__"][0].value + decision = await self._await_human_approval(task_id, interrupt_value) + result = await compiled.ainvoke(Command(resume=decision), config=config) + + self._messages = result["messages"] + # Surface the messages this turn produced (tool calls, results, final + # text) to the AgentEx UI. The SDK helper does the LangGraph→AgentEx + # message conversion and returns the final assistant text. + final_text = await emit_langgraph_messages(self._messages[self._emitted:], task_id) + self._emitted = len(self._messages) + self._status = "completed" + return final_text + + async def _await_human_approval(self, task_id: str, pending: dict[str, Any]) -> dict[str, Any]: + """Pause until a ``provide_approval`` signal arrives, then return the decision.""" + self._pending_approval = pending + self._approval_response = None + self._status = "waiting_for_approval" + + await adk.messages.create( + task_id=task_id, + content=TextContent( + author="agent", + content=( + f"⏸️ Waiting for human approval to run **{pending.get('name')}** " + f"with `{json.dumps(pending.get('args', {}))}`.\n\n" + "Send a `provide_approval` signal, e.g. " + '`{"approved": true, "approver": "you"}`.' + ), + ), + ) + + await workflow.wait_condition(lambda: self._approval_response is not None) + + decision = self._approval_response or {"approved": False} + self._pending_approval = None + self._approval_response = None + self._status = "processing" + return decision + + # ------------------------------------------------------------------ # + # Queries — inspect the running agent live from the Temporal UI/client. + # ------------------------------------------------------------------ # + + @workflow.query + def get_status(self) -> str: + """Current status: idle | processing | waiting_for_approval | completed.""" + return self._status + + @workflow.query + def get_pending_approval(self) -> dict[str, Any] | None: + """The tool call currently awaiting human approval, if any.""" + return self._pending_approval + + @workflow.query + def get_graph_state(self) -> dict[str, Any]: + """A snapshot of the agent loop's progress.""" + return { + "turn_number": self._turn_number, + "message_count": len(self._messages), + "status": self._status, + "pending_approval": self._pending_approval, + "completed": self._complete_task, + } + + @workflow.query + def get_graph_mermaid(self) -> str: + """Mermaid diagram of the agent graph (renders in GitHub/Notion).""" + try: + return self._visualization_graph().get_graph().draw_mermaid() + except Exception as exc: # pragma: no cover - visualization is best-effort + return f"Could not render graph: {exc}" + + @workflow.query + def get_graph_ascii(self) -> str: + """ASCII-art diagram of the agent graph (requires the `grandalf` package).""" + try: + return self._visualization_graph().get_graph().draw_ascii() + except ImportError: + return "ASCII rendering requires the 'grandalf' package. Try get_graph_mermaid instead." + except Exception as exc: # pragma: no cover - visualization is best-effort + return f"Could not render graph: {exc}" + + def _visualization_graph(self): + """Lazily build + cache the compiled graph used purely for rendering.""" + if self._viz_graph is None: + self._viz_graph = build_graph().compile() + return self._viz_graph + + # ------------------------------------------------------------------ # + # Entry point + # ------------------------------------------------------------------ # + + @workflow.run + async def on_task_create(self, params: CreateTaskParams) -> str: + """Keep the conversation alive, handling incoming message/approval signals.""" + logger.info(f"Task created: {params.task.id}") + + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=( + f"Task initialized with params:\n{json.dumps(params.params, indent=2)}\n\n" + "Send me a message and I'll respond using a LangGraph agent whose nodes " + "run as durable Temporal activities." + ), + ), + ) + + await workflow.wait_condition(lambda: self._complete_task, timeout=None) + return "Task completed" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/pyproject.toml.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/pyproject.toml.j2 new file mode 100644 index 000000000..125ce704c --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-langgraph/pyproject.toml.j2 @@ -0,0 +1,42 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ project_name }}" +version = "0.1.0" +description = "{{ description }}" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + # Temporal with the LangGraph plugin (temporalio.contrib.langgraph), + # which runs LangGraph nodes as Temporal activities. Needs >=1.27.0. + "temporalio[langgraph]>=1.27.0", + "langchain-openai", + "langchain-core", + "grandalf", + "python-dotenv", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", + "debugpy>=1.8.15", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/requirements.txt.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/requirements.txt.j2 new file mode 100644 index 000000000..a499fc17c --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-langgraph/requirements.txt.j2 @@ -0,0 +1,18 @@ +# Agentex SDK +agentex-sdk + +# Scale GenAI Platform Python SDK +scale-gp + +# Temporal with the LangGraph plugin (temporalio.contrib.langgraph). +# The plugin runs LangGraph nodes as Temporal activities; needs >=1.27.0. +temporalio[langgraph]>=1.27.0 + +# LangChain model + tools +langchain-openai +langchain-core + +# Optional: enables get_graph_ascii() ASCII graph rendering +grandalf + +python-dotenv diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/test_agent.py.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/test_agent.py.j2 new file mode 100644 index 000000000..2d28e44d4 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-langgraph/test_agent.py.j2 @@ -0,0 +1,147 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: {{ agent_name }}) +""" + +import os +import uuid +import asyncio +import pytest +import pytest_asyncio +from agentex import AsyncAgentex +from agentex.types import TaskMessage +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam +from test_utils.async_utils import ( + poll_for_agent_response, + send_event_and_poll_yielding, + stream_agent_response, + validate_text_in_response, + poll_messages, +) + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "{{ agent_name }}") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and polling for the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # TODO: Poll for the initial task creation message (if your agent sends one) + # async for message in poll_messages( + # client=client, + # task_id=task.id, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected initial message + # assert "expected initial text" in message.content.content + # break + + # TODO: Send an event and poll for response using the yielding helper function + # user_message = "Your test message here" + # async for message in send_event_and_poll_yielding( + # client=client, + # agent_id=agent_id, + # task_id=task.id, + # user_message=user_message, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected response + # assert "expected response text" in message.content.content + # break + pass + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and streaming the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # user_message = "Your test message here" + + # # Collect events from stream + # all_events = [] + + # async def collect_stream_events(): + # async for event in stream_agent_response( + # client=client, + # task_id=task.id, + # timeout=30, + # ): + # all_events.append(event) + + # # Start streaming task + # stream_task = asyncio.create_task(collect_stream_events()) + + # # Send the event + # event_content = TextContentParam(type="text", author="user", content=user_message) + # await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + + # # Wait for streaming to complete + # await stream_task + + # # TODO: Add your validation here + # assert len(all_events) > 0, "No events received in streaming response" + pass + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/.dockerignore.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/.dockerignore.j2 new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/.dockerignore.j2 @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/.env.example.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/.env.example.j2 new file mode 100644 index 000000000..015f49ef7 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/.env.example.j2 @@ -0,0 +1,13 @@ +# {{ agent_name }} - Environment Variables +# Copy this file to .env and fill in the values + +# API key for your LLM provider +LITELLM_API_KEY= + +# LLM base URL (optional - override to use a different provider) +# OPENAI_BASE_URL= + +# SGP Configuration (optional - for tracing) +# SGP_API_KEY= +# SGP_ACCOUNT_ID= +# SGP_CLIENT_BASE_URL= 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 new file mode 100644 index 000000000..0d9801016 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile-uv.j2 @@ -0,0 +1,55 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +# Install tctl (Temporal CLI) +RUN curl -L https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_arm64.tar.gz -o /tmp/tctl.tar.gz && \ + tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ + chmod +x /usr/local/bin/tctl && \ + rm /tmp/tctl.tar.gz + +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +ENV UV_HTTP_TIMEOUT=1000 + +WORKDIR /app/{{ project_path_from_build_root }} + +# Copy dependency files for layer caching +COPY {{ project_path_from_build_root }}/pyproject.toml ./ + +# Install dependencies (without project itself, for layer caching) +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-install-project --no-dev + +# Copy the project code +COPY {{ project_path_from_build_root }}/project ./project + +# Install the project +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" + +# Run the ACP server using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile.j2 new file mode 100644 index 000000000..4c1798c42 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile.j2 @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + node \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install tctl (Temporal CLI) +RUN curl -L https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_arm64.tar.gz -o /tmp/tctl.tar.gz && \ + tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ + chmod +x /usr/local/bin/tctl && \ + rm /tmp/tctl.tar.gz + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy just the requirements file to optimize caching +COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt + +WORKDIR /app/{{ project_path_from_build_root }} + +# Install the required Python packages +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 + +# Run the ACP server using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/README.md.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/README.md.j2 new file mode 100644 index 000000000..50dcdf164 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/README.md.j2 @@ -0,0 +1,224 @@ +# {{ agent_name }} - AgentEx Temporal + OpenAI Agents SDK Template + +This is a starter template for building AI agents with the AgentEx framework, Temporal workflows, and OpenAI Agents SDK. It provides a production-ready foundation with: + +- **Durable execution** via Temporal workflows +- **AI agent capabilities** via OpenAI Agents SDK +- **Tool use** via Temporal activities +- **Streaming responses** for real-time feedback +- **Conversation state management** across turns +- **Tracing/observability** via SGP integration + +## What You'll Learn + +- **Tasks**: A task is a grouping mechanism for related messages (like a conversation thread) +- **Messages**: Communication objects within a task (text, data, instructions) +- **Temporal Workflows**: Long-running processes with state management and async operations +- **Activities**: Non-deterministic operations (API calls, I/O) that Temporal can retry and recover +- **OpenAI Agents SDK**: Building AI agents with tools, instructions, and streaming + +## Running the Agent + +1. Run the agent locally: +```bash +agentex agents run --manifest manifest.yaml +``` + +The agent will start on port 8000 and be ready to handle conversations. + +## Project Structure + +``` +{{ project_name }}/ +├── project/ # Your agent's code +│ ├── __init__.py +│ ├── acp.py # ACP server with OpenAI plugin setup +│ ├── workflow.py # Temporal workflow with OpenAI agent +│ ├── activities.py # Temporal activities (tools for your agent) +│ └── run_worker.py # Temporal worker setup +├── Dockerfile # Container definition +├── manifest.yaml # Deployment config +├── dev.ipynb # Development notebook for testing +{% if use_uv %} +└── pyproject.toml # Dependencies (uv) +{% else %} +└── requirements.txt # Dependencies (pip) +{% endif %} +``` + +## Key Concepts + +### Activities as Tools + +Activities are Temporal's way of handling non-deterministic operations. In this template, activities also serve as tools for your OpenAI agent: + +```python +# In activities.py - define the activity +@activity.defn +async def get_weather() -> str: + return "Sunny, 72°F" + +# In workflow.py - use it as a tool for the agent +agent = Agent( + name="my-agent", + tools=[ + openai_agents.workflow.activity_as_tool( + get_weather, + start_to_close_timeout=timedelta(minutes=5), + ), + ], +) +``` + +### Conversation State + +The workflow maintains conversation history across turns using `StateModel`: + +```python +class StateModel(BaseModel): + input_list: List[Dict[str, Any]] # Conversation history + turn_number: int # Turn counter for tracing +``` + +### Tracing + +Each conversation turn creates a tracing span for observability: + +```python +async with adk.tracing.span( + trace_id=params.task.id, + name=f"Turn {self._state.turn_number}", + input=turn_input.model_dump(), +) as span: + # Agent execution happens here +``` + +## Adding New Tools/Activities + +See the detailed instructions in `project/activities.py`. The process is: + +1. **Define** the activity in `activities.py` +2. **Register** it in `run_worker.py` +3. **Add** it as a tool in `workflow.py` + +## Temporal Dashboard + +Monitor your workflows and activities at: + +``` +http://localhost:8080 +``` + +The dashboard shows: +- Running and completed workflows +- Activity execution history +- Retries and failures +- Workflow state and signals + +## Development + +### 1. Customize the Agent + +Edit `project/workflow.py` to change: +- Agent instructions +- Model (default: `gpt-4o-mini`) +- Tools available to the agent + +### 2. Add New Activities + +See `project/activities.py` for detailed instructions on adding new tools. + +### 3. Test with the Development Notebook + +```bash +jupyter notebook dev.ipynb +# Or in VS Code +code dev.ipynb +``` + +### 4. Manage Dependencies + +{% if use_uv %} +```bash +# Add new dependencies +agentex uv add requests anthropic + +# Install/sync dependencies +agentex uv sync +``` +{% else %} +```bash +# Add to requirements.txt +echo "requests" >> requirements.txt +pip install -r requirements.txt +``` +{% endif %} + +## Local Development + +### 1. Start the Agentex Backend +```bash +cd agentex +make dev +``` + +### 2. Setup Your Agent's Environment +```bash +{% if use_uv %} +agentex uv sync +source .venv/bin/activate +{% else %} +pip install -r requirements.txt +{% endif %} +``` + +### 3. Run Your Agent +```bash +export ENVIRONMENT=development +agentex agents run --manifest manifest.yaml +``` + +### 4. Interact with Your Agent + +Via Web UI: +```bash +cd agentex-web +make dev +# Open http://localhost:3000 +``` + +## Environment Variables + +For local development, create a `.env` file: + +```bash +LITELLM_API_KEY=your-litellm-key +SGP_API_KEY=your-sgp-key # Optional: for tracing +SGP_ACCOUNT_ID=your-account-id # Optional: for tracing +``` + +## Troubleshooting + +### Common Issues + +1. **Agent not responding** + - Check if agent is running on port 8000 + - Verify `ENVIRONMENT=development` is set + - Check logs for errors + +2. **Temporal workflow issues** + - Check Temporal Web UI at http://localhost:8080 + - Verify Temporal server is running + - Check workflow logs + +3. **OpenAI API errors** + - Verify `LITELLM_API_KEY` is set + - Check API rate limits + - Verify model name is correct + +4. **Activity failures** + - Check activity logs in console + - Verify activity is registered in `run_worker.py` + - Check timeout settings + +Happy building with Temporal + OpenAI Agents SDK! diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/dev.ipynb.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/dev.ipynb.j2 new file mode 100644 index 000000000..d3a68303f --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/dev.ipynb.j2 @@ -0,0 +1,126 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"{{ agent_name }}\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [], + "source": [ + "# (REQUIRED) Create a new task. For Async agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b03b0d37", + "metadata": {}, + "outputs": [], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a6927cc0", + "metadata": {}, + "outputs": [], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4864e354", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/environments.yaml.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/environments.yaml.j2 new file mode 100644 index 000000000..a3df5e228 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/environments.yaml.j2 @@ -0,0 +1,64 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-{{agent_name}}" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + # This is used to override the global helm values.yaml file in the agentex-agent helm charts + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + temporal-worker: + enabled: true + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/manifest.yaml.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/manifest.yaml.j2 new file mode 100644 index 000000000..b9216929f --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/manifest.yaml.j2 @@ -0,0 +1,140 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../ # Keep this as the default root + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - {{ project_path_from_build_root }} + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: {{ project_path_from_build_root }}/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: {{ project_path_from_build_root }}/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + # Path to temporal worker file + # Examples: + # project/run_worker.py (standard) + # workers/temporal.py (custom structure) + # ../shared/worker.py (shared across projects) + worker: project/run_worker.py + + +# Agent Configuration +# ----------------- +agent: + # Type of agent - either sync or async + acp_type: async + + # Unique name for your agent + # Used for task routing and monitoring + name: {{ agent_name }} + + # Description of what your agent does + # Helps with documentation and discovery + description: {{ description | tojson }} + + # Temporal workflow configuration + # This enables your agent to run as a Temporal workflow for long-running tasks + temporal: + enabled: true + workflows: + # Name of the workflow class + # Must match the @workflow.defn name in your workflow.py + - name: {{ workflow_name }} + + # Queue name for task distribution + # Used by Temporal to route tasks to your agent + # Convention: _task_queue + queue_name: {{ queue_name }} + + # Optional: Health check port for temporal worker + # Defaults to 80 if not specified + # health_check_port: 80 + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + credentials: + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + # - env_var_name: LITELLM_API_KEY + # secret_name: litellm-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + env: {} + # LITELLM_API_KEY: "" + # OPENAI_BASE_URL: "" + # OPENAI_ORG_ID: "" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: [] # Update with your image pull secret name + # - name: my-registry-secret + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific environments (environments.yaml) + global: + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/project/acp.py.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/project/acp.py.j2 new file mode 100644 index 000000000..93ed6e659 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/project/acp.py.j2 @@ -0,0 +1,86 @@ +import os +import sys + +# LiteLLM proxy auth: copy LITELLM_API_KEY to OPENAI_API_KEY for OpenAI client compatibility +_litellm_key = os.environ.get("LITELLM_API_KEY") +if _litellm_key: + os.environ["OPENAI_API_KEY"] = _litellm_key + +from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters +from datetime import timedelta +from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ContextInterceptor +from agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model import ( + TemporalStreamingModelProvider, +) + +# === DEBUG SETUP (AgentEx CLI Debug Support) === +if os.getenv("AGENTEX_DEBUG_ENABLED") == "true": + try: + import debugpy + from agentex.lib.utils.logging import make_logger + + logger = make_logger(__name__) + debug_port = int(os.getenv("AGENTEX_DEBUG_PORT", "5679")) + debug_type = os.getenv("AGENTEX_DEBUG_TYPE", "acp") + wait_for_attach = os.getenv("AGENTEX_DEBUG_WAIT_FOR_ATTACH", "false").lower() == "true" + + # Configure debugpy + debugpy.configure(subProcess=False) + debugpy.listen(debug_port) + + logger.info(f"[{debug_type.upper()}] Debug server listening on port {debug_port}") + + if wait_for_attach: + logger.info(f"[{debug_type.upper()}] Waiting for debugger to attach...") + debugpy.wait_for_client() + logger.info(f"[{debug_type.upper()}] Debugger attached!") + else: + logger.info(f"[{debug_type.upper()}] Ready for debugger attachment") + + except ImportError: + print("debugpy not available. Install with: pip install debugpy") + sys.exit(1) + except Exception as e: + print(f"Debug setup failed: {e}") + sys.exit(1) +# === END DEBUG SETUP === + +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.lib.types.fastacp import TemporalACPConfig + +context_interceptor = ContextInterceptor() +streaming_model_provider = TemporalStreamingModelProvider() + + +# Create the ACP server +acp = FastACP.create( + acp_type="async", + config=TemporalACPConfig( + # When deployed to the cluster, the Temporal address will automatically be set to the cluster address + # For local development, we set the address manually to talk to the local Temporal service set up via docker compose + type="temporal", + temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[OpenAIAgentsPlugin( + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(days=1) + ), + model_provider=streaming_model_provider + )], + interceptors=[context_interceptor] + ) +) + + +# Notice that we don't need to register any handlers when we use type="temporal" +# If you look at the code in agentex.sdk.fastacp.impl.temporal_acp +# You can see that these handlers are automatically registered when the ACP is created + +# @acp.on_task_create +# This will be handled by the method in your workflow that is decorated with @workflow.run + +# @acp.on_task_event_send +# This will be handled by the method in your workflow that is decorated with @workflow.signal(name=SignalName.RECEIVE_MESSAGE) + +# @acp.on_task_cancel +# This does not need to be handled by your workflow. +# It is automatically handled by the temporal client which cancels the workflow directly diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/project/activities.py.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/project/activities.py.j2 new file mode 100644 index 000000000..907cb287a --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/project/activities.py.j2 @@ -0,0 +1,116 @@ +""" +Temporal Activities for OpenAI Agents SDK +========================================== + +WHAT ARE ACTIVITIES? +-------------------- +Activities are functions that perform non-deterministic operations - things that +might have different results each time they run, such as: +- API calls (weather services, databases, external services) +- File I/O operations +- Current time/date lookups +- Random number generation +- Any operation with side effects + +Temporal workflows must be deterministic (same input = same output every time). +Activities let you safely perform non-deterministic work while Temporal handles +retries, timeouts, and failure recovery automatically. + + +HOW TO ADD NEW ACTIVITIES: +-------------------------- +Adding a new activity requires 3 steps: + +1. DEFINE the activity in this file with the @activity.defn decorator: + + @activity.defn + async def my_new_activity(param: str) -> str: + # Your non-deterministic logic here + return result + +2. REGISTER it in run_worker.py by adding to the activities list: + + from project.activities import get_weather, my_new_activity + + all_activities = get_all_activities() + [ + stream_lifecycle_content, + get_weather, + my_new_activity, # Add your new activity here + ] + +3. ADD it as a tool to your OpenAI agent in workflow.py: + + from project.activities import get_weather, my_new_activity + + agent = Agent( + name="...", + tools=[ + openai_agents.workflow.activity_as_tool( + get_weather, + start_to_close_timeout=timedelta(minutes=5), + ), + openai_agents.workflow.activity_as_tool( + my_new_activity, # Add your new activity as a tool + start_to_close_timeout=timedelta(minutes=5), + ), + ], + ) + + +RUNNING ACTIVITIES OUTSIDE OPENAI AGENT SDK: +-------------------------------------------- +You can also call activities directly from your workflow without going through +the OpenAI agent. This is useful for setup/teardown operations or when you need +to run an activity before the agent starts: + + from temporalio import workflow + from datetime import timedelta + + # Inside your workflow method: + result = await workflow.execute_activity( + get_weather, + start_to_close_timeout=timedelta(minutes=5), + ) + +For activities with parameters: + + result = await workflow.execute_activity( + my_activity_with_params, + "param_value", # positional args + start_to_close_timeout=timedelta(minutes=5), + ) + + +TEMPORAL DASHBOARD: +------------------- +Monitor your workflows and activities in real-time at: + + http://localhost:8080 + +The dashboard shows: +- Running and completed workflows +- Activity execution history +- Retries and failures +- Workflow state and signals +""" + +from temporalio import activity + +from agentex.lib.utils.logging import make_logger + +logger = make_logger(__name__) + + +@activity.defn +async def get_weather() -> str: + """ + Get the current weather. + + This is a dummy activity that returns a hardcoded string for demo purposes. + Replace this with a real weather API call in your implementation. + + Returns: + A string describing the current weather conditions. + """ + logger.info("get_weather activity called") + return "Sunny, 72°F" diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/project/run_worker.py.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/project/run_worker.py.j2 new file mode 100644 index 000000000..2516d3e0b --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/project/run_worker.py.j2 @@ -0,0 +1,56 @@ +import asyncio + +from agentex.lib.core.temporal.activities import get_all_activities +from agentex.lib.core.temporal.workers.worker import AgentexWorker +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.debug import setup_debug_if_enabled +from agentex.lib.environment_variables import EnvironmentVariables +from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters +from datetime import timedelta +from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ContextInterceptor +from agentex.lib.core.temporal.plugins.openai_agents.hooks.activities import stream_lifecycle_content +from agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model import ( + TemporalStreamingModelProvider, +) +from project.workflow import {{ workflow_class }} +from project.activities import get_weather + +environment_variables = EnvironmentVariables.refresh() + +logger = make_logger(__name__) + + +async def main(): + # Setup debug mode if enabled + setup_debug_if_enabled() + + task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE + if task_queue_name is None: + raise ValueError("WORKFLOW_TASK_QUEUE is not set") + + # Register all activities here + # When you add new activities in activities.py, add them to this list + all_activities = get_all_activities() + [stream_lifecycle_content, get_weather] + + context_interceptor = ContextInterceptor() + streaming_model_provider = TemporalStreamingModelProvider() + + # Create a worker with automatic tracing + worker = AgentexWorker( + task_queue=task_queue_name, + plugins=[OpenAIAgentsPlugin( + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(days=1) + ), + model_provider=streaming_model_provider + )], + interceptors=[context_interceptor], + ) + + await worker.run( + activities=all_activities, + workflow={{ workflow_class }}, + ) + +if __name__ == "__main__": + asyncio.run(main()) 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 new file mode 100644 index 000000000..af8b7a299 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/project/workflow.py.j2 @@ -0,0 +1,181 @@ +import json +import os + +from temporalio import workflow + +from agentex.lib import adk +from agentex.protocol.acp import CreateTaskParams, SendEventParams +from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow +from agentex.lib.core.temporal.types.workflow import SignalName +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.environment_variables import EnvironmentVariables +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 +from typing import List, Dict, Any +from temporalio.contrib import openai_agents +from project.activities import get_weather +from agentex.lib.core.tracing.tracing_processor_manager import ( + add_tracing_processor_config, +) +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from datetime import timedelta + + +environment_variables = EnvironmentVariables.refresh() + +if environment_variables.WORKFLOW_NAME is None: + raise ValueError("Environment variable WORKFLOW_NAME is not set") + +if environment_variables.AGENT_NAME is None: + raise ValueError("Environment variable AGENT_NAME is not set") + +logger = make_logger(__name__) + +# Setup tracing for SGP (Scale GenAI Platform) +# This enables visibility into your agent's execution in the SGP dashboard +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) +) + + +class StateModel(BaseModel): + """ + State model for preserving conversation history across turns. + + This allows the agent to maintain context throughout the conversation, + making it possible to reference previous messages and build on the discussion. + + Attributes: + input_list: The conversation history in OpenAI message format. + turn_number: Counter for tracking conversation turns (useful for tracing). + """ + + input_list: List[Dict[str, Any]] + turn_number: int + + +class TurnInput(BaseModel): + """Input model for tracing spans.""" + input_list: List[Dict[str, Any]] + + +class TurnOutput(BaseModel): + """Output model for tracing spans.""" + final_output: Any + + +@workflow.defn(name=environment_variables.WORKFLOW_NAME) +class {{ workflow_class }}(BaseWorkflow): + """ + Workflow for {{ agent_name }} agent using OpenAI Agents SDK. + + This workflow: + - Maintains conversation state across turns + - Creates tracing spans for each turn + - Runs an OpenAI agent with tools (activities) + - Streams responses back to the client + """ + + def __init__(self): + super().__init__(display_name=environment_variables.AGENT_NAME) + self._complete_task = False + self._state: StateModel = StateModel(input_list=[], turn_number=0) + self._task_id = None + self._trace_id = None + self._parent_span_id = None + + @workflow.signal(name=SignalName.RECEIVE_EVENT) + async def on_task_event_send(self, params: SendEventParams) -> None: + logger.info(f"Received task message instruction: {params}") + + content = params.event.content + if not isinstance(content, TextContent): + logger.warning("Ignoring non-text event content (type=%s)", getattr(content, "type", "?")) + return + + # Increment turn number for tracing + self._state.turn_number += 1 + + self._task_id = params.task.id + self._trace_id = params.task.id + self._parent_span_id = params.task.id + + # Add the user message to conversation history + self._state.input_list.append({"role": "user", "content": content.content}) + + # Echo back the client's message to show it in the UI + await adk.messages.create(task_id=params.task.id, content=params.event.content) + + temporal_streaming_hooks = TemporalStreamingHooks(task_id=params.task.id) + + # Create a span to track this turn of the conversation + turn_input = TurnInput( + input_list=self._state.input_list, + ) + async with adk.tracing.span( + trace_id=params.task.id, + name=f"Turn {self._state.turn_number}", + input=turn_input.model_dump(), + ) as span: + self._parent_span_id = span.id if span else None + + # Create the OpenAI agent with tools + # Add your activities as tools using activity_as_tool() + agent = Agent( + name="{{ agent_name }}", + instructions="You are a helpful assistant. Use your tools to help the user.", + model="gpt-4o-mini", + tools=[ + openai_agents.workflow.activity_as_tool( + get_weather, + start_to_close_timeout=timedelta(minutes=5), + ), + # Add more tools here as you create new activities: + # openai_agents.workflow.activity_as_tool( + # your_new_activity, + # start_to_close_timeout=timedelta(minutes=5), + # ), + ], + ) + + # Run the agent with hooks to enable streaming responses + result = await Runner.run(agent, self._state.input_list, hooks=temporal_streaming_hooks) + + # Update the state with the assistant's response for the next turn + self._state.input_list = result.to_input_list() # type: ignore[assignment] + + # Set span output for tracing - include full state + if span: + turn_output = TurnOutput(final_output=result.final_output) + span.output = turn_output.model_dump() + + @workflow.run + async def on_task_create(self, params: CreateTaskParams) -> str: + logger.info(f"Received task create params: {params}") + + # Acknowledge that the task has been created + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"Hello! I'm {{ agent_name }}, your AI assistant. How can I help you today?\n\nParams received:\n{json.dumps(params.params, indent=2)}", + ), + ) + + await workflow.wait_condition( + lambda: self._complete_task, + timeout=None, + ) + return "Task completed" diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/pyproject.toml.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/pyproject.toml.j2 new file mode 100644 index 000000000..a1ebab933 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/pyproject.toml.j2 @@ -0,0 +1,35 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ project_name }}" +version = "0.1.0" +description = "{{ description }}" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "temporalio", + "openai-agents>=0.4.2", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", + "debugpy>=1.8.15", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/requirements.txt.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/requirements.txt.j2 new file mode 100644 index 000000000..d4bd7a0fa --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/requirements.txt.j2 @@ -0,0 +1,4 @@ +agentex-sdk +scale-gp +temporalio +openai-agents>=0.4.2 diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/test_agent.py.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/test_agent.py.j2 new file mode 100644 index 000000000..ee71f177c --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/test_agent.py.j2 @@ -0,0 +1,147 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: {{ agent_name }}) +""" + +import os +import uuid +import asyncio +import pytest +import pytest_asyncio +from agentex import AsyncAgentex +from agentex.types import TaskMessage +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam +from test_utils.async_utils import ( + poll_for_agent_response, + send_event_and_poll_yielding, + stream_agent_response, + validate_text_in_response, + poll_messages, +) + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "{{ agent_name }}") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, _agent_name: str, agent_id: str): + """Test sending an event and polling for the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # TODO: Poll for the initial task creation message (if your agent sends one) + # async for message in poll_messages( + # client=client, + # task_id=task.id, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected initial message + # assert "expected initial text" in message.content.content + # break + + # TODO: Send an event and poll for response using the yielding helper function + # user_message = "Your test message here" + # async for message in send_event_and_poll_yielding( + # client=client, + # agent_id=agent_id, + # task_id=task.id, + # user_message=user_message, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected response + # assert "expected response text" in message.content.content + # break + pass + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, _agent_name: str, agent_id: str): + """Test sending an event and streaming the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # user_message = "Your test message here" + + # # Collect events from stream + # all_events = [] + + # async def collect_stream_events(): + # async for event in stream_agent_response( + # client=client, + # task_id=task.id, + # timeout=30, + # ): + # all_events.append(event) + + # # Start streaming task + # stream_task = asyncio.create_task(collect_stream_events()) + + # # Send the event + # event_content = TextContentParam(type="text", author="user", content=user_message) + # await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + + # # Wait for streaming to complete + # await stream_task + + # # TODO: Add your validation here + # assert len(all_events) > 0, "No events received in streaming response" + pass + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/.dockerignore.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/.dockerignore.j2 new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/.dockerignore.j2 @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/.env.example.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/.env.example.j2 new file mode 100644 index 000000000..1e81b15dd --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/.env.example.j2 @@ -0,0 +1,12 @@ +# {{ agent_name }} - Environment Variables +# Copy this file to .env and fill in the values + +# API key for your LLM provider +LITELLM_API_KEY= + +# LLM base URL (optional - override to use a different provider) +# OPENAI_BASE_URL= + +# SGP Configuration (optional - for tracing) +# SGP_API_KEY= +# SGP_ACCOUNT_ID= 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 new file mode 100644 index 000000000..0d9801016 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile-uv.j2 @@ -0,0 +1,55 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +# Install tctl (Temporal CLI) +RUN curl -L https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_arm64.tar.gz -o /tmp/tctl.tar.gz && \ + tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ + chmod +x /usr/local/bin/tctl && \ + rm /tmp/tctl.tar.gz + +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +ENV UV_HTTP_TIMEOUT=1000 + +WORKDIR /app/{{ project_path_from_build_root }} + +# Copy dependency files for layer caching +COPY {{ project_path_from_build_root }}/pyproject.toml ./ + +# Install dependencies (without project itself, for layer caching) +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-install-project --no-dev + +# Copy the project code +COPY {{ project_path_from_build_root }}/project ./project + +# Install the project +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" + +# Run the ACP server using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile.j2 new file mode 100644 index 000000000..4c1798c42 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile.j2 @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + node \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install tctl (Temporal CLI) +RUN curl -L https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_arm64.tar.gz -o /tmp/tctl.tar.gz && \ + tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ + chmod +x /usr/local/bin/tctl && \ + rm /tmp/tctl.tar.gz + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy just the requirements file to optimize caching +COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt + +WORKDIR /app/{{ project_path_from_build_root }} + +# Install the required Python packages +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 + +# Run the ACP server using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/README.md.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/README.md.j2 new file mode 100644 index 000000000..ca1abcc7f --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/README.md.j2 @@ -0,0 +1,227 @@ +# {{ agent_name }} - AgentEx Temporal + Pydantic AI + +A starter template for building AI agents with AgentEx, Temporal workflows, and +[Pydantic AI](https://ai.pydantic.dev/). Production-ready foundation with: + +- **Durable execution** via Temporal workflows +- **Typed AI agent** via Pydantic AI's `Agent` (and `TemporalAgent` durable wrapper) +- **Tool use** — each tool call runs as its own retried, observable Temporal activity +- **Streaming responses** — tokens delta-stream to Agentex via Redis from inside the model activity +- **Multi-turn conversation state** — kept on the workflow instance, durable for free +- **Tracing/observability** — per-turn span with per-tool-call children, shipped to SGP/AgentEx + +## What You'll Learn + +- **Tasks**: A task is a grouping mechanism for related messages (like a conversation thread) +- **Messages**: Communication objects within a task (text, data, instructions) +- **Temporal Workflows**: Long-running processes with state management and async operations +- **Activities**: Non-deterministic operations (LLM calls, tool execution) that Temporal records and retries +- **Pydantic AI**: A typed agent framework that handles the tool-call loop, structured output, and streaming +- **TemporalAgent**: The pydantic-ai wrapper that converts every model/tool call into a Temporal activity + +## Running the Agent + +1. Run the agent locally: +```bash +agentex agents run --manifest manifest.yaml +``` + +The agent will start on port 8000 and be ready to handle conversations. + +## Project Structure + +``` +{{ project_name }}/ +├── project/ # Your agent's code +│ ├── __init__.py +│ ├── acp.py # ACP server with PydanticAIPlugin setup +│ ├── workflow.py # Temporal workflow + multi-turn state +│ ├── agent.py # Pydantic AI Agent + TemporalAgent wrapping +│ ├── tools.py # Tool function implementations +│ └── run_worker.py # Temporal worker setup +├── Dockerfile # Container definition +├── manifest.yaml # Deployment config +├── dev.ipynb # Development notebook for testing +{% if use_uv %} +└── pyproject.toml # Dependencies (uv) +{% else %} +└── requirements.txt # Dependencies (pip) +{% endif %} +``` + +## Key Concepts + +### Activities as Tools + +Activities are Temporal's way of handling non-deterministic operations. In this template, activities also serve as tools for your OpenAI agent: + +```python +# In activities.py - define the activity +@activity.defn +async def get_weather() -> str: + return "Sunny, 72°F" + +# In workflow.py - use it as a tool for the agent +agent = Agent( + name="my-agent", + tools=[ + openai_agents.workflow.activity_as_tool( + get_weather, + start_to_close_timeout=timedelta(minutes=5), + ), + ], +) +``` + +### Conversation State + +The workflow maintains conversation history across turns using `StateModel`: + +```python +class StateModel(BaseModel): + input_list: List[Dict[str, Any]] # Conversation history + turn_number: int # Turn counter for tracing +``` + +### Tracing + +Each conversation turn creates a tracing span for observability: + +```python +async with adk.tracing.span( + trace_id=params.task.id, + name=f"Turn {self._state.turn_number}", + input=turn_input.model_dump(), +) as span: + # Agent execution happens here +``` + +## Adding New Tools/Activities + +See the detailed instructions in `project/activities.py`. The process is: + +1. **Define** the activity in `activities.py` +2. **Register** it in `run_worker.py` +3. **Add** it as a tool in `workflow.py` + +## Temporal Dashboard + +Monitor your workflows and activities at: + +``` +http://localhost:8080 +``` + +The dashboard shows: +- Running and completed workflows +- Activity execution history +- Retries and failures +- Workflow state and signals + +## Development + +### 1. Customize the Agent + +Edit `project/workflow.py` to change: +- Agent instructions +- Model (default: `gpt-4o-mini`) +- Tools available to the agent + +### 2. Add New Activities + +See `project/activities.py` for detailed instructions on adding new tools. + +### 3. Test with the Development Notebook + +```bash +jupyter notebook dev.ipynb +# Or in VS Code +code dev.ipynb +``` + +### 4. Manage Dependencies + +{% if use_uv %} +```bash +# Add new dependencies +agentex uv add requests anthropic + +# Install/sync dependencies +agentex uv sync +``` +{% else %} +```bash +# Add to requirements.txt +echo "requests" >> requirements.txt +pip install -r requirements.txt +``` +{% endif %} + +## Local Development + +### 1. Start the Agentex Backend +```bash +cd agentex +make dev +``` + +### 2. Setup Your Agent's Environment +```bash +{% if use_uv %} +agentex uv sync +source .venv/bin/activate +{% else %} +pip install -r requirements.txt +{% endif %} +``` + +### 3. Run Your Agent +```bash +export ENVIRONMENT=development +agentex agents run --manifest manifest.yaml +``` + +### 4. Interact with Your Agent + +Via Web UI: +```bash +cd agentex-web +make dev +# Open http://localhost:3000 +``` + +## Environment Variables + +For local development, create a `.env` file: + +```bash +LITELLM_API_KEY=your-litellm-key +SGP_API_KEY=your-sgp-key # Optional: for tracing +SGP_ACCOUNT_ID=your-account-id # Optional: for tracing +``` + +## Troubleshooting + +### Common Issues + +1. **Agent not responding** + - Check if agent is running on port 8000 + - Verify `ENVIRONMENT=development` is set + - Check logs for errors + +2. **Temporal workflow issues** + - Check Temporal Web UI at http://localhost:8080 + - Verify Temporal server is running + - Check workflow logs + +3. **OpenAI API errors** + - Verify `LITELLM_API_KEY` is set + - Check API rate limits + - Verify model name is correct + +4. **Activity failures** + - Check activity logs in console + - Verify activity is registered in `run_worker.py` + - Check timeout settings + +Happy building with Temporal + OpenAI Agents SDK! diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/dev.ipynb.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/dev.ipynb.j2 new file mode 100644 index 000000000..d3a68303f --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/dev.ipynb.j2 @@ -0,0 +1,126 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"{{ agent_name }}\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [], + "source": [ + "# (REQUIRED) Create a new task. For Async agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b03b0d37", + "metadata": {}, + "outputs": [], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a6927cc0", + "metadata": {}, + "outputs": [], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4864e354", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/environments.yaml.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/environments.yaml.j2 new file mode 100644 index 000000000..a3df5e228 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/environments.yaml.j2 @@ -0,0 +1,64 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-{{agent_name}}" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + # This is used to override the global helm values.yaml file in the agentex-agent helm charts + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + temporal-worker: + enabled: true + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/manifest.yaml.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/manifest.yaml.j2 new file mode 100644 index 000000000..b9216929f --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/manifest.yaml.j2 @@ -0,0 +1,140 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../ # Keep this as the default root + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - {{ project_path_from_build_root }} + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: {{ project_path_from_build_root }}/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: {{ project_path_from_build_root }}/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + # Path to temporal worker file + # Examples: + # project/run_worker.py (standard) + # workers/temporal.py (custom structure) + # ../shared/worker.py (shared across projects) + worker: project/run_worker.py + + +# Agent Configuration +# ----------------- +agent: + # Type of agent - either sync or async + acp_type: async + + # Unique name for your agent + # Used for task routing and monitoring + name: {{ agent_name }} + + # Description of what your agent does + # Helps with documentation and discovery + description: {{ description | tojson }} + + # Temporal workflow configuration + # This enables your agent to run as a Temporal workflow for long-running tasks + temporal: + enabled: true + workflows: + # Name of the workflow class + # Must match the @workflow.defn name in your workflow.py + - name: {{ workflow_name }} + + # Queue name for task distribution + # Used by Temporal to route tasks to your agent + # Convention: _task_queue + queue_name: {{ queue_name }} + + # Optional: Health check port for temporal worker + # Defaults to 80 if not specified + # health_check_port: 80 + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + credentials: + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + # - env_var_name: LITELLM_API_KEY + # secret_name: litellm-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + env: {} + # LITELLM_API_KEY: "" + # OPENAI_BASE_URL: "" + # OPENAI_ORG_ID: "" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: [] # Update with your image pull secret name + # - name: my-registry-secret + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific environments (environments.yaml) + global: + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/acp.py.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/acp.py.j2 new file mode 100644 index 000000000..dde726905 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/acp.py.j2 @@ -0,0 +1,35 @@ +"""ACP server for the Temporal Pydantic AI agent. + +This file is intentionally thin. When ``acp_type="async"`` is combined +with ``TemporalACPConfig(type="temporal", ...)``, FastACP auto-wires: + + HTTP task/create → @workflow.run on the workflow class + HTTP task/event/send → @workflow.signal(SignalName.RECEIVE_EVENT) + HTTP task/cancel → workflow cancellation via the Temporal client + +so we don't define any handlers here. The agent code lives in +``project/workflow.py`` and is executed by the Temporal worker +(``project/run_worker.py``), not by this HTTP process. +""" + +from __future__ import annotations + +import os + +from dotenv import load_dotenv + +load_dotenv() + +from pydantic_ai.durable_exec.temporal import PydanticAIPlugin + +from agentex.lib.types.fastacp import TemporalACPConfig +from agentex.lib.sdk.fastacp.fastacp import FastACP + +acp = FastACP.create( + acp_type="async", + config=TemporalACPConfig( + type="temporal", + temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[PydanticAIPlugin()], + ), +) diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/agent.py.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/agent.py.j2 new file mode 100644 index 000000000..da97856ea --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/agent.py.j2 @@ -0,0 +1,115 @@ +"""Pydantic AI agent definition for {{ agent_name }}. + +Constructs the base ``pydantic_ai.Agent`` once at import time, registers +tools, and wraps it in ``TemporalAgent`` from +``pydantic_ai.durable_exec.temporal``. + +The ``TemporalAgent`` wrapper makes every model call and every tool call +run as a Temporal activity automatically. The workflow code stays +deterministic; the non-deterministic work (LLM HTTP calls, tool execution) +moves into recorded activities. + +Streaming back to Agentex happens via ``event_stream_handler``, which +receives Pydantic AI ``AgentStreamEvent``s from inside the model activity +and forwards them through the unified harness surface +(``UnifiedEmitter.auto_send_turn`` + ``PydanticAITurn``). The ``task_id`` and +tracing parent span ID are threaded into the handler via ``deps``. +""" + +from __future__ import annotations + +from datetime import datetime +from collections.abc import AsyncIterable + +from pydantic import BaseModel +from pydantic_ai import Agent, RunContext +from project.tools import get_weather +from pydantic_ai.messages import AgentStreamEvent +from pydantic_ai.durable_exec.temporal import TemporalAgent + +from agentex.lib.core.harness import UnifiedEmitter +from agentex.lib.adk import PydanticAITurn + +# Swap this for any Pydantic AI-supported model identifier +# (e.g. "anthropic:claude-3-5-sonnet-latest", "openai:gpt-4o"). +MODEL_NAME = "openai:gpt-4o-mini" + +SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. + +Current date and time: {timestamp} + +Guidelines: +- Be concise and helpful +- Use tools when they would help answer the user's question +- If you're unsure, ask clarifying questions +- Always provide accurate information +""" + + +class TaskDeps(BaseModel): + """Per-run dependencies passed into the agent via ``deps=``. + + Pydantic AI's ``RunContext.deps`` is the canonical place to thread + request-scoped data (like the Agentex task_id) into tools and event + handlers — including code that runs inside Temporal activities. + """ + + task_id: str + # When set, the event handler nests per-tool-call spans under this + # span. Typically the ID of the per-turn span opened by the workflow. + parent_span_id: str | None = None + + +def _build_base_agent() -> Agent[TaskDeps, str]: + """Build the underlying Pydantic AI agent with tools registered. + + Tools must be registered BEFORE the agent is wrapped in TemporalAgent; + changes to tool registration after wrapping are not reflected. + """ + agent: Agent[TaskDeps, str] = Agent( + MODEL_NAME, + deps_type=TaskDeps, + system_prompt=SYSTEM_PROMPT.format( + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + ), + ) + + # Register additional tools by adding more `agent.tool_plain(...)` calls. + agent.tool_plain(get_weather) + return agent + + +async def event_handler( + run_context: RunContext[TaskDeps], + events: AsyncIterable[AgentStreamEvent], +) -> None: + """Stream Pydantic AI events to Agentex via Redis from inside the model activity. + + Pydantic AI calls this with the live event stream as soon as the model + activity begins emitting parts. Because the handler runs inside the + activity (not the workflow), it can freely make non-deterministic Redis + writes — including the tracing HTTP calls that record per-tool-call + spans under the workflow's per-turn span (when ``parent_span_id`` is set). + + The UnifiedEmitter is constructed from ``deps`` (task_id + parent_span_id), + so tool spans nest under the workflow's per-turn span and messages auto-send + to the task stream. + """ + emitter = UnifiedEmitter( + task_id=run_context.deps.task_id, + trace_id=run_context.deps.task_id, + parent_span_id=run_context.deps.parent_span_id, + ) + turn = PydanticAITurn(events, model=MODEL_NAME) + await emitter.auto_send_turn(turn) + + +# Construct the durable agent at module load time so that the +# PydanticAIPlugin can auto-discover its activities via the workflow's +# ``__pydantic_ai_agents__`` attribute. +base_agent = _build_base_agent() +temporal_agent: TemporalAgent[TaskDeps, str] = TemporalAgent( + base_agent, + name="{{ project_name }}_agent", + event_stream_handler=event_handler, +) diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/run_worker.py.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/run_worker.py.j2 new file mode 100644 index 000000000..29c4c7aa5 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/run_worker.py.j2 @@ -0,0 +1,48 @@ +"""Temporal worker for {{ agent_name }}. + +Run as a separate long-lived process alongside the ACP HTTP server. The +worker polls Temporal for workflow + activity tasks and executes them. + +The ``PydanticAIPlugin`` reads ``__pydantic_ai_agents__`` off the workflow +class and registers every model/tool activity the TemporalAgent needs — +so we don't have to enumerate activities by hand here. +""" + +import asyncio + +from project.workflow import {{ workflow_class }} +from pydantic_ai.durable_exec.temporal import PydanticAIPlugin + +from agentex.lib.utils.debug import setup_debug_if_enabled +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.activities import get_all_activities +from agentex.lib.core.temporal.workers.worker import AgentexWorker + +environment_variables = EnvironmentVariables.refresh() +logger = make_logger(__name__) + + +async def main(): + setup_debug_if_enabled() + + task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE + if task_queue_name is None: + raise ValueError("WORKFLOW_TASK_QUEUE is not set") + + # get_all_activities() returns the built-in Agentex activities (state, + # messages, streaming, tracing). Pydantic AI's TemporalAgent activities + # are auto-registered by PydanticAIPlugin via __pydantic_ai_agents__. + worker = AgentexWorker( + task_queue=task_queue_name, + plugins=[PydanticAIPlugin()], + ) + + await worker.run( + activities=get_all_activities(), + workflow={{ workflow_class }}, + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/tools.py.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/tools.py.j2 new file mode 100644 index 000000000..bab87942a --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/tools.py.j2 @@ -0,0 +1,20 @@ +"""Tool definitions for the Pydantic AI agent. + +Pydantic AI tools are registered directly on the Agent via decorators +(see project.agent). This module hosts the bare functions so they're +easy to unit-test in isolation. +""" + +from __future__ import annotations + + +def get_weather(city: str) -> str: + """Get the current weather for a city. + + Args: + city: The name of the city to get weather for. + + Returns: + A string describing the weather conditions. + """ + return f"The weather in {city} is sunny and 72°F" 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 new file mode 100644 index 000000000..6dcca3002 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/workflow.py.j2 @@ -0,0 +1,153 @@ +"""Temporal workflow for {{ agent_name }}. + +The workflow holds task state durably across crashes. Its signal handler +delegates the actual agent run to ``temporal_agent.run(...)`` — which +internally schedules model and tool activities, each independently +durable. The ``event_stream_handler`` registered on ``temporal_agent`` +pushes streaming deltas to Redis while the model activity runs. + +Multi-turn memory is kept on the workflow instance itself +(``self._message_history``). Temporal's workflow state is already durable +and replay-safe, so unlike the async-base template we don't need an +external ``adk.state`` round-trip — the message list survives crashes +because Temporal replays the activity results that produced it. +""" + +from __future__ import annotations + +import os +import json +from typing import TYPE_CHECKING + +from temporalio import workflow +from project.agent import TaskDeps, temporal_agent + +from agentex.lib import adk +from agentex.protocol.acp import SendEventParams, CreateTaskParams +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.types.workflow import SignalName +from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config + +if TYPE_CHECKING: + from pydantic_ai.messages import ModelMessage + +# Register the SGP tracing exporter. Spans also reach the AgentEx backend +# via the default Agentex processor that's lazy-initialised on first span. +SGP_API_KEY = os.environ.get("SGP_API_KEY", "") +SGP_ACCOUNT_ID = os.environ.get("SGP_ACCOUNT_ID", "") +if SGP_API_KEY and SGP_ACCOUNT_ID: + add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=SGP_API_KEY, + sgp_account_id=SGP_ACCOUNT_ID, + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) + ) + +environment_variables = EnvironmentVariables.refresh() + +if environment_variables.WORKFLOW_NAME is None: + raise ValueError("Environment variable WORKFLOW_NAME is not set") +if environment_variables.AGENT_NAME is None: + raise ValueError("Environment variable AGENT_NAME is not set") + +logger = make_logger(__name__) + + +@workflow.defn(name=environment_variables.WORKFLOW_NAME) +class {{ workflow_class }}(BaseWorkflow): + """Long-running Temporal workflow that delegates each turn to a Pydantic AI TemporalAgent. + + The ``__pydantic_ai_agents__`` attribute is the marker the + ``PydanticAIPlugin`` looks for at worker startup: it pulls + ``temporal_agent.temporal_activities`` off this list and registers + every model/tool activity on the worker automatically — so we don't + have to enumerate activities by hand in ``run_worker.py``. + """ + + __pydantic_ai_agents__ = [temporal_agent] + + def __init__(self): + super().__init__(display_name=environment_variables.AGENT_NAME) + self._complete_task = False + self._turn_number = 0 + # Conversation history accumulated across turns. Each entry is a + # pydantic-ai ``ModelMessage``. Temporal replays the activity that + # produced these messages, so the list is rebuilt deterministically + # if the workflow ever recovers from a crash. + self._message_history: list["ModelMessage"] = [] + + @workflow.signal(name=SignalName.RECEIVE_EVENT) + async def on_task_event_send(self, params: SendEventParams) -> None: + """Handle a new user message: echo it, then run the agent durably.""" + logger.info(f"Received task event: {params.task.id}") + + content = params.event.content + if not isinstance(content, TextContent): + logger.warning("Ignoring non-text event content (type=%s)", getattr(content, "type", "?")) + return + user_message = content.content + + self._turn_number += 1 + + # Echo the user's message so it shows up in the UI as a chat bubble. + await adk.messages.create(task_id=params.task.id, content=params.event.content) + + async with adk.tracing.span( + trace_id=params.task.id, + task_id=params.task.id, + name=f"Turn {self._turn_number}", + input={"message": user_message}, + ) as span: + # temporal_agent.run() is the magic line. Internally it schedules + # a model activity (LLM HTTP call) and, for each tool the model + # invokes, a separate tool activity. Each is independently + # durable and retried. While the model activity runs, the + # event_stream_handler on temporal_agent pushes deltas to Redis + # so the UI sees tokens stream live. + # + # Passing ``message_history`` makes the run remember prior turns; + # without it the agent would respond to each user message as if + # it had never seen the conversation before. + result = await temporal_agent.run( + user_message, + message_history=self._message_history, + deps=TaskDeps( + task_id=params.task.id, + parent_span_id=span.id if span else None, + ), + ) + # Persist the new full history (user + assistant + any tool + # rounds) so the next turn picks up from here. + self._message_history = list(result.all_messages()) + if span: + span.output = {"final_output": result.output} + + @workflow.run + async def on_task_create(self, params: CreateTaskParams) -> str: + """Workflow entry point — keep the conversation alive for incoming signals.""" + logger.info(f"Task created: {params.task.id}") + + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=( + f"Task initialized with params:\n{json.dumps(params.params, indent=2)}\n" + f"Send me a message and I'll respond using a Pydantic AI agent backed by Temporal." + ), + ), + ) + + await workflow.wait_condition(lambda: self._complete_task, timeout=None) + return "Task completed" + + @workflow.signal + async def complete_task_signal(self) -> None: + """Graceful workflow shutdown signal.""" + logger.info("Received complete_task signal") + self._complete_task = True diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/pyproject.toml.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/pyproject.toml.j2 new file mode 100644 index 000000000..e95df9e7b --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/pyproject.toml.j2 @@ -0,0 +1,35 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ project_name }}" +version = "0.1.0" +description = "{{ description }}" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "temporalio>=1.18.2", + "pydantic-ai-slim[openai]>=1.0,<2", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", + "debugpy>=1.8.15", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/requirements.txt.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/requirements.txt.j2 new file mode 100644 index 000000000..b2c95f02f --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/requirements.txt.j2 @@ -0,0 +1,4 @@ +agentex-sdk +scale-gp +temporalio>=1.18.2 +pydantic-ai-slim[openai]>=1.0,<2 diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/test_agent.py.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/test_agent.py.j2 new file mode 100644 index 000000000..ee71f177c --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/test_agent.py.j2 @@ -0,0 +1,147 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: {{ agent_name }}) +""" + +import os +import uuid +import asyncio +import pytest +import pytest_asyncio +from agentex import AsyncAgentex +from agentex.types import TaskMessage +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam +from test_utils.async_utils import ( + poll_for_agent_response, + send_event_and_poll_yielding, + stream_agent_response, + validate_text_in_response, + poll_messages, +) + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "{{ agent_name }}") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, _agent_name: str, agent_id: str): + """Test sending an event and polling for the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # TODO: Poll for the initial task creation message (if your agent sends one) + # async for message in poll_messages( + # client=client, + # task_id=task.id, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected initial message + # assert "expected initial text" in message.content.content + # break + + # TODO: Send an event and poll for response using the yielding helper function + # user_message = "Your test message here" + # async for message in send_event_and_poll_yielding( + # client=client, + # agent_id=agent_id, + # task_id=task.id, + # user_message=user_message, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected response + # assert "expected response text" in message.content.content + # break + pass + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, _agent_name: str, agent_id: str): + """Test sending an event and streaming the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # user_message = "Your test message here" + + # # Collect events from stream + # all_events = [] + + # async def collect_stream_events(): + # async for event in stream_agent_response( + # client=client, + # task_id=task.id, + # timeout=30, + # ): + # all_events.append(event) + + # # Start streaming task + # stream_task = asyncio.create_task(collect_stream_events()) + + # # Send the event + # event_content = TextContentParam(type="text", author="user", content=user_message) + # await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + + # # Wait for streaming to complete + # await stream_task + + # # TODO: Add your validation here + # assert len(all_events) > 0, "No events received in streaming response" + pass + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/src/agentex/lib/cli/templates/temporal/.dockerignore.j2 b/src/agentex/lib/cli/templates/temporal/.dockerignore.j2 new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal/.dockerignore.j2 @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/src/agentex/lib/cli/templates/temporal/.env.example.j2 b/src/agentex/lib/cli/templates/temporal/.env.example.j2 new file mode 100644 index 000000000..015f49ef7 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal/.env.example.j2 @@ -0,0 +1,13 @@ +# {{ agent_name }} - Environment Variables +# Copy this file to .env and fill in the values + +# API key for your LLM provider +LITELLM_API_KEY= + +# LLM base URL (optional - override to use a different provider) +# OPENAI_BASE_URL= + +# SGP Configuration (optional - for tracing) +# SGP_API_KEY= +# SGP_ACCOUNT_ID= +# SGP_CLIENT_BASE_URL= diff --git a/src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 new file mode 100644 index 000000000..0d9801016 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 @@ -0,0 +1,55 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +# Install tctl (Temporal CLI) +RUN curl -L https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_arm64.tar.gz -o /tmp/tctl.tar.gz && \ + tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ + chmod +x /usr/local/bin/tctl && \ + rm /tmp/tctl.tar.gz + +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +ENV UV_HTTP_TIMEOUT=1000 + +WORKDIR /app/{{ project_path_from_build_root }} + +# Copy dependency files for layer caching +COPY {{ project_path_from_build_root }}/pyproject.toml ./ + +# Install dependencies (without project itself, for layer caching) +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-install-project --no-dev + +# Copy the project code +COPY {{ project_path_from_build_root }}/project ./project + +# Install the project +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" + +# Run the ACP server using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal/Dockerfile.j2 new file mode 100644 index 000000000..4c1798c42 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal/Dockerfile.j2 @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + node \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install tctl (Temporal CLI) +RUN curl -L https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_arm64.tar.gz -o /tmp/tctl.tar.gz && \ + tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ + chmod +x /usr/local/bin/tctl && \ + rm /tmp/tctl.tar.gz + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy just the requirements file to optimize caching +COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt + +WORKDIR /app/{{ project_path_from_build_root }} + +# Install the required Python packages +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 + +# Run the ACP server using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal/README.md.j2 b/src/agentex/lib/cli/templates/temporal/README.md.j2 new file mode 100644 index 000000000..7dc8a7dc1 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal/README.md.j2 @@ -0,0 +1,353 @@ +# {{ agent_name }} - AgentEx Temporal Agent Template + +This is a starter template for building asynchronous agents with the AgentEx framework and Temporal. It provides a basic implementation of the Agent 2 Client Protocol (ACP) with Temporal workflow support to help you get started quickly. + +## What You'll Learn + +- **Tasks**: A task is a grouping mechanism for related messages. Think of it as a conversation thread or a session. +- **Messages**: Messages are communication objects within a task. They can contain text, data, or instructions. +- **ACP Events**: The agent responds to four main events: + - `task_received`: When a new task is created + - `task_message_received`: When a message is sent within a task + - `task_approved`: When a task is approved + - `task_canceled`: When a task is canceled +- **Temporal Workflows**: Long-running processes that can handle complex state management and async operations + +## Running the Agent + +1. Run the agent locally: +```bash +agentex agents run --manifest manifest.yaml +``` + +The agent will start on port 8000 and print messages whenever it receives any of the ACP events. + +## What's Inside + +This template: +- Sets up a basic ACP server with Temporal integration +- Handles each of the required ACP events +- Provides a foundation for building complex async agents +- Includes Temporal workflow and activity definitions + +## Next Steps + +For more advanced agent development, check out the AgentEx tutorials: + +- **Tutorials 00-08**: Learn about building synchronous agents with ACP +- **Tutorials 09-10**: Learn how to use Temporal to power asynchronous agents + - Tutorial 09: Basic Temporal workflow setup + - Tutorial 10: Advanced Temporal patterns and best practices + +These tutorials will help you understand: +- How to handle long-running tasks +- Implementing state machines +- Managing complex workflows +- Best practices for async agent development + +## The Manifest File + +The `manifest.yaml` file is your agent's configuration file. It defines: +- How your agent should be built and packaged +- What files are included in your agent's Docker image +- Your agent's name and description +- Local development settings (like the port your agent runs on) +- Temporal worker configuration + +This file is essential for both local development and deployment of your agent. + +## Project Structure + +``` +{{ project_name }}/ +├── project/ # Your agent's code +│ ├── __init__.py +│ ├── acp.py # ACP server and event handlers +│ ├── workflow.py # Temporal workflow definitions +│ ├── activities.py # Temporal activity definitions +│ └── run_worker.py # Temporal worker setup +├── Dockerfile # Container definition +├── manifest.yaml # Deployment config +├── dev.ipynb # Development notebook for testing +{% if use_uv %} +└── pyproject.toml # Dependencies (uv) +{% else %} +└── requirements.txt # Dependencies (pip) +{% endif %} +``` + +## Development + +### 1. Customize Event Handlers +- Modify the handlers in `acp.py` to implement your agent's logic +- Add your own tools and capabilities +- Implement custom state management + +### 2. Test Your Agent with the Development Notebook +Use the included `dev.ipynb` Jupyter notebook to test your agent interactively: + +```bash +# Start Jupyter notebook (make sure you have jupyter installed) +jupyter notebook dev.ipynb + +# Or use VS Code to open the notebook directly +code dev.ipynb +``` + +The notebook includes: +- **Setup**: Connect to your local AgentEx backend +- **Task creation**: Create a new task for the conversation +- **Event sending**: Send events to the agent and get responses +- **Async message subscription**: Subscribe to server-side events to receive agent responses +- **Rich message display**: Beautiful formatting with timestamps and author information + +The notebook automatically uses your agent name (`{{ agent_name }}`) and demonstrates the async ACP workflow: create task → send event → subscribe to responses. + +### 3. Develop Temporal Workflows +- Edit `workflow.py` to define your agent's async workflow logic +- Modify `activities.py` to add custom activities +- Use `run_worker.py` to configure the Temporal worker + +### 4. Manage Dependencies + +{% if use_uv %} +You chose **uv** for package management. Here's how to work with dependencies: + +```bash +# Add new dependencies +agentex uv add requests openai anthropic + +# Add Temporal-specific dependencies (already included) +agentex uv add temporalio + +# Install/sync dependencies +agentex uv sync + +# Run commands with uv +uv run agentex agents run --manifest manifest.yaml +``` + +**Benefits of uv:** +- Faster dependency resolution and installation +- Better dependency isolation +- Modern Python packaging standards + +{% else %} +You chose **pip** for package management. Here's how to work with dependencies: + +```bash +# Probably create a conda env for your agent. +# Optionally add agentex-sdk editable installation + +# Edit requirements.txt manually to add dependencies +echo "requests" >> requirements.txt +echo "openai" >> requirements.txt + +# Temporal dependencies are already included +# temporalio is already in requirements.txt + +# Install dependencies +pip install -r requirements.txt +``` + +**Benefits of pip:** +- Familiar workflow for most Python developers +- Simple requirements.txt management +- Wide compatibility +{% endif %} + +### 5. Configure Credentials +- Add any required credentials to your manifest.yaml +- For local development, create a `.env` file in the project directory +- Use `load_dotenv()` only in development mode: + +```python +import os +from dotenv import load_dotenv + +if os.environ.get("ENVIRONMENT") == "development": + load_dotenv() +``` + +## Local Development + +### 1. Start the Agentex Backend +```bash +# Navigate to the backend directory +cd agentex + +# Start all services using Docker Compose +make dev + +# Optional: In a separate terminal, use lazydocker for a better UI (everything should say "healthy") +lzd +``` + +### 2. Setup Your Agent's requirements/pyproject.toml +```bash +agentex uv sync [--group editable-apy] +source .venv/bin/activate + +# OR +conda create -n {{ project_name }} python=3.12 +conda activate {{ project_name }} +pip install -r requirements.txt +``` +### 3. Run Your Agent +```bash +# From this directory +export ENVIRONMENT=development && [uv run] agentex agents run --manifest manifest.yaml +``` +4. **Interact with your agent** + +Option 0: CLI (deprecated - to be replaced once a new CLI is implemented - please use the web UI for now!) +```bash +# Submit a task via CLI +agentex tasks submit --agent {{ agent_name }} --task "Your task here" +``` + +Option 1: Web UI +```bash +# Start the local web interface +cd agentex-web +make dev + +# Then open http://localhost:3000 in your browser to chat with your agent +``` + +## Development Tips + +### Environment Variables +- Set environment variables in project/.env for any required credentials +- Or configure them in the manifest.yaml under the `env` section +- The `.env` file is automatically loaded in development mode + +### Local Testing +- Use `export ENVIRONMENT=development` before running your agent +- This enables local service discovery and debugging features +- Your agent will automatically connect to locally running services + +### Temporal-Specific Tips +- Monitor workflows in the Temporal Web UI at http://localhost:8080 +- Use the Temporal CLI for advanced workflow management +- Check workflow logs for debugging async operations + +### Debugging +- Check agent logs in the terminal where you ran the agent +- Use the web UI to inspect task history and responses +- Monitor backend services with `lzd` (LazyDocker) +- Use Temporal Web UI for workflow debugging + +### To build the agent Docker image locally (normally not necessary): + +1. Build the agent image: +```bash +agentex agents build --manifest manifest.yaml +``` + +## Advanced Features + +### Temporal Workflows +Extend your agent with sophisticated async workflows: + +```python +# In project/workflow.py +@workflow.defn +class MyWorkflow(BaseWorkflow): + async def complex_operation(self): + # Multi-step async operations + # Error handling and retries + # State management + pass +``` + +### Custom Activities +Add custom activities for external operations. **Important**: Always specify appropriate timeouts (recommended: 10 minutes): + +```python +# In project/activities.py +from datetime import timedelta +from temporalio import activity +from temporalio.common import RetryPolicy + +@activity.defn(name="call_external_api") +async def call_external_api(data): + # HTTP requests, database operations, etc. + pass + +# In your workflow, call it with a timeout: +result = await workflow.execute_activity( + "call_external_api", + data, + start_to_close_timeout=timedelta(minutes=10), # Recommended: 10 minute timeout + heartbeat_timeout=timedelta(minutes=1), # Optional: heartbeat monitoring + retry_policy=RetryPolicy(maximum_attempts=3) # Optional: retry policy +) + +# Don't forget to register your custom activities in run_worker.py: +# all_activities = get_all_activities() + [your_custom_activity_function] +``` + +### Integration with External Services +{% if use_uv %} +```bash +# Add service clients +agentex uv add httpx requests-oauthlib + +# Add AI/ML libraries +agentex uv add openai anthropic transformers + +# Add database clients +agentex uv add asyncpg redis +``` +{% else %} +```bash +# Add to requirements.txt +echo "httpx" >> requirements.txt +echo "openai" >> requirements.txt +echo "asyncpg" >> requirements.txt +pip install -r requirements.txt +``` +{% endif %} + +## Troubleshooting + +### Common Issues + +1. **Agent not appearing in web UI** + - Check if agent is running on port 8000 + - Verify `ENVIRONMENT=development` is set + - Check agent logs for errors + +2. **Temporal workflow issues** + - Check Temporal Web UI at http://localhost:8080 + - Verify Temporal server is running in backend services + - Check workflow logs for specific errors + +3. **Dependency issues** +{% if use_uv %} + - Run `agentex uv sync` to ensure all dependencies are installed + - Verify temporalio is properly installed +{% else %} + - Run `pip install -r requirements.txt` + - Check if all dependencies are correctly listed in requirements.txt + - Verify temporalio is installed correctly +{% endif %} + +4. **Port conflicts** + - Check if another service is using port 8000 + - Use `lsof -i :8000` to find conflicting processes + +### Temporal-Specific Troubleshooting + +1. **Workflow not starting** + - Check if Temporal server is running (`docker ps`) + - Verify task queue configuration in `run_worker.py` + - Check workflow registration in the worker + +2. **Activity failures** + - Check activity logs in the console + - Verify activity registration + - Check for timeout issues + +Happy building with Temporal! 🚀⚡ \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal/dev.ipynb.j2 b/src/agentex/lib/cli/templates/temporal/dev.ipynb.j2 new file mode 100644 index 000000000..d3a68303f --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal/dev.ipynb.j2 @@ -0,0 +1,126 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"{{ agent_name }}\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [], + "source": [ + "# (REQUIRED) Create a new task. For Async agents, you must create a task for messages to be associated with.\n", + "import uuid\n", + "\n", + "rpc_response = client.agents.create_task(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", + " \"params\": {}\n", + " }\n", + ")\n", + "\n", + "task = rpc_response.result\n", + "print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b03b0d37", + "metadata": {}, + "outputs": [], + "source": [ + "# Send an event to the agent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_event(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"task_id\": task.id,\n", + " }\n", + ")\n", + "\n", + "event = rpc_response.result\n", + "print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a6927cc0", + "metadata": {}, + "outputs": [], + "source": [ + "# Subscribe to the async task messages produced by the agent\n", + "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", + "\n", + "task_messages = subscribe_to_async_task_messages(\n", + " client=client,\n", + " task=task, \n", + " only_after_timestamp=event.created_at, \n", + " print_messages=True,\n", + " rich_print=True,\n", + " timeout=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4864e354", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/agentex/lib/cli/templates/temporal/environments.yaml.j2 b/src/agentex/lib/cli/templates/temporal/environments.yaml.j2 new file mode 100644 index 000000000..a3df5e228 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal/environments.yaml.j2 @@ -0,0 +1,64 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-{{agent_name}}" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + # This is used to override the global helm values.yaml file in the agentex-agent helm charts + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + temporal-worker: + enabled: true + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal/manifest.yaml.j2 b/src/agentex/lib/cli/templates/temporal/manifest.yaml.j2 new file mode 100644 index 000000000..b9216929f --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal/manifest.yaml.j2 @@ -0,0 +1,140 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../ # Keep this as the default root + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - {{ project_path_from_build_root }} + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: {{ project_path_from_build_root }}/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: {{ project_path_from_build_root }}/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + # Path to temporal worker file + # Examples: + # project/run_worker.py (standard) + # workers/temporal.py (custom structure) + # ../shared/worker.py (shared across projects) + worker: project/run_worker.py + + +# Agent Configuration +# ----------------- +agent: + # Type of agent - either sync or async + acp_type: async + + # Unique name for your agent + # Used for task routing and monitoring + name: {{ agent_name }} + + # Description of what your agent does + # Helps with documentation and discovery + description: {{ description | tojson }} + + # Temporal workflow configuration + # This enables your agent to run as a Temporal workflow for long-running tasks + temporal: + enabled: true + workflows: + # Name of the workflow class + # Must match the @workflow.defn name in your workflow.py + - name: {{ workflow_name }} + + # Queue name for task distribution + # Used by Temporal to route tasks to your agent + # Convention: _task_queue + queue_name: {{ queue_name }} + + # Optional: Health check port for temporal worker + # Defaults to 80 if not specified + # health_check_port: 80 + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + credentials: + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + # - env_var_name: LITELLM_API_KEY + # secret_name: litellm-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + env: {} + # LITELLM_API_KEY: "" + # OPENAI_BASE_URL: "" + # OPENAI_ORG_ID: "" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: [] # Update with your image pull secret name + # - name: my-registry-secret + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific environments (environments.yaml) + global: + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal/project/acp.py.j2 b/src/agentex/lib/cli/templates/temporal/project/acp.py.j2 new file mode 100644 index 000000000..ec06135c6 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal/project/acp.py.j2 @@ -0,0 +1,64 @@ +import os +import sys + +# === DEBUG SETUP (AgentEx CLI Debug Support) === +if os.getenv("AGENTEX_DEBUG_ENABLED") == "true": + try: + import debugpy + from agentex.lib.utils.logging import make_logger + + logger = make_logger(__name__) + debug_port = int(os.getenv("AGENTEX_DEBUG_PORT", "5679")) + debug_type = os.getenv("AGENTEX_DEBUG_TYPE", "acp") + wait_for_attach = os.getenv("AGENTEX_DEBUG_WAIT_FOR_ATTACH", "false").lower() == "true" + + # Configure debugpy + debugpy.configure(subProcess=False) + debugpy.listen(debug_port) + + logger.info(f"🐛 [{debug_type.upper()}] Debug server listening on port {debug_port}") + + if wait_for_attach: + logger.info(f"⏳ [{debug_type.upper()}] Waiting for debugger to attach...") + debugpy.wait_for_client() + logger.info(f"✅ [{debug_type.upper()}] Debugger attached!") + else: + logger.info(f"📡 [{debug_type.upper()}] Ready for debugger attachment") + + except ImportError: + print("❌ debugpy not available. Install with: pip install debugpy") + sys.exit(1) + except Exception as e: + print(f"❌ Debug setup failed: {e}") + sys.exit(1) +# === END DEBUG SETUP === + +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.lib.types.fastacp import TemporalACPConfig + + +# Create the ACP server +acp = FastACP.create( + acp_type="async", + config=TemporalACPConfig( + # When deployed to the cluster, the Temporal address will automatically be set to the cluster address + # For local development, we set the address manually to talk to the local Temporal service set up via docker compose + type="temporal", + temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233") + ) +) + + +# Notice that we don't need to register any handlers when we use type="temporal" +# If you look at the code in agentex.sdk.fastacp.impl.temporal_acp +# You can see that these handlers are automatically registered when the ACP is created + +# @acp.on_task_create +# This will be handled by the method in your workflow that is decorated with @workflow.run + +# @acp.on_task_event_send +# This will be handled by the method in your workflow that is decorated with @workflow.signal(name=SignalName.RECEIVE_MESSAGE) + +# @acp.on_task_cancel +# This does not need to be handled by your workflow. +# It is automatically handled by the temporal client which cancels the workflow directly \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal/project/activities.py.j2 b/src/agentex/lib/cli/templates/temporal/project/activities.py.j2 new file mode 100644 index 000000000..6144b2343 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal/project/activities.py.j2 @@ -0,0 +1,77 @@ +""" +Custom Temporal Activities Template +==================================== +This file is for defining custom Temporal activities that can be executed +by your workflow. Activities are used for: +- External API calls +- Database operations +- File I/O operations +- Heavy computations +- Any non-deterministic operations + +IMPORTANT: All activities should have appropriate timeouts! +Default recommendation: start_to_close_timeout=timedelta(minutes=10) +""" + +from datetime import timedelta +from typing import Any, Dict + +from pydantic import BaseModel +from temporalio import activity +from temporalio.common import RetryPolicy + +from agentex.lib.utils.logging import make_logger + +logger = make_logger(__name__) + + +# Example activity parameter models +class ExampleActivityParams(BaseModel): + """Parameters for the example activity""" + data: Dict[str, Any] + task_id: str + + +# Example custom activity +@activity.defn(name="example_custom_activity") +async def example_custom_activity(params: ExampleActivityParams) -> Dict[str, Any]: + """ + Example custom activity that demonstrates best practices. + + When calling this activity from your workflow, use: + ```python + result = await workflow.execute_activity( + "example_custom_activity", + ExampleActivityParams(data={"key": "value"}, task_id=task_id), + start_to_close_timeout=timedelta(minutes=10), # Recommended: 10 minute timeout + heartbeat_timeout=timedelta(minutes=1), # Optional: heartbeat every minute + retry_policy=RetryPolicy(maximum_attempts=3) # Optional: retry up to 3 times + ) + ``` + """ + logger.info(f"Processing activity for task {params.task_id} with data: {params.data}") + + # Your activity logic here + # This could be: + # - API calls + # - Database operations + # - File processing + # - ML model inference + # - etc. + + result = { + "status": "success", + "processed_data": params.data, + "task_id": params.task_id + } + + return result + + +# Add more custom activities below as needed +# Remember to: +# 1. Use appropriate timeouts (default: 10 minutes) +# 2. Define clear parameter models with Pydantic +# 3. Handle errors appropriately +# 4. Use logging for debugging +# 5. Keep activities focused on a single responsibility diff --git a/src/agentex/lib/cli/templates/temporal/project/run_worker.py.j2 b/src/agentex/lib/cli/templates/temporal/project/run_worker.py.j2 new file mode 100644 index 000000000..1721abacf --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal/project/run_worker.py.j2 @@ -0,0 +1,38 @@ +import asyncio + +from agentex.lib.core.temporal.activities import get_all_activities +from agentex.lib.core.temporal.workers.worker import AgentexWorker +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.debug import setup_debug_if_enabled +from agentex.lib.environment_variables import EnvironmentVariables + +from project.workflow import {{ workflow_class }} + + +environment_variables = EnvironmentVariables.refresh() + +logger = make_logger(__name__) + + +async def main(): + # Setup debug mode if enabled + setup_debug_if_enabled() + + task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE + if task_queue_name is None: + raise ValueError("WORKFLOW_TASK_QUEUE is not set") + + all_activities = get_all_activities() + [] # add your own activities here + + # Create a worker with automatic tracing + worker = AgentexWorker( + task_queue=task_queue_name, + ) + + await worker.run( + activities=all_activities, + workflow={{ workflow_class }}, + ) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal/project/workflow.py.j2 b/src/agentex/lib/cli/templates/temporal/project/workflow.py.j2 new file mode 100644 index 000000000..56db5abf3 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal/project/workflow.py.j2 @@ -0,0 +1,66 @@ +import json + +from temporalio import workflow + +from agentex.lib import adk +from agentex.protocol.acp import CreateTaskParams, SendEventParams +from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow +from agentex.lib.core.temporal.types.workflow import SignalName +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.environment_variables import EnvironmentVariables + +environment_variables = EnvironmentVariables.refresh() + +if environment_variables.WORKFLOW_NAME is None: + raise ValueError("Environment variable WORKFLOW_NAME is not set") + +if environment_variables.AGENT_NAME is None: + raise ValueError("Environment variable AGENT_NAME is not set") + +logger = make_logger(__name__) + +@workflow.defn(name=environment_variables.WORKFLOW_NAME) +class {{ workflow_class }}(BaseWorkflow): + """ + Minimal async workflow template for AgentEx Temporal agents. + """ + def __init__(self): + super().__init__(display_name=environment_variables.AGENT_NAME) + self._complete_task = False + + @workflow.signal(name=SignalName.RECEIVE_EVENT) + async def on_task_event_send(self, params: SendEventParams) -> None: + logger.info(f"Received task message instruction: {params}") + + # 2. Echo back the client's message to show it in the UI. This is not done by default so the agent developer has full control over what is shown to the user. + await adk.messages.create(task_id=params.task.id, content=params.event.content) + + # 3. Send a simple response message. + # In future tutorials, this is where we'll add more sophisticated response logic. + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"Hello! I've received your message. I can't respond right now, but in future tutorials we'll see how you can get me to intelligently respond to your message.", + ), + ) + + @workflow.run + async def on_task_create(self, params: CreateTaskParams) -> str: + logger.info(f"Received task create params: {params}") + + # 1. Acknowledge that the task has been created. + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=f"Hello! I've received your task. Normally you can do some state initialization here, or just pass and do nothing until you get your first event. For now I'm just acknowledging that I've received a task with the following params:\n\n{json.dumps(params.params, indent=2)}.\n\nYou should only see this message once, when the task is created. All subsequent events will be handled by the `on_task_event_send` handler.", + ), + ) + + await workflow.wait_condition( + lambda: self._complete_task, + timeout=None, # Set a timeout if you want to prevent the task from running indefinitely. Generally this is not needed. Temporal can run hundreds of millions of workflows in parallel and more. Only do this if you have a specific reason to do so. + ) + return "Task completed" diff --git a/src/agentex/lib/cli/templates/temporal/pyproject.toml.j2 b/src/agentex/lib/cli/templates/temporal/pyproject.toml.j2 new file mode 100644 index 000000000..9e157aa48 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal/pyproject.toml.j2 @@ -0,0 +1,34 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ project_name }}" +version = "0.1.0" +description = "{{ description }}" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "temporalio", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", + "flake8", + "debugpy>=1.8.15", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/src/agentex/lib/cli/templates/temporal/requirements.txt.j2 b/src/agentex/lib/cli/templates/temporal/requirements.txt.j2 new file mode 100644 index 000000000..0b8ae19b3 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal/requirements.txt.j2 @@ -0,0 +1,5 @@ +# Install agentex-sdk from local path +agentex-sdk + +# Scale GenAI Platform Python SDK +scale-gp diff --git a/src/agentex/lib/cli/templates/temporal/test_agent.py.j2 b/src/agentex/lib/cli/templates/temporal/test_agent.py.j2 new file mode 100644 index 000000000..ee71f177c --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal/test_agent.py.j2 @@ -0,0 +1,147 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: {{ agent_name }}) +""" + +import os +import uuid +import asyncio +import pytest +import pytest_asyncio +from agentex import AsyncAgentex +from agentex.types import TaskMessage +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam +from test_utils.async_utils import ( + poll_for_agent_response, + send_event_and_poll_yielding, + stream_agent_response, + validate_text_in_response, + poll_messages, +) + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "{{ agent_name }}") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, _agent_name: str, agent_id: str): + """Test sending an event and polling for the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # TODO: Poll for the initial task creation message (if your agent sends one) + # async for message in poll_messages( + # client=client, + # task_id=task.id, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected initial message + # assert "expected initial text" in message.content.content + # break + + # TODO: Send an event and poll for response using the yielding helper function + # user_message = "Your test message here" + # async for message in send_event_and_poll_yielding( + # client=client, + # agent_id=agent_id, + # task_id=task.id, + # user_message=user_message, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected response + # assert "expected response text" in message.content.content + # break + pass + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, _agent_name: str, agent_id: str): + """Test sending an event and streaming the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # user_message = "Your test message here" + + # # Collect events from stream + # all_events = [] + + # async def collect_stream_events(): + # async for event in stream_agent_response( + # client=client, + # task_id=task.id, + # timeout=30, + # ): + # all_events.append(event) + + # # Start streaming task + # stream_task = asyncio.create_task(collect_stream_events()) + + # # Send the event + # event_content = TextContentParam(type="text", author="user", content=user_message) + # await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + + # # Wait for streaming to complete + # await stream_task + + # # TODO: Add your validation here + # assert len(all_events) > 0, "No events received in streaming response" + pass + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/src/agentex/lib/cli/utils/__init__.py b/src/agentex/lib/cli/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/cli/utils/auth_utils.py b/src/agentex/lib/cli/utils/auth_utils.py new file mode 100644 index 000000000..b2a747456 --- /dev/null +++ b/src/agentex/lib/cli/utils/auth_utils.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import json +import base64 +from typing import Any, Dict + +from agentex.config.agent_manifest import AgentManifest +from agentex.config.environment_config import AgentAuthConfig + + +# DEPRECATED: Old function for backward compatibility +# Will be removed in future version +def _encode_principal_context(manifest: AgentManifest) -> str | None: # noqa: ARG001 + """ + DEPRECATED: This function is deprecated as AgentManifest no longer contains auth. + Use _encode_principal_context_from_env_config instead. + + This function is kept temporarily for backward compatibility during migration. + """ + # AgentManifest no longer has auth field - this will always return None + return None + + +def _encode_principal_context_from_env_config(auth_config: "AgentAuthConfig | None") -> str | None: + """ + Encode principal context from environment configuration. + + Args: + auth_config: AgentAuthConfig containing principal configuration + + Returns: + Base64-encoded JSON string of the principal, or None if no principal + """ + if auth_config is None: + return None + + principal = auth_config.principal + if not principal: + return None + + json_str = json.dumps(principal, separators=(',', ':')) + encoded_bytes = base64.b64encode(json_str.encode('utf-8')) + return encoded_bytes.decode('utf-8') + + +def _encode_principal_dict(principal: Dict[str, Any]) -> str | None: + """ + Encode principal dictionary directly. + + Args: + principal: Dictionary containing principal configuration + + Returns: + Base64-encoded JSON string of the principal, or None if principal is empty + """ + if not principal: + return None + + json_str = json.dumps(principal, separators=(',', ':')) + encoded_bytes = base64.b64encode(json_str.encode('utf-8')) + return encoded_bytes.decode('utf-8') diff --git a/src/agentex/lib/cli/utils/cli_utils.py b/src/agentex/lib/cli/utils/cli_utils.py new file mode 100644 index 000000000..4238e8fd9 --- /dev/null +++ b/src/agentex/lib/cli/utils/cli_utils.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import typer +from rich.console import Console + +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" +) -> str: + """Handle questionary cancellation by checking for None and exiting gracefully""" + if result is None: + console.print(f"[yellow]{operation.capitalize()} cancelled by user[/yellow]") + raise typer.Exit(0) + return result diff --git a/src/agentex/lib/cli/utils/credential_utils.py b/src/agentex/lib/cli/utils/credential_utils.py new file mode 100644 index 000000000..720b784b8 --- /dev/null +++ b/src/agentex/lib/cli/utils/credential_utils.py @@ -0,0 +1,103 @@ +import subprocess + +from rich.prompt import Prompt, Confirm +from rich.console import Console + +from agentex.config.credentials import CredentialMapping + +console = Console() + + +def check_secret_exists(secret_name: str, namespace: str) -> bool: + """Check if a Kubernetes secret exists in the given namespace.""" + try: + result = subprocess.run( + ["kubectl", "get", "secret", secret_name, "-n", namespace], + capture_output=True, + text=True, + check=False, + ) + return result.returncode == 0 + except Exception: + return False + + +def create_env_var_secret(credential: CredentialMapping, namespace: str) -> bool: + """Create a generic secret for environment variable credentials.""" + console.print( + f"[yellow]Secret '{credential.secret_name}' not found in namespace '{namespace}'[/yellow]" + ) + + if not Confirm.ask( + f"Would you like to create the secret '{credential.secret_name}'?" + ): + return False + + # Prompt for the secret value + secret_value = Prompt.ask( + f"Enter the value for '{credential.secret_key}'", password=True + ) + + try: + # Create the secret using kubectl + subprocess.run( + [ + "kubectl", + "create", + "secret", + "generic", + credential.secret_name, + f"--from-literal={credential.secret_key}={secret_value}", + "-n", + namespace, + ], + capture_output=True, + text=True, + check=True, + ) + + console.print( + f"[green]✓ Created secret '{credential.secret_name}' in namespace '{namespace}'[/green]" + ) + return True + + except subprocess.CalledProcessError as e: + console.print(f"[red]✗ Failed to create secret: {e.stderr}[/red]") + return False + + +# def create_image_pull_secret(credential: ImagePullCredential, namespace: str) -> bool: +# """Create an image pull secret with interactive prompts.""" +# console.print(f"[yellow]Image pull secret '{credential.secret_name}' not found in namespace '{namespace}'[/yellow]") + +# if not Confirm.ask(f"Would you like to create the image pull secret '{credential.secret_name}'?"): +# return False + +# # Prompt for registry details +# registry_server = Prompt.ask("Docker registry server (e.g., docker.io, gcr.io)") +# username = Prompt.ask("Username") +# password = Prompt.ask("Password", password=True) +# email = Prompt.ask("Email (optional)", default="") + +# try: +# # Create the image pull secret using kubectl +# cmd = [ +# "kubectl", "create", "secret", "docker-registry", +# credential.secret_name, +# f"--docker-server={registry_server}", +# f"--docker-username={username}", +# f"--docker-password={password}", +# "-n", namespace +# ] + +# if email: +# cmd.append(f"--docker-email={email}") + +# result = subprocess.run(cmd, capture_output=True, text=True, check=True) + +# console.print(f"[green]✓ Created image pull secret '{credential.secret_name}' in namespace '{namespace}'[/green]") +# return True + +# except subprocess.CalledProcessError as e: +# console.print(f"[red]✗ Failed to create image pull secret: {e.stderr}[/red]") +# return False diff --git a/src/agentex/lib/cli/utils/exceptions.py b/src/agentex/lib/cli/utils/exceptions.py new file mode 100644 index 000000000..efd41b6c5 --- /dev/null +++ b/src/agentex/lib/cli/utils/exceptions.py @@ -0,0 +1,6 @@ +class HelmError(Exception): + """An error occurred during helm operations""" + + +class DeploymentError(Exception): + """An error occurred during deployment""" diff --git a/src/agentex/lib/cli/utils/kubectl_utils.py b/src/agentex/lib/cli/utils/kubectl_utils.py new file mode 100644 index 000000000..4213233cd --- /dev/null +++ b/src/agentex/lib/cli/utils/kubectl_utils.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import subprocess + +from kubernetes import client, config +from rich.console import Console +from kubernetes.client.rest import ApiException + +from agentex.lib.utils.logging import make_logger +from agentex.lib.cli.utils.exceptions import DeploymentError + +logger = make_logger(__name__) +console = Console() + + +class KubernetesClientManager: + """Manages Kubernetes clients for different contexts""" + + def __init__(self): + self._clients: dict[str, client.CoreV1Api] = {} + + def get_client(self, context: str | None = None) -> client.CoreV1Api: + """Get a Kubernetes client for the specified context""" + if context is None: + context = get_current_context() + + if context not in self._clients: + try: + # Load config for specific context + config.load_kube_config(context=context) + self._clients[context] = client.CoreV1Api() + logger.info(f"Created Kubernetes client for context: {context}") + except Exception as e: + raise DeploymentError( + f"Failed to create Kubernetes client for context '{context}': {e}" + ) from e + + return self._clients[context] + + def clear_cache(self): + """Clear cached clients (useful when contexts change)""" + self._clients.clear() + + +def get_current_context() -> str: + """Get the current kubectl context""" + try: + contexts, active_context = config.list_kube_config_contexts() + if active_context is None: + raise DeploymentError("No active kubectl context found") + return active_context["name"] + except Exception as e: + raise DeploymentError(f"Failed to get current kubectl context: {e}") from e + + +# Global client manager instance +_client_manager = KubernetesClientManager() + + +def list_available_contexts() -> list[str]: + """List all available kubectl contexts""" + try: + contexts, _ = config.list_kube_config_contexts() + return [ctx["name"] for ctx in contexts] # type: ignore[index] + except Exception as e: + raise DeploymentError(f"Failed to list kubectl contexts: {e}") from e + + +def validate_cluster_context(cluster_name: str) -> bool: + """Check if a cluster name corresponds to an available kubectl context""" + try: + available_contexts = list_available_contexts() + return cluster_name in available_contexts + except DeploymentError: + return False + + +def switch_kubectl_context(cluster_name: str) -> None: + """Switch to the specified kubectl context""" + try: + # Use subprocess for context switching as it's a local kubeconfig operation + subprocess.run( + ["kubectl", "config", "use-context", cluster_name], + capture_output=True, + text=True, + check=True, + ) + # Clear client cache since context changed + _client_manager.clear_cache() + logger.info(f"Switched to kubectl context: {cluster_name}") + except (subprocess.CalledProcessError, FileNotFoundError) as e: + raise DeploymentError( + f"Failed to switch to kubectl context '{cluster_name}': {e}" + ) from e + + +def validate_namespace(namespace: str, context: str | None = None) -> bool: + """Check if a namespace exists in the specified cluster context""" + try: + k8s_client = _client_manager.get_client(context) + k8s_client.read_namespace(name=namespace) + return True + except ApiException as e: + if e.status == 404: + return False + raise DeploymentError(f"Failed to validate namespace '{namespace}': {e}") from e + except Exception as e: + raise DeploymentError(f"Failed to validate namespace '{namespace}': {e}") from e + + +def check_and_switch_cluster_context(cluster_name: str) -> None: + """Check and switch to the specified kubectl context""" + # Validate cluster context + if not validate_cluster_context(cluster_name): + available_contexts = list_available_contexts() + raise DeploymentError( + f"Cluster '{cluster_name}' not found in kubectl contexts.\n" + f"Available contexts: {', '.join(available_contexts)}\n" + f"Please ensure you have a valid kubeconfig for this cluster." + ) + + # Switch to the specified cluster context + current_context = get_current_context() + if current_context != cluster_name: + console.print( + f"[blue]ℹ[/blue] Switching from context '{current_context}' to '{cluster_name}'" + ) + switch_kubectl_context(cluster_name) + else: + console.print( + f"[blue]ℹ[/blue] Using current kubectl context: [bold]{cluster_name}[/bold]" + ) + + +def get_k8s_client(context: str | None = None) -> client.CoreV1Api: + """Get a Kubernetes client for the specified context (or current context if None)""" + return _client_manager.get_client(context) diff --git a/src/agentex/lib/cli/utils/kubernetes_secrets_utils.py b/src/agentex/lib/cli/utils/kubernetes_secrets_utils.py new file mode 100644 index 000000000..0a67a31e4 --- /dev/null +++ b/src/agentex/lib/cli/utils/kubernetes_secrets_utils.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import base64 + +from kubernetes import client +from rich.console import Console +from kubernetes.client.rest import ApiException + +from agentex.lib.utils.logging import make_logger +from agentex.lib.cli.utils.kubectl_utils import get_k8s_client + +logger = make_logger(__name__) +console = Console() + +KUBERNETES_SECRET_TYPE_OPAQUE = "Opaque" +KUBERNETES_SECRET_TYPE_DOCKERCONFIGJSON = "kubernetes.io/dockerconfigjson" +KUBERNETES_SECRET_TYPE_BASIC_AUTH = "kubernetes.io/basic-auth" +KUBERNETES_SECRET_TYPE_TLS = "kubernetes.io/tls" + +VALID_SECRET_TYPES = [ + KUBERNETES_SECRET_TYPE_OPAQUE, + KUBERNETES_SECRET_TYPE_DOCKERCONFIGJSON, + KUBERNETES_SECRET_TYPE_BASIC_AUTH, + KUBERNETES_SECRET_TYPE_TLS, +] + +KUBERNETES_SECRET_TO_MANIFEST_KEY = { + KUBERNETES_SECRET_TYPE_OPAQUE: "credentials", + KUBERNETES_SECRET_TYPE_DOCKERCONFIGJSON: "imagePullSecrets", +} + + +def _create_secret_object( + name: str, data: dict[str, str], secret_type: str = KUBERNETES_SECRET_TYPE_OPAQUE +) -> client.V1Secret: + """Helper to create a V1Secret object with multiple key-value pairs""" + return client.V1Secret( + metadata=client.V1ObjectMeta(name=name), + type=secret_type, + string_data=data, # Use string_data for automatic base64 encoding + ) + + +def create_secret_with_data( + name: str, data: dict[str, str], namespace: str, context: str | None = None +) -> None: + """Create a new Kubernetes secret with multiple key-value pairs""" + v1 = get_k8s_client(context) + + try: + # Check if secret exists + v1.read_namespaced_secret(name=name, namespace=namespace) + console.print( + f"[red]Error: Secret '{name}' already exists in namespace '{namespace}'[/red]" + ) + return + except ApiException as e: + if e.status != 404: # If error is not "Not Found" + raise + + # Create the secret + secret = _create_secret_object(name, data) + + try: + v1.create_namespaced_secret(namespace=namespace, body=secret) + console.print( + f"[green]Created secret '{name}' in namespace '{namespace}' with {len(data)} keys[/green]" + ) + except ApiException as e: + console.print(f"[red]Error creating secret: {e.reason}[/red]") + raise RuntimeError(f"Failed to create secret: {str(e)}") from e + + +def update_secret_with_data( + name: str, data: dict[str, str], namespace: str, context: str | None = None +) -> None: + """Create or update a Kubernetes secret with multiple key-value pairs""" + v1 = get_k8s_client(context) + secret = _create_secret_object(name, data) + + try: + # Try to update first + v1.replace_namespaced_secret(name=name, namespace=namespace, body=secret) + console.print( + f"[green]Updated secret '{name}' in namespace '{namespace}' with {len(data)} keys[/green]" + ) + except ApiException as e: + if e.status == 404: + # Secret doesn't exist, create it + try: + v1.create_namespaced_secret(namespace=namespace, body=secret) + console.print( + f"[green]Created secret '{name}' in namespace '{namespace}' with {len(data)} keys[/green]" + ) + except ApiException as create_error: + console.print( + f"[red]Error creating secret: {create_error.reason}[/red]" + ) + raise RuntimeError( + f"Failed to create secret: {str(create_error)}" + ) from create_error + else: + console.print(f"[red]Error updating secret: {e.reason}[/red]") + raise RuntimeError(f"Failed to update secret: {str(e)}") from e + + +def create_image_pull_secret_with_data( + name: str, data: dict[str, str], namespace: str, context: str | None = None +) -> None: + """Create a new Kubernetes image pull secret with dockerconfigjson type""" + v1 = get_k8s_client(context) + + try: + # Check if secret exists + v1.read_namespaced_secret(name=name, namespace=namespace) + console.print( + f"[red]Error: Secret '{name}' already exists in namespace '{namespace}'[/red]" + ) + return + except ApiException as e: + if e.status != 404: # If error is not "Not Found" + raise + + # Create the secret with dockerconfigjson type + secret = _create_secret_object(name, data, KUBERNETES_SECRET_TYPE_DOCKERCONFIGJSON) + + try: + v1.create_namespaced_secret(namespace=namespace, body=secret) + console.print( + f"[green]Created image pull secret '{name}' in namespace '{namespace}' with {len(data)} keys[/green]" + ) + except ApiException as e: + console.print(f"[red]Error creating image pull secret: {e.reason}[/red]") + raise RuntimeError(f"Failed to create image pull secret: {str(e)}") from e + + +def update_image_pull_secret_with_data( + name: str, data: dict[str, str], namespace: str, context: str | None = None +) -> None: + """Create or update a Kubernetes image pull secret with dockerconfigjson type""" + v1 = get_k8s_client(context) + secret = _create_secret_object(name, data, KUBERNETES_SECRET_TYPE_DOCKERCONFIGJSON) + + try: + # Try to update first + v1.replace_namespaced_secret(name=name, namespace=namespace, body=secret) + console.print( + f"[green]Updated image pull secret '{name}' in namespace '{namespace}' with {len(data)} keys[/green]" + ) + except ApiException as e: + if e.status == 404: + # Secret doesn't exist, create it + try: + v1.create_namespaced_secret(namespace=namespace, body=secret) + console.print( + f"[green]Created image pull secret '{name}' in namespace '{namespace}' with {len(data)} keys[/green]" + ) + except ApiException as create_error: + console.print( + f"[red]Error creating image pull secret: {create_error.reason}[/red]" + ) + raise RuntimeError( + f"Failed to create image pull secret: {str(create_error)}" + ) from create_error + else: + console.print(f"[red]Error updating image pull secret: {e.reason}[/red]") + raise RuntimeError(f"Failed to update image pull secret: {str(e)}") from e + + +def get_secret_data( + name: str, namespace: str, context: str | None = None +) -> dict[str, str]: + """Get the actual data from a secret""" + v1 = get_k8s_client(context) + try: + secret = v1.read_namespaced_secret(name=name, namespace=namespace) + if secret.data: # type: ignore[union-attr] + # Decode base64 data + return { + key: base64.b64decode(value).decode("utf-8") + for key, value in secret.data.items() # type: ignore[union-attr] + } + return {} + except ApiException as e: + if e.status == 404: + return {} + raise RuntimeError(f"Failed to get secret data: {str(e)}") from e diff --git a/src/agentex/lib/cli/utils/path_utils.py b/src/agentex/lib/cli/utils/path_utils.py new file mode 100644 index 000000000..d6c881f6d --- /dev/null +++ b/src/agentex/lib/cli/utils/path_utils.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from typing import Dict +from pathlib import Path + +from agentex.lib.utils.logging import make_logger +from agentex.config.agent_manifest import AgentManifest + +logger = make_logger(__name__) + + +class PathResolutionError(Exception): + """An error occurred during path resolution""" + + +def resolve_and_validate_path(base_path: Path, configured_path: str, file_type: str) -> Path: + """Resolve and validate a configured path""" + path_obj = Path(configured_path) + + if path_obj.is_absolute(): + # Absolute path - resolve to canonical form + resolved_path = path_obj.resolve() + else: + # Relative path - resolve relative to manifest directory + resolved_path = (base_path / configured_path).resolve() + + # Validate the file exists + if not resolved_path.exists(): + raise PathResolutionError( + f"{file_type} file not found: {resolved_path}\n" + f" Configured path: {configured_path}\n" + f" Resolved from manifest: {base_path}" + ) + + # Validate it's actually a file + if not resolved_path.is_file(): + raise PathResolutionError(f"{file_type} path is not a file: {resolved_path}") + + return resolved_path + + +def validate_path_security(resolved_path: Path, manifest_dir: Path) -> None: + """Basic security validation for resolved paths""" + try: + # Ensure the resolved path is accessible + resolved_path.resolve() + + # Optional: Add warnings for paths that go too far up + try: + # Check if path goes more than 3 levels up from manifest + relative_to_manifest = resolved_path.relative_to(manifest_dir.parent.parent.parent) + if str(relative_to_manifest).startswith(".."): + logger.warning( + f"Path goes significantly outside project structure: {resolved_path}" + ) + except ValueError: + # Path is outside the tree - that's okay, just log it + logger.info(f"Using path outside manifest directory tree: {resolved_path}") + + except Exception as e: + raise PathResolutionError(f"Path resolution failed: {resolved_path} - {str(e)}") from e + + +def get_file_paths(manifest: AgentManifest, manifest_path: str) -> Dict[str, Path | None]: + """Get resolved file paths from manifest configuration""" + manifest_dir = Path(manifest_path).parent.resolve() + + # Use configured paths or fall back to defaults for backward compatibility + if manifest.local_development and manifest.local_development.paths: + paths_config = manifest.local_development.paths + + # Resolve ACP path + acp_path = resolve_and_validate_path(manifest_dir, paths_config.acp, "ACP server") + validate_path_security(acp_path, manifest_dir) + + # Resolve worker path if specified + worker_path = None + if paths_config.worker: + worker_path = resolve_and_validate_path( + manifest_dir, paths_config.worker, "Temporal worker" + ) + validate_path_security(worker_path, manifest_dir) + else: + # Backward compatibility: use old hardcoded structure + project_dir = manifest_dir / "project" + acp_path = (project_dir / "acp.py").resolve() + worker_path = (project_dir / "run_worker.py").resolve() if manifest.agent.is_temporal_agent() else None + + # Validate backward compatibility paths + if not acp_path.exists(): + raise PathResolutionError(f"ACP file not found: {acp_path}") + + if worker_path and not worker_path.exists(): + raise PathResolutionError(f"Worker file not found: {worker_path}") + + return { + "acp": acp_path, + "worker": worker_path, + "acp_dir": acp_path.parent, + "worker_dir": worker_path.parent if worker_path else None, + } + + +def calculate_uvicorn_target_for_local(acp_path: Path, manifest_dir: Path) -> str: + """Calculate the uvicorn target path for local development""" + # Ensure both paths are resolved to canonical form for accurate comparison + acp_resolved = acp_path.resolve() + manifest_resolved = manifest_dir.resolve() + + try: + # Try to use path relative to manifest directory + acp_relative = acp_resolved.relative_to(manifest_resolved) + # Convert to module notation: project/acp.py -> project.acp + module_path = str(acp_relative.with_suffix('')) # Remove .py extension + module_path = module_path.replace('/', '.') # Convert slashes to dots + module_path = module_path.replace('\\', '.') # Handle Windows paths + return module_path + except ValueError: + # Path cannot be made relative - use absolute file path + logger.warning(f"ACP file {acp_resolved} cannot be made relative to manifest directory {manifest_resolved}, using absolute file path") + return str(acp_resolved) + + +def calculate_docker_acp_module(manifest: AgentManifest, manifest_path: str) -> str: + """Calculate the Python module path for the ACP file in the Docker container + + This should return the same module notation as local development for consistency. + """ + # Use the same logic as local development + manifest_dir = Path(manifest_path).parent + + # Get the configured ACP path (could be relative or absolute) + if manifest.local_development and manifest.local_development.paths: + acp_config_path = manifest.local_development.paths.acp + else: + acp_config_path = "project/acp.py" # Default + + # Resolve to actual file path + acp_path = resolve_and_validate_path(manifest_dir, acp_config_path, "ACP") + + # Use the same module calculation as local development + return calculate_uvicorn_target_for_local(acp_path, manifest_dir) + + + \ No newline at end of file diff --git a/src/agentex/lib/core/__init__.py b/src/agentex/lib/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/core/adapters/__init__.py b/src/agentex/lib/core/adapters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/core/adapters/llm/__init__.py b/src/agentex/lib/core/adapters/llm/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/src/agentex/lib/core/adapters/llm/__init__.py @@ -0,0 +1 @@ + diff --git a/src/agentex/lib/core/adapters/llm/adapter_litellm.py b/src/agentex/lib/core/adapters/llm/adapter_litellm.py new file mode 100644 index 000000000..7935f5f49 --- /dev/null +++ b/src/agentex/lib/core/adapters/llm/adapter_litellm.py @@ -0,0 +1,51 @@ +from typing import override +from collections.abc import Generator, AsyncGenerator + +import litellm as llm + +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 + +logger = make_logger(__name__) + + +class LiteLLMGateway(LLMGateway): + @override + def completion(self, *args, **kwargs) -> Completion: + if kwargs.get("stream", True): + raise ValueError( + "Please use self.completion_stream instead of self.completion to stream responses" + ) + + response = llm.completion(*args, **kwargs) + return Completion.model_validate(response) + + @override + def completion_stream(self, *args, **kwargs) -> Generator[Completion, None, None]: + if not kwargs.get("stream"): + raise ValueError("To use streaming, please set stream=True in the kwargs") + + for chunk in llm.completion(*args, **kwargs): + yield Completion.model_validate(chunk) + + @override + async def acompletion(self, *args, **kwargs) -> Completion: + if kwargs.get("stream", True): + raise ValueError( + "Please use self.acompletion_stream instead of self.acompletion to stream responses" + ) + + # Return a single completion for non-streaming + response = await llm.acompletion(*args, **kwargs) + return Completion.model_validate(response) + + @override + async def acompletion_stream( + self, *args, **kwargs + ) -> AsyncGenerator[Completion, None]: + if not kwargs.get("stream"): + raise ValueError("To use streaming, please set stream=True in the kwargs") + + 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/adapter_sgp.py b/src/agentex/lib/core/adapters/llm/adapter_sgp.py new file mode 100644 index 000000000..31098246e --- /dev/null +++ b/src/agentex/lib/core/adapters/llm/adapter_sgp.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import os +from typing import override +from collections.abc import Generator, AsyncGenerator + +from scale_gp import SGPClient, AsyncSGPClient + +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 + +logger = make_logger(__name__) + + +class SGPLLMGateway(LLMGateway): + def __init__(self, sgp_api_key: str | None = None): + self.sync_client = SGPClient(api_key=os.environ.get("SGP_API_KEY", sgp_api_key)) + self.async_client = AsyncSGPClient( + api_key=os.environ.get("SGP_API_KEY", sgp_api_key) + ) + + @override + def completion(self, *args, **kwargs) -> Completion: + if kwargs.get("stream", True): + raise ValueError( + "Please use self.completion_stream instead of self.completion to stream responses" + ) + + response = self.sync_client.beta.chat.completions.create(*args, **kwargs) + return Completion.model_validate(response) + + @override + def completion_stream(self, *args, **kwargs) -> Generator[Completion, None, None]: + if not kwargs.get("stream"): + raise ValueError("To use streaming, please set stream=True in the kwargs") + + for chunk in self.sync_client.beta.chat.completions.create(*args, **kwargs): + yield Completion.model_validate(chunk) + + @override + async def acompletion(self, *args, **kwargs) -> Completion: + if kwargs.get("stream", True): + raise ValueError( + "Please use self.acompletion_stream instead of self.acompletion to stream responses" + ) + + # Return a single completion for non-streaming + response = await self.async_client.beta.chat.completions.create(*args, **kwargs) + return Completion.model_validate(response) + + @override + async def acompletion_stream( + self, *args, **kwargs + ) -> AsyncGenerator[Completion, None]: + if not kwargs.get("stream"): + raise ValueError("To use streaming, please set stream=True in the kwargs") + + async for chunk in self.async_client.beta.chat.completions.create(*args, **kwargs): # type: ignore[misc] + yield Completion.model_validate(chunk) diff --git a/src/agentex/lib/core/adapters/llm/port.py b/src/agentex/lib/core/adapters/llm/port.py new file mode 100644 index 000000000..4daaade45 --- /dev/null +++ b/src/agentex/lib/core/adapters/llm/port.py @@ -0,0 +1,24 @@ +from abc import ABC, abstractmethod +from collections.abc import Generator, AsyncGenerator + +from agentex.lib.types.llm_messages import Completion + + +class LLMGateway(ABC): + @abstractmethod + def completion(self, *args, **kwargs) -> Completion: + raise NotImplementedError + + @abstractmethod + def completion_stream(self, *args, **kwargs) -> Generator[Completion, None, None]: + raise NotImplementedError + + @abstractmethod + async def acompletion(self, *args, **kwargs) -> Completion: + raise NotImplementedError + + @abstractmethod + async def acompletion_stream( + self, *args, **kwargs + ) -> AsyncGenerator[Completion, None]: + raise NotImplementedError diff --git a/src/agentex/lib/core/adapters/streams/adapter_redis.py b/src/agentex/lib/core/adapters/streams/adapter_redis.py new file mode 100644 index 000000000..8446d67f1 --- /dev/null +++ b/src/agentex/lib/core/adapters/streams/adapter_redis.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import os +import json +import asyncio +from typing import Any, Annotated, override +from collections.abc import AsyncIterator + +import redis.asyncio as redis +from fastapi import Depends + +from agentex.lib.utils.logging import make_logger +from agentex.lib.core.adapters.streams.port import StreamRepository + +logger = make_logger(__name__) + + +_DEFAULT_STREAM_MAXLEN = 10000 +_DEFAULT_STREAM_TTL_SECONDS = 3600 + + +class RedisStreamRepository(StreamRepository): + """ + A simplified Redis implementation of the EventStreamRepository interface. + Optimized for text/JSON streaming with SSE. + """ + + def __init__( + self, + redis_url: str | None = None, + stream_maxlen: int | None = None, + stream_ttl_seconds: int | None = None, + ): + # Get Redis URL from environment if not provided + self.redis_url = redis_url or os.environ.get( + "REDIS_URL", "redis://localhost:6379" + ) + self.redis = redis.from_url(self.redis_url) + self.stream_maxlen = ( + stream_maxlen + if stream_maxlen is not None + else int(os.environ.get("REDIS_STREAM_MAXLEN", _DEFAULT_STREAM_MAXLEN)) + ) + # 0 disables sliding TTL. + self.stream_ttl_seconds = ( + stream_ttl_seconds + if stream_ttl_seconds is not None + else int( + os.environ.get("REDIS_STREAM_TTL_SECONDS", _DEFAULT_STREAM_TTL_SECONDS) + ) + ) + + @override + async def send_event(self, topic: str, event: dict[str, Any]) -> str: + """ + Send an event to a Redis stream. + + Args: + topic: The stream topic/name + event: The event data (will be JSON serialized) + + Returns: + The message ID from Redis + """ + try: + # Simple JSON serialization + event_json = json.dumps(event) + + # # Uncomment to debug + # logger.info(f"Sending event to Redis stream {topic}: {event_json}") + + # Pipeline XADD + EXPIRE in one round-trip so the stream key gets + # a sliding TTL — orphaned streams (no writes for the TTL window) + # self-delete. Mirrors the server-side adapter (scaleapi/scale-agentex#215). + if self.stream_ttl_seconds > 0: + async with self.redis.pipeline(transaction=False) as pipe: + pipe.xadd( + name=topic, + fields={"data": event_json}, + maxlen=self.stream_maxlen, + approximate=True, + ) + pipe.expire(name=topic, time=self.stream_ttl_seconds) + # raise_on_error=False so an EXPIRE failure does not surface + # to the caller after XADD already succeeded — that would + # risk callers retrying and duplicating messages. A failed + # TTL refresh is recoverable: MAXLEN still caps RAM and the + # next write resets the clock. + results = await pipe.execute(raise_on_error=False) + # results[0] = xadd message ID (or Exception) + # results[1] = expire bool (or Exception) + message_id = results[0] + if isinstance(message_id, Exception): + raise message_id + if isinstance(results[1], Exception): + logger.warning( + f"Failed to refresh TTL on stream {topic}: {results[1]}" + ) + else: + message_id = await self.redis.xadd( + name=topic, + fields={"data": event_json}, + maxlen=self.stream_maxlen, + approximate=True, + ) + + return message_id + except Exception as e: + logger.error(f"Error publishing to Redis stream {topic}: {e}") + raise + + @override + async def subscribe( + self, topic: str, last_id: str = "$" + ) -> AsyncIterator[dict[str, Any]]: + """ + Subscribe to a Redis stream and yield events as they come in. + + Args: + topic: The stream topic to subscribe to + last_id: Where to start reading from: + "$" = only new messages (default) + "0" = all messages from the beginning + "" = messages after the specified ID + + Yields: + Parsed event data + """ + + current_id = last_id + + while True: + try: + # Read new messages with a reasonable block time + streams = {topic: current_id} + response = await self.redis.xread( + streams=streams, + count=10, # Get up to 10 messages at a time (reduces overprocessing) + block=2000, # Wait up to 2 seconds for new messages + ) + + if response: + for _, messages in response: + for message_id, fields in messages: + # Update the last_id for next iteration + current_id = message_id + + # Extract and parse the JSON data + if b"data" in fields: + try: + data_str = fields[b"data"].decode("utf-8") + event = json.loads(data_str) + yield event + except Exception as e: + logger.warning( + f"Failed to parse event from Redis stream: {e}" + ) + + # Small sleep to prevent tight loops + await asyncio.sleep(0.01) + + except Exception as e: + logger.error(f"Error reading from Redis stream: {e}") + await asyncio.sleep(1) # Back off on errors + + @override + async def cleanup_stream(self, topic: str) -> None: + """ + Clean up a Redis stream. + + Args: + topic: The stream topic to clean up + """ + try: + await self.redis.delete(topic) + logger.info(f"Cleaned up Redis stream: {topic}") + except Exception as e: + logger.error(f"Error cleaning up Redis stream {topic}: {e}") + raise + + +DRedisStreamRepository = Annotated[ + RedisStreamRepository | None, Depends(RedisStreamRepository) +] diff --git a/src/agentex/lib/core/adapters/streams/port.py b/src/agentex/lib/core/adapters/streams/port.py new file mode 100644 index 000000000..31b5eda61 --- /dev/null +++ b/src/agentex/lib/core/adapters/streams/port.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any +from collections.abc import AsyncIterator + + +class StreamRepository(ABC): + """ + Interface for event streaming repositories. + Used to publish and subscribe to event streams. + """ + + @abstractmethod + async def send_event(self, topic: str, event: dict[str, Any]) -> str: + """ + Send an event to a stream. + + Args: + topic: The stream topic/name + event: The event data + + Returns: + The message ID or other identifier + """ + raise NotImplementedError + + @abstractmethod + async def subscribe( + self, topic: str, last_id: str = "$" + ) -> AsyncIterator[dict[str, Any]]: + """ + Subscribe to a stream and yield events as they come in. + + Args: + topic: The stream topic to subscribe to + last_id: Where to start reading from + + Yields: + Event data + """ + raise NotImplementedError + + @abstractmethod + async def cleanup_stream(self, topic: str) -> None: + """ + Clean up a stream. + + Args: + topic: The stream topic to clean up + """ + raise NotImplementedError diff --git a/src/agentex/lib/core/clients/__init__.py b/src/agentex/lib/core/clients/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/src/agentex/lib/core/clients/__init__.py @@ -0,0 +1 @@ + diff --git a/src/agentex/lib/core/clients/temporal/__init__.py b/src/agentex/lib/core/clients/temporal/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/core/clients/temporal/temporal_client.py b/src/agentex/lib/core/clients/temporal/temporal_client.py new file mode 100644 index 000000000..8b74ddf77 --- /dev/null +++ b/src/agentex/lib/core/clients/temporal/temporal_client.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +from typing import Any +from datetime import timedelta +from collections.abc import Callable + +from temporalio.client import Client, WorkflowExecutionStatus +from temporalio.common import ( + RetryPolicy as TemporalRetryPolicy, + WorkflowIDReusePolicy, + WorkflowIDConflictPolicy, +) +from temporalio.service import RPCError, RPCStatusCode +from temporalio.converter import PayloadCodec, DataConverter + +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.core.clients.temporal.types import ( + TaskStatus, + RetryPolicy, + WorkflowState, + ConflictWorkflowPolicy, + DuplicateWorkflowPolicy, +) +from agentex.lib.core.clients.temporal.utils import get_temporal_client + +logger = make_logger(__name__) + +DEFAULT_RETRY_POLICY = RetryPolicy( + maximum_attempts=1, + initial_interval=timedelta(seconds=1), + backoff_coefficient=2.0, + maximum_interval=timedelta(minutes=10), +) + + +TEMPORAL_STATUS_TO_UPLOAD_STATUS_AND_REASON = { + # TODO: Support canceled status + WorkflowExecutionStatus.CANCELED: WorkflowState( + status=TaskStatus.CANCELED, + reason="Task canceled by the user.", + is_terminal=True, + ), + WorkflowExecutionStatus.COMPLETED: WorkflowState( + status=TaskStatus.COMPLETED, + reason="Task completed successfully.", + is_terminal=True, + ), + WorkflowExecutionStatus.FAILED: WorkflowState( + status=TaskStatus.FAILED, + reason="Task encountered terminal failure. Please contact support if retrying does not resolve the issue.", + is_terminal=True, + ), + WorkflowExecutionStatus.RUNNING: WorkflowState( + status=TaskStatus.RUNNING, + reason="Task is running.", + is_terminal=False, + ), + WorkflowExecutionStatus.TERMINATED: WorkflowState( + status=TaskStatus.CANCELED, + reason="Task canceled by the user.", + is_terminal=True, + ), + WorkflowExecutionStatus.TIMED_OUT: WorkflowState( + status=TaskStatus.FAILED, + reason="Task timed out. Please contact support if retrying does not resolve the issue", + is_terminal=True, + ), + WorkflowExecutionStatus.CONTINUED_AS_NEW: WorkflowState( + status=TaskStatus.RUNNING, + reason="Task is running.", + is_terminal=False, + ), +} + +DUPLICATE_POLICY_TO_ID_REUSE_POLICY = { + DuplicateWorkflowPolicy.ALLOW_DUPLICATE: WorkflowIDReusePolicy.ALLOW_DUPLICATE, + DuplicateWorkflowPolicy.ALLOW_DUPLICATE_FAILED_ONLY: WorkflowIDReusePolicy.ALLOW_DUPLICATE_FAILED_ONLY, + DuplicateWorkflowPolicy.REJECT_DUPLICATE: WorkflowIDReusePolicy.REJECT_DUPLICATE, + DuplicateWorkflowPolicy.TERMINATE_IF_RUNNING: WorkflowIDReusePolicy.TERMINATE_IF_RUNNING, +} + +CONFLICT_POLICY_TO_ID_CONFLICT_POLICY = { + ConflictWorkflowPolicy.UNSPECIFIED: WorkflowIDConflictPolicy.UNSPECIFIED, + ConflictWorkflowPolicy.FAIL: WorkflowIDConflictPolicy.FAIL, + ConflictWorkflowPolicy.USE_EXISTING: WorkflowIDConflictPolicy.USE_EXISTING, + ConflictWorkflowPolicy.TERMINATE_EXISTING: WorkflowIDConflictPolicy.TERMINATE_EXISTING, +} + + +class TemporalClient: + def __init__( + self, + temporal_client: Client | None = None, + plugins: list[Any] = [], + payload_codec: PayloadCodec | None = None, + data_converter: DataConverter | None = None, + ): + self._client: Client | None = temporal_client + self._plugins = plugins + self._payload_codec = payload_codec + self._data_converter = data_converter + + @property + def client(self) -> Client: + """Get the temporal client, raising an error if not initialized.""" + if self._client is None: + raise RuntimeError("Temporal client not initialized - ensure temporal_address is properly configured") + return self._client + + @classmethod + async def create( + cls, + temporal_address: str, + plugins: list[Any] = [], + payload_codec: PayloadCodec | None = None, + data_converter: DataConverter | None = None, + ): + if temporal_address in [ + "false", + "False", + "null", + "None", + "", + "undefined", + False, + None, + ]: + _client = None + else: + _client = await get_temporal_client( + temporal_address, + plugins=plugins, + payload_codec=payload_codec, + data_converter=data_converter, + ) + return cls(_client, plugins, payload_codec, data_converter) + + async def setup(self, temporal_address: str): + self._client = await self._get_temporal_client(temporal_address=temporal_address) + + async def _get_temporal_client(self, temporal_address: str) -> Client | None: + if temporal_address in [ + "false", + "False", + "null", + "None", + "", + "undefined", + False, + None, + ]: + return None + else: + return await get_temporal_client( + temporal_address, + plugins=self._plugins, + payload_codec=self._payload_codec, + data_converter=self._data_converter, + ) + + async def start_workflow( + self, + *args: Any, + duplicate_policy: DuplicateWorkflowPolicy = DuplicateWorkflowPolicy.ALLOW_DUPLICATE, + conflict_policy: ConflictWorkflowPolicy = ConflictWorkflowPolicy.UNSPECIFIED, + retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, + task_timeout: timedelta = timedelta(seconds=10), + execution_timeout: timedelta | None = None, + **kwargs: Any, + ) -> str: + if ( + duplicate_policy == DuplicateWorkflowPolicy.TERMINATE_IF_RUNNING + and conflict_policy != ConflictWorkflowPolicy.UNSPECIFIED + ): + raise ValueError( + "conflict_policy cannot be set when duplicate_policy is TERMINATE_IF_RUNNING; " + "use ConflictWorkflowPolicy.TERMINATE_EXISTING instead" + ) + temporal_retry_policy = TemporalRetryPolicy(**retry_policy.model_dump(exclude_unset=True)) + workflow_handle = await self.client.start_workflow( + *args, + retry_policy=temporal_retry_policy, + task_timeout=task_timeout, + execution_timeout=execution_timeout, + id_reuse_policy=DUPLICATE_POLICY_TO_ID_REUSE_POLICY[duplicate_policy], + id_conflict_policy=CONFLICT_POLICY_TO_ID_CONFLICT_POLICY[conflict_policy], + **kwargs, + ) + return workflow_handle.id + + async def send_signal( + self, + workflow_id: str, + signal: str | Callable[[dict[str, Any] | list[Any] | str | int | float | bool | BaseModel], Any], + payload: dict[str, Any] | list[Any] | str | int | float | bool | BaseModel, + ) -> None: + handle = self.client.get_workflow_handle(workflow_id=workflow_id) + await handle.signal(signal, payload) # type: ignore[misc] + + async def query_workflow( + self, + workflow_id: str, + query: str | Callable[[dict[str, Any] | list[Any] | str | int | float | bool | BaseModel], Any], + ) -> Any: + """ + Submit a query to a workflow by name and return the results. + + Args: + workflow_id: The ID of the workflow to query + query: The name of the query or a callable query function + + Returns: + The result of the query + """ + handle = self.client.get_workflow_handle(workflow_id=workflow_id) + return await handle.query(query) + + async def get_workflow_status(self, workflow_id: str) -> WorkflowState: + try: + handle = self.client.get_workflow_handle(workflow_id=workflow_id) + description = await handle.describe() + return TEMPORAL_STATUS_TO_UPLOAD_STATUS_AND_REASON[description.status] + except RPCError as e: + if e.status == RPCStatusCode.NOT_FOUND: + return WorkflowState( + status="NOT_FOUND", + reason="Workflow not found", + is_terminal=True, + ) + raise + + async def terminate_workflow(self, workflow_id: str) -> None: + return await self.client.get_workflow_handle(workflow_id).terminate() + + async def cancel_workflow(self, workflow_id: str) -> None: + return await self.client.get_workflow_handle(workflow_id).cancel() diff --git a/src/agentex/lib/core/clients/temporal/types.py b/src/agentex/lib/core/clients/temporal/types.py new file mode 100644 index 000000000..eceef154e --- /dev/null +++ b/src/agentex/lib/core/clients/temporal/types.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from enum import Enum +from datetime import timedelta + +from pydantic import Field + +from agentex.lib.utils.model_utils import BaseModel + + +class WorkflowState(BaseModel): + status: str + is_terminal: bool + reason: str | None = None + + +class RetryPolicy(BaseModel): + initial_interval: timedelta = Field( + timedelta(seconds=1), + description="Backoff interval for the first retry. Default 1s.", + ) + backoff_coefficient: float = Field( + 2.0, + description="Coefficient to multiply previous backoff interval by to get new interval. Default 2.0.", + ) + maximum_interval: timedelta | None = Field( + None, + description="Maximum backoff interval between retries. Default 100x :py:attr:`initial_interval`.", + ) + maximum_attempts: int = Field( + 0, + description="Maximum number of attempts. If 0, the default, there is no maximum.", + ) + + +class DuplicateWorkflowPolicy(str, Enum): + ALLOW_DUPLICATE = "ALLOW_DUPLICATE" + ALLOW_DUPLICATE_FAILED_ONLY = "ALLOW_DUPLICATE_FAILED_ONLY" + REJECT_DUPLICATE = "REJECT_DUPLICATE" + TERMINATE_IF_RUNNING = "TERMINATE_IF_RUNNING" + + +class ConflictWorkflowPolicy(str, Enum): + UNSPECIFIED = "UNSPECIFIED" + FAIL = "FAIL" + USE_EXISTING = "USE_EXISTING" + TERMINATE_EXISTING = "TERMINATE_EXISTING" + + +class TaskStatus(str, Enum): + CANCELED = "CANCELED" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + RUNNING = "RUNNING" + TERMINATED = "TERMINATED" + TIMED_OUT = "TIMED_OUT" diff --git a/src/agentex/lib/core/clients/temporal/utils.py b/src/agentex/lib/core/clients/temporal/utils.py new file mode 100644 index 000000000..15b08cec6 --- /dev/null +++ b/src/agentex/lib/core/clients/temporal/utils.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import dataclasses +from typing import Any + +from temporalio.client import Client, Plugin as ClientPlugin +from temporalio.worker import Interceptor +from temporalio.runtime import Runtime, TelemetryConfig, OpenTelemetryConfig +from temporalio.converter import PayloadCodec, DataConverter +from temporalio.contrib.pydantic import pydantic_data_converter + +from agentex.lib.core.tracing.temporal import temporal_tracing_interceptors + +# class DateTimeJSONEncoder(AdvancedJSONEncoder): +# def default(self, o: Any) -> Any: +# if isinstance(o, datetime.datetime): +# return o.isoformat() +# return super().default(o) + + +# class DateTimeJSONTypeConverter(JSONTypeConverter): +# def to_typed_value( +# self, hint: Type, value: Any +# ) -> Union[Optional[Any], _JSONTypeConverterUnhandled]: +# if hint == datetime.datetime: +# return datetime.datetime.fromisoformat(value) +# return JSONTypeConverter.Unhandled + + +# class DateTimePayloadConverter(CompositePayloadConverter): +# def __init__(self) -> None: +# json_converter = JSONPlainPayloadConverter( +# encoder=DateTimeJSONEncoder, +# custom_type_converters=[DateTimeJSONTypeConverter()], +# ) +# super().__init__( +# *[ +# c if not isinstance(c, JSONPlainPayloadConverter) else json_converter +# for c in DefaultPayloadConverter.default_encoding_payload_converters +# ] +# ) + + +# custom_data_converter = dataclasses.replace( +# DataConverter.default, +# payload_converter_class=DateTimePayloadConverter, +# ) + + +def validate_client_plugins(plugins: list[Any]) -> None: + """ + Validate that all items in the plugins list are valid Temporal client plugins. + + Args: + plugins: List of plugins to validate + + Raises: + TypeError: If any plugin is not a valid ClientPlugin instance + """ + for i, plugin in enumerate(plugins): + if not isinstance(plugin, ClientPlugin): + raise TypeError( + f"Plugin at index {i} must be an instance of temporalio.client.Plugin, " + f"got {type(plugin).__name__}. Note: WorkerPlugin is not valid for workflow clients." + ) + + +def validate_worker_interceptors(interceptors: list[Any]) -> None: + """ + Validate that all items in the interceptors list are valid Temporal worker interceptors. + + Args: + interceptors: List of interceptors to validate + + Raises: + TypeError: If any interceptor is not a valid Interceptor instance + """ + for i, interceptor in enumerate(interceptors): + if not isinstance(interceptor, Interceptor): + raise TypeError( + f"Interceptor at index {i} must be an instance of temporalio.worker.Interceptor, " + f"got {type(interceptor).__name__}" + ) + + +async def get_temporal_client( + temporal_address: str, + metrics_url: str | None = None, + plugins: list[Any] = [], + payload_codec: PayloadCodec | None = None, + data_converter: DataConverter | None = None, +) -> Client: + """ + Create a Temporal client with plugin integration. + + Args: + temporal_address: Temporal server address + metrics_url: Optional metrics endpoint URL + plugins: List of Temporal plugins to include + payload_codec: Optional payload codec for encoding/decoding payloads + (e.g. encryption, compression). Cannot be combined with the + OpenAIAgentsPlugin via this kwarg — see ``data_converter``. + data_converter: Optional pre-built ``DataConverter``. Use this when + composing the OpenAIAgentsPlugin with a payload codec: build a + ``DataConverter(payload_converter_class=OpenAIPayloadConverter, + payload_codec=...)`` and pass it here. Mutually exclusive with + ``payload_codec``. + + Returns: + Configured Temporal client + """ + # Validate plugins if any are provided + if plugins: + validate_client_plugins(plugins) + + if payload_codec is not None and data_converter is not None: + raise ValueError( + "Pass payload_codec inside `data_converter` " + "(DataConverter(..., payload_codec=...)) instead of as a separate " + "kwarg. Specifying both is ambiguous." + ) + + # Lazy import to avoid pulling in opentelemetry.sdk for non-Temporal agents + from temporalio.contrib.openai_agents import OpenAIAgentsPlugin + + has_openai_plugin = any(isinstance(p, OpenAIAgentsPlugin) for p in (plugins or [])) + + if has_openai_plugin and payload_codec is not None and data_converter is None: + raise ValueError( + "payload_codec passed as a kwarg alongside OpenAIAgentsPlugin would " + "be silently dropped by the plugin's data-converter transformer. " + "Build a DataConverter explicitly with " + "`payload_converter_class=OpenAIPayloadConverter` (or a subclass) " + "and `payload_codec=...`, then pass it via the `data_converter` " + "kwarg instead." + ) + + connect_kwargs: dict[str, Any] = { + "target_host": temporal_address, + "plugins": plugins, + # Propagate OTel trace context on outbound start_workflow / execute_activity + # (enabled by default; AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED=false to disable). + "interceptors": temporal_tracing_interceptors(), + } + + if data_converter is not None: + connect_kwargs["data_converter"] = data_converter + elif not has_openai_plugin: + dc = pydantic_data_converter + if payload_codec: + dc = dataclasses.replace(dc, payload_codec=payload_codec) + connect_kwargs["data_converter"] = dc + + if not metrics_url: + client = await Client.connect(**connect_kwargs) + else: + runtime = Runtime(telemetry=TelemetryConfig(metrics=OpenTelemetryConfig(url=metrics_url))) + connect_kwargs["runtime"] = runtime + client = await Client.connect(**connect_kwargs) + return client diff --git a/src/agentex/lib/core/compat/__init__.py b/src/agentex/lib/core/compat/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/src/agentex/lib/core/compat/__init__.py @@ -0,0 +1 @@ + diff --git a/src/agentex/lib/core/compat/version_guard.py b/src/agentex/lib/core/compat/version_guard.py new file mode 100644 index 000000000..56933de0b --- /dev/null +++ b/src/agentex/lib/core/compat/version_guard.py @@ -0,0 +1,164 @@ +"""Runtime SDK ↔ backend contract-version guard. + +Complements the *build-time* cross-version compatibility tests (``tests/compat``): + +- **Build-time** (CI): is this *client* compatible with the window of supported server + contracts (``min-supported``..``current``)? +- **Runtime** (this module): is the *server* the SDK is pointed at within that window? + +It runs once at ACP/worker startup, reads the backend's contract version (the version +the server already reports via ``/openapi.json`` ``info.version``), and **fails fast with +an actionable error** if the backend is older than this SDK supports — instead of the +mismatch surfacing later as opaque 500s / missing-field errors deep in a request. + +``MIN_BACKEND_CONTRACT`` is the same source of truth as the ``min-supported`` server +contract in ``tests/compat/server_specs/manifest.json``: the oldest agentex backend this +SDK version supports. Bump both together when a breaking change raises the floor. +""" + +from __future__ import annotations + +import os +import re + +import httpx + +from agentex.lib.utils.logging import make_logger + +logger = make_logger(__name__) + +# Oldest agentex backend contract this SDK is compatible with. +# Keep in sync with the `min-supported` spec in tests/compat (#407); the version axis +# itself comes from scale-agentex release tags (#321). Bump on a breaking SDK change. +MIN_BACKEND_CONTRACT = "0.1.0" + +SKIP_ENV = "AGENTEX_SKIP_VERSION_CHECK" + +# Full-string SemVer. Accepts: `1.2.3`, leading `v`, surrounding whitespace, `-prerelease` +# (captured), `+build` (ignored). Anchored at both ends so a malformed tail (`0.1.0rc1`, +# `0.1.0.1`) is rejected → None → "unknown, proceed", not silently coerced to stable `0.1.0`. +_VERSION_RE = re.compile( + r"^\s*v?(\d+)\.(\d+)\.(\d+)" # major.minor.patch + r"(?:-([0-9A-Za-z.-]+))?" # optional -prerelease (captured) + r"(?:\+[0-9A-Za-z.-]+)?" # optional +build metadata (ignored) + r"\s*$" +) + + +class IncompatibleBackendError(RuntimeError): + """Raised when the agentex backend is older than this SDK's minimum supported contract.""" + + +def _parse(version: str | None) -> tuple[int, int, int, str | None] | None: + """Parse ``major.minor.patch[-prerelease]`` → ``(major, minor, patch, prerelease)``. + + ``prerelease`` is the raw dot-separated identifier string (e.g. ``"rc.1"``), or None for + a stable release. Build metadata (after ``+``) is ignored. Returns None if unparseable. + """ + m = _VERSION_RE.match(version or "") + if not m: + return None + return (int(m.group(1)), int(m.group(2)), int(m.group(3)), m.group(4) or None) + + +# Comparable SemVer precedence key. The 4th element keeps a uniform shape across stable and +# prerelease so the whole tuple is orderable: (rank, identifiers), where stable rank 1 > prerelease +# rank 0 (and the identifier list is only ever compared when both sides are prereleases, rank 0). +_PreKey = tuple[int, int, int, tuple[int, list[tuple[int, int, str]]]] + + +def _precedence_key(parsed: tuple[int, int, int, str | None]) -> _PreKey: + """SemVer §11 precedence key (directly comparable with ``<``). + + A stable release outranks any prerelease of the same triplet (``0.1.0-rc.1 < 0.1.0``); + among prereleases, numeric identifiers rank below alphanumeric and compare field-by-field, + with a longer identifier list outranking a shorter prefix-equal one. + """ + major, minor, patch, prerelease = parsed + if prerelease is None: + return (major, minor, patch, (1, [])) # stable sorts above every prerelease + identifiers: list[tuple[int, int, str]] = [] + for ident in prerelease.split("."): + if ident.isdigit(): + identifiers.append((0, int(ident), "")) # numeric: lowest class, numeric order + else: + identifiers.append((1, 0, ident)) # alphanumeric: higher class, lexical order + return (major, minor, patch, (0, identifiers)) + + +def _truthy(name: str) -> bool: + return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on") + + +async def fetch_backend_version(base_url: str, *, timeout: float = 5.0) -> str | None: + """Return the backend's reported contract version (``/openapi.json`` ``info.version``), or None.""" + url = base_url.rstrip("/") + "/openapi.json" + try: + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.get(url) + resp.raise_for_status() + return (resp.json().get("info") or {}).get("version") + except Exception as exc: # noqa: BLE001 - any failure → unknown, handled by caller + logger.warning("backend version guard: could not fetch %s (%s)", url, exc) + return None + + +async def assert_backend_compatible( + base_url: str | None, + *, + min_version: str = MIN_BACKEND_CONTRACT, + sdk_version: str | None = None, +) -> None: + """Fail fast at startup if the backend is older than ``min_version``. + + No-op (warns, does not raise) when: + - ``AGENTEX_SKIP_VERSION_CHECK`` is set (explicit bypass), + - ``base_url`` is unset, + - the backend version can't be determined (unreachable / unparseable) — a transient + blip or a contract-less server shouldn't crash startup. + + Raises ``IncompatibleBackendError`` only when the backend version is *known* and older + than ``min_version``. + """ + if _truthy(SKIP_ENV): + logger.warning("%s set — skipping backend version guard", SKIP_ENV) + return + if not base_url: + return + + if sdk_version is None: + from agentex._version import __version__ as sdk_version # local import to avoid cycles + + backend_version = await fetch_backend_version(base_url) + if backend_version is None: + logger.warning( + "backend version guard: could not determine backend version at %s; proceeding " + "(set %s=1 to silence).", + base_url, + SKIP_ENV, + ) + return + + backend, minimum = _parse(backend_version), _parse(min_version) + if backend is None or minimum is None: + logger.warning( + "backend version guard: unparseable version(s) backend=%r min=%r; proceeding.", + backend_version, + min_version, + ) + return + + if _precedence_key(backend) < _precedence_key(minimum): + raise IncompatibleBackendError( + f"agentex-sdk {sdk_version} requires agentex backend >= {min_version}, " + f"but {base_url} reports {backend_version}. " + f"Upgrade the backend, or pin agentex-sdk to a version compatible with backend " + f"{backend_version}. (Set {SKIP_ENV}=1 to bypass at your own risk.)" + ) + + logger.info( + "backend version guard OK: sdk=%s backend=%s (min=%s)", + sdk_version, + backend_version, + min_version, + ) diff --git a/src/agentex/lib/core/harness/__init__.py b/src/agentex/lib/core/harness/__init__.py new file mode 100644 index 000000000..067751d63 --- /dev/null +++ b/src/agentex/lib/core/harness/__init__.py @@ -0,0 +1,30 @@ +"""Shared, harness-independent machinery for the unified harness surface. + +The Agentex StreamTaskMessage* stream is the single source of truth; this +package derives spans from it and delivers it (yield or auto-send), so every +harness tap gets streaming + tracing + turn usage uniformly. +""" + +from agentex.lib.core.harness.types import ( + OpenSpan, + CloseSpan, + TurnUsage, + SpanSignal, + TurnResult, + HarnessTurn, + StreamTaskMessage, +) +from agentex.lib.core.harness.tracer import SpanTracer +from agentex.lib.core.harness.emitter import UnifiedEmitter + +__all__ = [ + "UnifiedEmitter", + "SpanTracer", + "OpenSpan", + "CloseSpan", + "SpanSignal", + "StreamTaskMessage", + "TurnUsage", + "TurnResult", + "HarnessTurn", +] diff --git a/src/agentex/lib/core/harness/auto_send.py b/src/agentex/lib/core/harness/auto_send.py new file mode 100644 index 000000000..b645a4aae --- /dev/null +++ b/src/agentex/lib/core/harness/auto_send.py @@ -0,0 +1,156 @@ +"""Auto-send delivery: canonical stream -> adk.streaming side effects + tracing.""" + +from __future__ import annotations + +from typing import Any, AsyncIterator +from datetime import datetime + +from agentex.types.text_delta import TextDelta +from agentex.types.text_content import TextContent +from agentex.lib.core.harness.types import TurnUsage, TurnResult, StreamTaskMessage +from agentex.lib.core.harness.tracer import SpanTracer +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.lib.core.harness.span_derivation import SpanDeriver + +try: + from agentex.lib.utils.logging import make_logger + + logger = make_logger(__name__) +except Exception: # ddtrace may be absent in some envs; fall back to stdlib + import logging + + logger = logging.getLogger(__name__) + + +async def auto_send( + events: AsyncIterator[StreamTaskMessage], + task_id: str, + tracer: SpanTracer | None = None, + streaming: Any = None, + usage: TurnUsage | None = None, + created_at: datetime | None = None, +) -> TurnResult: + """Push the canonical stream to the task stream via adk.streaming. + + Opens a streaming context per message (keyed by index), streams deltas via + ctx.stream_update, and closes via ctx.close() on Done. Posts tool + request/response full messages by opening a context with the content and + closing it immediately (no deltas). Derives and traces spans from the same + stream. Returns the last text segment's text + usage. + + Index-keyed routing: each Start(index=i) opens a context stored in + ctx_map[i]; Delta(index=i) routes to ctx_map.get(i); Done(index=i) closes + and removes ctx_map[i]. Events with index is None are skipped. The finally + block closes all remaining open contexts. + + final_text last-segment semantics: a new Start(TextContent) resets + final_text_parts so that multi-step turns return the LAST text segment. + Full(TextContent) also overwrites final_text_parts (same semantics). + + created_at is forwarded to every streaming_task_message_context call so + callers can back-date message timestamps. + + Mirrors the open/close/stream_update pattern from + src/agentex/lib/adk/_modules/_langgraph_turn.py: + - context opened via streaming_task_message_context(...).__aenter__() + - context closed via ctx.close() (not __aexit__) + - deltas pushed as StreamTaskMessageDelta with parent_task_message set + from ctx.task_message + + For async + temporal agents (call from inside an activity). + """ + if streaming is None: + from agentex.lib import adk + + streaming = adk.streaming + + deriver = SpanDeriver() if tracer is not None else None + final_text_parts: list[str] = [] + ctx_map: dict[int, Any] = {} + + async def _close_all() -> None: + # Guard each close independently: a failure on one context (e.g. a + # backend hiccup during teardown) must not abandon the remaining open + # contexts, otherwise their task messages would never be finalized. + for ctx in list(ctx_map.values()): + try: + await ctx.close() + except Exception as exc: + logger.warning("[harness.auto_send] context close failed during teardown: %s", exc) + ctx_map.clear() + + try: + async for event in events: + if deriver is not None and tracer is not None: + for signal in deriver.observe(event): + await tracer.handle(signal) + + if isinstance(event, StreamTaskMessageStart): + if event.index is None: + continue + i = event.index + # Reset final_text_parts when a new text segment starts + if isinstance(event.content, TextContent): + final_text_parts = [] + ctx = streaming.streaming_task_message_context( + task_id=task_id, + initial_content=event.content, + created_at=created_at, + ) + ctx_map[i] = await ctx.__aenter__() + + elif isinstance(event, StreamTaskMessageDelta): + if event.index is None: + continue + ctx = ctx_map.get(event.index) + if ctx is not None and event.delta is not None: + # Reconstruct the delta with parent_task_message set from + # the context's task_message (mirrors the legacy + # _langgraph_async streaming helper, now in _langgraph_turn.py). + delta_with_parent = StreamTaskMessageDelta( + parent_task_message=ctx.task_message, + delta=event.delta, + type="delta", + index=event.index, + ) + await ctx.stream_update(delta_with_parent) + if isinstance(event.delta, TextDelta) and event.delta.text_delta: + final_text_parts.append(event.delta.text_delta) + + elif isinstance(event, StreamTaskMessageDone): + if event.index is None: + continue + ctx = ctx_map.pop(event.index, None) + if ctx is not None: + await ctx.close() + + elif isinstance(event, StreamTaskMessageFull): + # Full messages: post the full message by opening a context + # with the content and closing it immediately (no deltas; + # StreamingTaskMessageContext.close() persists initial_content + # when the accumulator is empty). Use async with so the context + # is closed even if close() raises (__aexit__ delegates to + # close()). + # Full(TextContent) also resets final_text_parts for + # last-segment semantics. + if isinstance(event.content, TextContent): + final_text_parts = [event.content.content] + async with streaming.streaming_task_message_context( + task_id=task_id, + initial_content=event.content, + created_at=created_at, + ): + pass + + finally: + await _close_all() + if deriver is not None and tracer is not None: + for signal in deriver.flush(): + await tracer.handle(signal) + + return TurnResult(final_text="".join(final_text_parts), usage=usage or TurnUsage()) diff --git a/src/agentex/lib/core/harness/emitter.py b/src/agentex/lib/core/harness/emitter.py new file mode 100644 index 000000000..5b56793bf --- /dev/null +++ b/src/agentex/lib/core/harness/emitter.py @@ -0,0 +1,80 @@ +"""UnifiedEmitter: the single facade agent authors use for either delivery mode.""" + +from __future__ import annotations + +from typing import AsyncGenerator +from datetime import datetime + +from agentex.lib.core.harness.types import TurnResult, HarnessTurn, StreamTaskMessage +from agentex.lib.core.harness.tracer import SpanTracer +from agentex.lib.core.harness.auto_send import auto_send +from agentex.lib.core.harness.yield_delivery import yield_events + + +class UnifiedEmitter: + """Ties trace context + chosen delivery together. + + Tracing modes (the `tracer` arg): + - tracer=None (default): auto-construct a SpanTracer if `trace_id` is present. + - tracer=False: disable tracing entirely, regardless of `trace_id`. + - tracer=: use the supplied instance. + + `tracing` and `streaming` are injection escape-hatches for tests/advanced + use; leave them None in production so the real adk modules are used. + """ + + tracer: SpanTracer | None + + def __init__( + self, + task_id: str, + trace_id: str | None, + parent_span_id: str | None, + tracer: SpanTracer | bool | None = None, + tracing: object | None = None, + streaming: object | None = None, + ): + self.task_id = task_id + self.trace_id = trace_id + self.parent_span_id = parent_span_id + self._streaming = streaming + if tracer is False: + self.tracer = None + elif isinstance(tracer, SpanTracer): + self.tracer = tracer + elif trace_id: + self.tracer = SpanTracer( + trace_id=trace_id, + parent_span_id=parent_span_id, + task_id=task_id, + tracing=tracing, + ) + else: + self.tracer = None + + async def yield_turn(self, turn: HarnessTurn) -> AsyncGenerator[StreamTaskMessage, None]: + """Sync HTTP ACP delivery: forward events, trace as side effect.""" + async for event in yield_events(turn.events, tracer=self.tracer): + yield event + + async def auto_send_turn(self, turn: HarnessTurn, created_at: datetime | None = None) -> TurnResult: + """Async/temporal delivery: push to the task stream, return TurnResult. + + Pass `created_at` (e.g. `workflow.now()` under Temporal) to stamp the + turn's messages with a deterministic timestamp; it is forwarded to the + streaming contexts. Default None preserves server-side timestamps. + """ + # `turn.usage()` is only valid AFTER `turn.events` is exhausted (the + # HarnessTurn single-pass contract: real turns populate usage while the + # stream is consumed). So drive delivery first, then read usage — do NOT + # pass `usage=turn.usage()` eagerly here (that would capture the empty + # default before the stream runs). + result = await auto_send( + turn.events, + task_id=self.task_id, + tracer=self.tracer, + streaming=self._streaming, + created_at=created_at, + ) + result.usage = turn.usage() + return result diff --git a/src/agentex/lib/core/harness/span_derivation.py b/src/agentex/lib/core/harness/span_derivation.py new file mode 100644 index 000000000..c0ed6ee90 --- /dev/null +++ b/src/agentex/lib/core/harness/span_derivation.py @@ -0,0 +1,173 @@ +"""Pure reducer: canonical StreamTaskMessage* stream -> span open/close signals. + +Has no dependency on adk; unit-testable in isolation. Delivery adapters feed it +every event and act on the returned signals. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass + +from agentex.lib.core.harness.types import OpenSpan, CloseSpan, SpanSignal, StreamTaskMessage +from agentex.types.tool_request_delta import ToolRequestDelta +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.types.reasoning_content_delta import ReasoningContentDelta +from agentex.types.reasoning_summary_delta import ReasoningSummaryDelta + + +@dataclass +class _ToolReqMeta: + tool_call_id: str + name: str + arguments: dict[str, object] + args_buf: str = "" # accumulated streamed argument fragments + + +class SpanDeriver: + """Stateful reducer over the canonical stream. + + Tool span: open on Done of a ToolRequestContent index; close on matching + ToolResponseContent by tool_call_id. Reasoning span: open on + Start(ReasoningContent); close on that index's Done. + + Deliberate contracts: + - A `Full(ToolResponseContent)` whose tool_call_id was never opened is + ignored (no CloseSpan emitted). + - A `Done` for an index that was never a tool_request/reasoning Start is + ignored (no signal emitted). + - Events with `index is None` are skipped entirely; without a stable index + they cannot be reliably paired, and aliasing them to a sentinel would + let unrelated None-indexed events cross-match. + - `flush()` closes anything still open as incomplete; unclosed tool spans + are emitted in the order they were opened. + """ + + def __init__(self) -> None: + self._tool_by_index: dict[int, _ToolReqMeta] = {} + self._reasoning_index_open: set[int] = set() + # accumulated reasoning text per open reasoning index, recorded as the + # span output on close (deltas carry the chain-of-thought / summary text). + self._reasoning_text: dict[int, str] = {} + # insertion-ordered set of open tool_call_ids (dict keys preserve order) + self._open_tool_ids: dict[str, None] = {} + + def observe(self, event: StreamTaskMessage) -> list[SpanSignal]: + if isinstance(event, StreamTaskMessageStart): + return self._on_start(event) + if isinstance(event, StreamTaskMessageDelta): + return self._on_delta(event) + if isinstance(event, StreamTaskMessageFull): + return self._on_full(event) + if isinstance(event, StreamTaskMessageDone): + return self._on_done(event) + return [] + + def flush(self) -> list[SpanSignal]: + """Close anything still open at end of stream, marked incomplete.""" + signals: list[SpanSignal] = [] + for tcid in list(self._open_tool_ids): + signals.append(CloseSpan(key=tcid, output=None, is_complete=False)) + self._open_tool_ids.clear() + for idx in sorted(self._reasoning_index_open): + text = self._reasoning_text.pop(idx, "") + signals.append(CloseSpan(key=f"reasoning:{idx}", output=text or None, is_complete=False)) + self._reasoning_index_open.clear() + self._reasoning_text.clear() + return signals + + def _on_start(self, event: StreamTaskMessageStart) -> list[SpanSignal]: + if event.index is None: + return [] + idx = event.index + content = event.content + if isinstance(content, ToolRequestContent): + self._tool_by_index[idx] = _ToolReqMeta( + tool_call_id=content.tool_call_id, + name=content.name, + arguments=dict(content.arguments or {}), + ) + return [] + if content.type == "reasoning": + self._reasoning_index_open.add(idx) + # Seed from any text already on the Start content — non-streaming + # harnesses may carry the full reasoning up front; deltas append. + summary = getattr(content, "summary", None) or [] + body = getattr(content, "content", None) or [] + self._reasoning_text[idx] = "".join([*summary, *body]) + return [OpenSpan(key=f"reasoning:{idx}", kind="reasoning", name="reasoning", input={})] + return [] + + def _on_delta(self, event: StreamTaskMessageDelta) -> list[SpanSignal]: + if event.index is None: + return [] + idx = event.index + delta = event.delta + if isinstance(delta, ToolRequestDelta): + meta = self._tool_by_index.get(idx) + if meta is not None and delta.arguments_delta: + meta.args_buf += delta.arguments_delta + elif isinstance(delta, ReasoningContentDelta): + if idx in self._reasoning_index_open and delta.content_delta: + self._reasoning_text[idx] = self._reasoning_text.get(idx, "") + delta.content_delta + elif isinstance(delta, ReasoningSummaryDelta): + if idx in self._reasoning_index_open and delta.summary_delta: + self._reasoning_text[idx] = self._reasoning_text.get(idx, "") + delta.summary_delta + return [] + + def _on_full(self, event: StreamTaskMessageFull) -> list[SpanSignal]: + """Handle a Full event. + + A `Full(ToolRequestContent)` opens a tool span (keyed by tool_call_id) + if it is not already open; the matching `Full(ToolResponseContent)` + closes it. This handles harnesses (e.g. LangGraph) that emit tool calls + as a single Full rather than Start+Done. + """ + content = event.content + if isinstance(content, ToolRequestContent): + tcid = content.tool_call_id + if tcid not in self._open_tool_ids: + self._open_tool_ids[tcid] = None + args = dict(content.arguments or {}) + return [OpenSpan(key=tcid, kind="tool", name=content.name, input=args)] + return [] + if isinstance(content, ToolResponseContent): + tcid = content.tool_call_id + if tcid in self._open_tool_ids: + self._open_tool_ids.pop(tcid, None) + return [ + CloseSpan( + key=tcid, + output=content.content, + is_complete=True, + is_error=content.is_error, + ) + ] + return [] + + def _on_done(self, event: StreamTaskMessageDone) -> list[SpanSignal]: + if event.index is None: + return [] + idx = event.index + meta = self._tool_by_index.pop(idx, None) + if meta is not None: + args = meta.arguments + if meta.args_buf: + try: + args = json.loads(meta.args_buf) + except json.JSONDecodeError: + args = {"_raw": meta.args_buf} + self._open_tool_ids[meta.tool_call_id] = None + return [OpenSpan(key=meta.tool_call_id, kind="tool", name=meta.name, input=args)] + if idx in self._reasoning_index_open: + self._reasoning_index_open.discard(idx) + text = self._reasoning_text.pop(idx, "") + return [CloseSpan(key=f"reasoning:{idx}", output=text or None, is_complete=True)] + return [] diff --git a/src/agentex/lib/core/harness/tracer.py b/src/agentex/lib/core/harness/tracer.py new file mode 100644 index 000000000..34cd95616 --- /dev/null +++ b/src/agentex/lib/core/harness/tracer.py @@ -0,0 +1,119 @@ +"""Adapter from SpanSignals to adk.tracing spans (best-effort, overridable).""" + +from __future__ import annotations + +from typing import Any + +from agentex.lib.core.harness.types import OpenSpan, CloseSpan, SpanSignal + +try: + from agentex.lib.core.tracing.lineage import resolve_refs, merge_refs_into_data +except Exception: # keep the harness importable without optional tracing deps + + def resolve_refs(tool_name: str, arguments: dict[str, Any] | None) -> list[dict[str, Any]]: # noqa: ARG001 + return [] + + def merge_refs_into_data(data: dict[str, Any] | None, refs: list[dict[str, Any]]) -> dict[str, Any]: # noqa: ARG001 + return dict(data or {}) + + +try: + from agentex.lib.utils.logging import make_logger + + logger = make_logger(__name__) +except Exception: # ddtrace may be absent in some envs; fall back to stdlib + import logging + + logger = logging.getLogger(__name__) + + +def _as_span_payload(value: Any, *, key: str) -> Any: + """Coerce a span input/output payload into a dict. + + The SGP spans API requires ``input`` and ``output`` to be objects: a scalar + or string is rejected with a 422 and the span is dropped by the async + processor. The SpanDeriver legitimately produces non-dict payloads — the + reasoning span's output is the chain-of-thought string, and some harnesses' + tool results are plain strings — so wrap anything that isn't already a dict + (``None`` passes through unchanged so an absent payload stays absent). + """ + if value is None or isinstance(value, dict): + return value + return {key: value} + + +class SpanTracer: + """Opens/closes adk.tracing child spans in response to span signals. + + `tracing` defaults to the real `adk.tracing` module; inject a fake in tests + or a custom tracer to override. No-op when `trace_id` is falsy. Never raises. + + The real TracingModule.end_span does NOT accept an output kwarg — output is + recorded by mutating span.output before calling end_span, matching the pattern + used throughout the codebase. + + Span-lifecycle contract: the `_open` dict (span key -> span object) is scoped + to a single turn. Pairing is by `key`: + - A duplicate OpenSpan for a key already in `_open` silently replaces the + earlier span; the earlier span is then orphaned (never closed / leaked). + - A CloseSpan for an unknown key is a no-op. + - Unpaired opens accumulate in `_open` for the lifetime of the tracer; since + a tracer is expected to live for one turn, this is bounded and acceptable. + """ + + def __init__( + self, + trace_id: str | None, + parent_span_id: str | None, + tracing: Any = None, + task_id: str | None = None, + ): + self.trace_id = trace_id + self.parent_span_id = parent_span_id + self.task_id = task_id + if tracing is None: + from agentex.lib import adk + + tracing = adk.tracing + self._tracing = tracing + self._open: dict[str, Any] = {} # span key -> span object + + async def handle(self, signal: SpanSignal) -> None: + if not self.trace_id: + return + try: + if isinstance(signal, OpenSpan): + span = await self._tracing.start_span( + trace_id=self.trace_id, + name=signal.name, + input=_as_span_payload(signal.input, key="input"), + parent_id=self.parent_span_id, + task_id=self.task_id, + ) + if span is not None: + if signal.kind == "tool": + refs = resolve_refs(signal.name, signal.input if isinstance(signal.input, dict) else {}) + if refs: + data = span.data if isinstance(span.data, dict) else {} + span.data = merge_refs_into_data(data, refs) + self._open[signal.key] = span + elif isinstance(signal, CloseSpan): + span = self._open.pop(signal.key, None) + if span is not None: + # Output is recorded by mutating span.output before end_span. + # The real TracingModule.end_span signature is: + # end_span(trace_id, span, start_to_close_timeout, heartbeat_timeout, retry_policy) + # It does not accept an output= kwarg. + span.output = _as_span_payload(signal.output, key="output") + # Tool failure status (ToolResponseContent.is_error) is recorded + # on span.data when the harness reports one; Span has no dedicated + # error field. None means no status was reported, so leave data alone. + if signal.is_error is not None: + data = span.data if isinstance(span.data, dict) else {} + span.data = {**data, "is_error": signal.is_error} + await self._tracing.end_span( + trace_id=self.trace_id, + span=span, + ) + except Exception as exc: # best-effort: tracing never breaks delivery + logger.warning("[harness.tracer] span signal failed: %s", exc) diff --git a/src/agentex/lib/core/harness/types.py b/src/agentex/lib/core/harness/types.py new file mode 100644 index 000000000..74e0dc314 --- /dev/null +++ b/src/agentex/lib/core/harness/types.py @@ -0,0 +1,96 @@ +"""Types for the unified harness surface.""" + +from __future__ import annotations + +from typing import Any, Union, Literal, Protocol, AsyncIterator, runtime_checkable +from dataclasses import field, dataclass + +from pydantic import BaseModel, ConfigDict + +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) + +# The canonical stream element. Taps yield these; delivery adapters consume them. +StreamTaskMessage = Union[ + StreamTaskMessageStart, + StreamTaskMessageDelta, + StreamTaskMessageFull, + StreamTaskMessageDone, +] + +SpanKind = Literal["tool", "reasoning", "subagent"] + + +@dataclass +class OpenSpan: + """Signal to open a child span. `key` pairs an open with its close.""" + + key: str + kind: SpanKind + name: str + input: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class CloseSpan: + """Signal to close the span previously opened with the same `key`.""" + + key: str + output: Any = None + is_complete: bool = True # False when closed by flush() without a result + is_error: bool | None = None # tool failure status; None when the harness reports no status + + +SpanSignal = Union[OpenSpan, CloseSpan] + + +class TurnUsage(BaseModel): + """Harness-independent turn usage/cost, attached to the turn span. + + Token field names align with agentex.lib.core.observability.llm_metrics. + """ + + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + model: str | None = None + input_tokens: int | None = None + output_tokens: int | None = None + cached_input_tokens: int | None = None + reasoning_tokens: int | None = None + total_tokens: int | None = None + cost_usd: float | None = None + duration_ms: int | None = None + # num_llm_calls is provider-reported and may be absent (None = "not + # reported"). num_tool_calls / num_reasoning_blocks are counted locally from + # the observed stream, so 0 is always a real count. + num_llm_calls: int | None = None + num_tool_calls: int = 0 + num_reasoning_blocks: int = 0 + + +class TurnResult(BaseModel): + """Returned to the caller after a turn is delivered.""" + + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + final_text: str = "" + usage: TurnUsage = TurnUsage() + + +@runtime_checkable +class HarnessTurn(Protocol): + """A single harness turn: a canonical stream plus its normalized usage. + + Python async generators cannot cleanly return a value to their consumer, so + a tap exposes usage via `usage()` (valid only after `events` is exhausted) + rather than via StopAsyncIteration. + """ + + @property + def events(self) -> AsyncIterator[StreamTaskMessage]: ... + + def usage(self) -> TurnUsage: ... diff --git a/src/agentex/lib/core/harness/yield_delivery.py b/src/agentex/lib/core/harness/yield_delivery.py new file mode 100644 index 000000000..69b39f152 --- /dev/null +++ b/src/agentex/lib/core/harness/yield_delivery.py @@ -0,0 +1,31 @@ +"""Yield delivery: pass the canonical stream through, tracing as a side effect.""" + +from __future__ import annotations + +from typing import AsyncIterator, AsyncGenerator + +from agentex.lib.core.harness.types import StreamTaskMessage +from agentex.lib.core.harness.tracer import SpanTracer +from agentex.lib.core.harness.span_derivation import SpanDeriver + + +async def yield_events( + events: AsyncIterator[StreamTaskMessage], + tracer: SpanTracer | None = None, +) -> AsyncGenerator[StreamTaskMessage, None]: + """Forward each event to the caller; derive + trace spans as a side effect. + + For sync HTTP ACP agents that yield events back over the response. When + `tracer` is None, this is a pure passthrough. + """ + deriver = SpanDeriver() if tracer is not None else None + try: + async for event in events: + if deriver is not None and tracer is not None: + for signal in deriver.observe(event): + await tracer.handle(signal) + yield event + finally: + if deriver is not None and tracer is not None: + for signal in deriver.flush(): + await tracer.handle(signal) diff --git a/src/agentex/lib/core/observability/__init__.py b/src/agentex/lib/core/observability/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/core/observability/llm_metrics.py b/src/agentex/lib/core/observability/llm_metrics.py new file mode 100644 index 000000000..b15e83824 --- /dev/null +++ b/src/agentex/lib/core/observability/llm_metrics.py @@ -0,0 +1,121 @@ +"""OTel metrics for LLM calls. + +Single source of truth for LLM-call instrumentation across all agentex code +paths — temporal+openai_agents streaming today, sync ACP and the Claude SDK +plugin in future PRs. Centralizing the instrument definitions here means +those follow-ups don't need to redefine the metric names, units, or +description strings; they import ``get_llm_metrics()`` and record values. + +The meter is no-op when the application hasn't configured a ``MeterProvider``, +so importing this module is safe for runtimes that don't use OTel. Instruments +are created lazily on first ``get_llm_metrics()`` call so a ``MeterProvider`` +configured *after* this module is imported still binds correctly. + +Cardinality is bounded: +- All metrics carry only ``model`` (the LLM model name). +- ``requests`` additionally carries ``status``, drawn from a small fixed set + (see ``classify_status``). + +Resource attributes (``service.name``, ``k8s.*``, etc.) come from the +application's OTel resource configuration and are added to every series +automatically. +""" + +from __future__ import annotations + +from typing import Optional + +from opentelemetry import metrics + + +class LLMMetrics: + """Lazily-created OTel instruments for LLM call telemetry.""" + + def __init__(self) -> None: + meter = metrics.get_meter("agentex.llm") + self.requests = meter.create_counter( + name="agentex.llm.requests", + unit="1", + description=( + "LLM call count tagged with status (success / rate_limit / " + "server_error / client_error / timeout / network_error / " + "other_error). Use to alert on 429s, 5xxs, etc." + ), + ) + self.ttft_ms = meter.create_histogram( + name="agentex.llm.ttft", + unit="ms", + description="Time from request submission to first content token (ms)", + ) + # ttat (time-to-first-answering-token) is distinct from ttft for reasoning + # models: ttft fires on the first reasoning chunk (which arrives quickly), + # while ttat fires on the first user-visible answer token (text or tool + # call). For non-reasoning models the two are equal. + self.ttat_ms = meter.create_histogram( + name="agentex.llm.ttat", + unit="ms", + description="Time from request submission to first answering token (text or tool-call delta) — excludes reasoning chunks", + ) + # Note: TPS denominator is the model-generation window + # (last_token_time - first_token_time), not total stream wall time. + # This isolates raw model throughput from event-loop / tool-call latency. + self.tps = meter.create_histogram( + name="agentex.llm.tps", + unit="tokens/s", + description="Output tokens per second over the generation window", + ) + self.input_tokens = meter.create_counter( + name="agentex.llm.input_tokens", + unit="tokens", + description="Total input tokens sent to the LLM", + ) + self.output_tokens = meter.create_counter( + name="agentex.llm.output_tokens", + unit="tokens", + description="Total output tokens returned by the LLM", + ) + self.cached_input_tokens = meter.create_counter( + name="agentex.llm.cached_input_tokens", + unit="tokens", + description="Subset of input tokens served from prompt cache", + ) + self.reasoning_tokens = meter.create_counter( + name="agentex.llm.reasoning_tokens", + unit="tokens", + description="Output tokens spent on reasoning (subset of output_tokens)", + ) + + +_llm_metrics: Optional[LLMMetrics] = None + + +def get_llm_metrics() -> LLMMetrics: + """Return the LLM metrics singleton, creating it on first use.""" + global _llm_metrics + if _llm_metrics is None: + _llm_metrics = LLMMetrics() + return _llm_metrics + + +def classify_status(exc: Optional[BaseException]) -> str: + """Categorize an LLM call's outcome into a small fixed set of status labels. + + A successful call returns ``"success"``. Exceptions are mapped by type name + so we don't depend on a specific provider SDK's exception class hierarchy: + OpenAI, Anthropic, and other providers all use names like ``RateLimitError``, + ``APITimeoutError``, ``InternalServerError``, etc. + """ + if exc is None: + return "success" + name = type(exc).__name__ + if "RateLimit" in name: + return "rate_limit" + if "Timeout" in name: + return "timeout" + if any(s in name for s in ("ServerError", "InternalServer", "ServiceUnavailable", "BadGateway")): + return "server_error" + if "Connection" in name: + return "network_error" + if any(s in name for s in ("BadRequest", "Authentication", "Permission", "NotFound", "Conflict", "UnprocessableEntity")): + return "client_error" + return "other_error" diff --git a/src/agentex/lib/core/observability/llm_metrics_hooks.py b/src/agentex/lib/core/observability/llm_metrics_hooks.py new file mode 100644 index 000000000..fce4b29ba --- /dev/null +++ b/src/agentex/lib/core/observability/llm_metrics_hooks.py @@ -0,0 +1,57 @@ +"""``RunHooks`` adapter that emits per-call LLM metrics. + +Used by the sync ACP path and as a base class for ``TemporalStreamingHooks`` +on the async path, so token / request / cache metrics emit consistently +across both. Streaming-only metrics (ttft, ttat, tps) are emitted from the +streaming model itself, not here — hooks don't see individual chunks. +""" + +from __future__ import annotations + +from typing import Any +from typing_extensions import override + +from agents import Agent, RunHooks, ModelResponse, RunContextWrapper + +from agentex.lib.core.observability.llm_metrics import classify_status, get_llm_metrics + + +class LLMMetricsHooks(RunHooks): + """Emits ``agentex.llm.requests`` + token counters on every LLM call.""" + + @override + async def on_llm_end( + self, + context: RunContextWrapper[Any], + agent: Agent[Any], + response: ModelResponse, + ) -> None: + del context # part of the RunHooks contract; unused here + m = get_llm_metrics() + attrs = {"model": str(agent.model) if agent.model else "unknown"} + # Request counter only depends on agent.model, so emit it first and + # outside the usage-extraction try block. Token counters reach into + # nested optional fields and are best-effort: a non-OpenAI provider + # (litellm-routed Anthropic, etc.) may return a Usage shape missing + # input_tokens_details / output_tokens_details — we emit zeros where + # we can and skip the rest rather than crash the caller. + try: + m.requests.add(1, {**attrs, "status": "success"}) + except Exception: + pass + try: + usage = response.usage + m.input_tokens.add(usage.input_tokens or 0, attrs) + m.output_tokens.add(usage.output_tokens or 0, attrs) + m.cached_input_tokens.add(usage.input_tokens_details.cached_tokens or 0, attrs) + m.reasoning_tokens.add(usage.output_tokens_details.reasoning_tokens or 0, attrs) + except Exception: + pass + + +def record_llm_failure(model: str, exc: BaseException) -> None: + """Best-effort counter bump for an LLM call that raised before ``on_llm_end``.""" + try: + get_llm_metrics().requests.add(1, {"model": model, "status": classify_status(exc)}) + except Exception: + pass diff --git a/src/agentex/lib/core/observability/tests/__init__.py b/src/agentex/lib/core/observability/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/core/observability/tests/test_llm_metrics.py b/src/agentex/lib/core/observability/tests/test_llm_metrics.py new file mode 100644 index 000000000..d8ab62eba --- /dev/null +++ b/src/agentex/lib/core/observability/tests/test_llm_metrics.py @@ -0,0 +1,83 @@ +"""Tests for ``agentex.lib.core.observability.llm_metrics``.""" + +from __future__ import annotations + +import agentex.lib.core.observability.llm_metrics as llm_metrics +from agentex.lib.core.observability.llm_metrics import ( + LLMMetrics, + classify_status, + get_llm_metrics, +) + + +class TestClassifyStatus: + def test_none_is_success(self): + assert classify_status(None) == "success" + + def test_rate_limit(self): + class RateLimitError(Exception): + pass + + assert classify_status(RateLimitError()) == "rate_limit" + + def test_timeout(self): + class APITimeoutError(Exception): + pass + + assert classify_status(APITimeoutError()) == "timeout" + + def test_server_error(self): + class InternalServerError(Exception): + pass + + assert classify_status(InternalServerError()) == "server_error" + + class ServiceUnavailable(Exception): + pass + + assert classify_status(ServiceUnavailable()) == "server_error" + + def test_network_error(self): + class APIConnectionError(Exception): + pass + + assert classify_status(APIConnectionError()) == "network_error" + + def test_client_error(self): + for cls_name in ("BadRequestError", "AuthenticationError", "PermissionError"): + cls = type(cls_name, (Exception,), {}) + assert classify_status(cls()) == "client_error" + + def test_unknown_falls_back(self): + class WeirdProviderException(Exception): + pass + + assert classify_status(WeirdProviderException()) == "other_error" + + +class TestGetLLMMetrics: + def test_returns_llm_metrics_instance(self, monkeypatch): + monkeypatch.setattr(llm_metrics, "_llm_metrics", None) + m = get_llm_metrics() + assert isinstance(m, LLMMetrics) + + def test_singleton_returns_same_instance(self, monkeypatch): + monkeypatch.setattr(llm_metrics, "_llm_metrics", None) + first = get_llm_metrics() + second = get_llm_metrics() + assert first is second + + def test_instruments_exist(self, monkeypatch): + monkeypatch.setattr(llm_metrics, "_llm_metrics", None) + m = get_llm_metrics() + for name in ( + "requests", + "ttft_ms", + "ttat_ms", + "tps", + "input_tokens", + "output_tokens", + "cached_input_tokens", + "reasoning_tokens", + ): + assert hasattr(m, name), f"missing instrument: {name}" diff --git a/src/agentex/lib/core/observability/tests/test_llm_metrics_hooks.py b/src/agentex/lib/core/observability/tests/test_llm_metrics_hooks.py new file mode 100644 index 000000000..a2cef95b8 --- /dev/null +++ b/src/agentex/lib/core/observability/tests/test_llm_metrics_hooks.py @@ -0,0 +1,215 @@ +"""Tests for ``agentex.lib.core.observability.llm_metrics_hooks``.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +import agentex.lib.core.observability.llm_metrics_hooks as hooks_module +from agentex.lib.core.observability.llm_metrics_hooks import ( + LLMMetricsHooks, + record_llm_failure, +) + + +def _mock_response( + *, + input_tokens: int = 100, + output_tokens: int = 50, + cached_tokens: int = 30, + reasoning_tokens: int = 10, +) -> MagicMock: + response = MagicMock() + response.usage.input_tokens = input_tokens + response.usage.output_tokens = output_tokens + response.usage.input_tokens_details.cached_tokens = cached_tokens + response.usage.output_tokens_details.reasoning_tokens = reasoning_tokens + return response + + +def _mock_agent(model: str = "gpt-5") -> MagicMock: + agent = MagicMock() + agent.model = model + return agent + + +class TestLLMMetricsHooksOnLLMEnd: + @pytest.mark.asyncio + async def test_emits_success_request_counter(self, monkeypatch): + m = MagicMock() + monkeypatch.setattr(hooks_module, "get_llm_metrics", lambda: m) + + await LLMMetricsHooks().on_llm_end( + context=MagicMock(), + agent=_mock_agent("gpt-5"), + response=_mock_response(), + ) + + m.requests.add.assert_called_once_with(1, {"model": "gpt-5", "status": "success"}) + + @pytest.mark.asyncio + async def test_emits_token_counters(self, monkeypatch): + m = MagicMock() + monkeypatch.setattr(hooks_module, "get_llm_metrics", lambda: m) + + await LLMMetricsHooks().on_llm_end( + context=MagicMock(), + agent=_mock_agent("gpt-5"), + response=_mock_response( + input_tokens=200, + output_tokens=75, + cached_tokens=50, + reasoning_tokens=20, + ), + ) + + attrs = {"model": "gpt-5"} + m.input_tokens.add.assert_called_once_with(200, attrs) + m.output_tokens.add.assert_called_once_with(75, attrs) + m.cached_input_tokens.add.assert_called_once_with(50, attrs) + m.reasoning_tokens.add.assert_called_once_with(20, attrs) + + @pytest.mark.asyncio + async def test_zero_tokens_emit_zero_not_skip(self, monkeypatch): + m = MagicMock() + monkeypatch.setattr(hooks_module, "get_llm_metrics", lambda: m) + + await LLMMetricsHooks().on_llm_end( + context=MagicMock(), + agent=_mock_agent(), + response=_mock_response(input_tokens=0, output_tokens=0, cached_tokens=0, reasoning_tokens=0), + ) + + m.input_tokens.add.assert_called_once_with(0, {"model": "gpt-5"}) + m.output_tokens.add.assert_called_once_with(0, {"model": "gpt-5"}) + + @pytest.mark.asyncio + async def test_unknown_model_falls_back(self, monkeypatch): + m = MagicMock() + monkeypatch.setattr(hooks_module, "get_llm_metrics", lambda: m) + + agent = MagicMock() + agent.model = None + + await LLMMetricsHooks().on_llm_end( + context=MagicMock(), + agent=agent, + response=_mock_response(), + ) + + m.requests.add.assert_called_once_with(1, {"model": "unknown", "status": "success"}) + + @pytest.mark.asyncio + async def test_swallows_exporter_failure(self, monkeypatch): + m = MagicMock() + m.requests.add.side_effect = RuntimeError("exporter exploded") + monkeypatch.setattr(hooks_module, "get_llm_metrics", lambda: m) + + # Should not raise — caller's flow must not break on metric failure. + await LLMMetricsHooks().on_llm_end( + context=MagicMock(), + agent=_mock_agent(), + response=_mock_response(), + ) + + @pytest.mark.asyncio + async def test_missing_usage_still_emits_request_counter(self, monkeypatch): + """Provider returns a response without `usage` — caller shouldn't crash, + and we should still record the success request counter.""" + m = MagicMock() + monkeypatch.setattr(hooks_module, "get_llm_metrics", lambda: m) + + class _Response: + @property + def usage(self): + raise AttributeError("no usage") + + await LLMMetricsHooks().on_llm_end( + context=MagicMock(), + agent=_mock_agent(), + response=_Response(), # type: ignore[arg-type] + ) + + m.requests.add.assert_called_once_with(1, {"model": "gpt-5", "status": "success"}) + m.input_tokens.add.assert_not_called() + m.output_tokens.add.assert_not_called() + + @pytest.mark.asyncio + async def test_missing_token_details_skips_those_counters(self, monkeypatch): + """Provider returns Usage without input_tokens_details (e.g. some + litellm wrappers / non-OpenAI providers): top-level token counts + still emit; the nested cached/reasoning counters are skipped.""" + m = MagicMock() + monkeypatch.setattr(hooks_module, "get_llm_metrics", lambda: m) + + class _Usage: + input_tokens = 100 + output_tokens = 50 + + @property + def input_tokens_details(self): + raise AttributeError("no details") + + class _Response: + usage = _Usage() + + await LLMMetricsHooks().on_llm_end( + context=MagicMock(), + agent=_mock_agent(), + response=_Response(), # type: ignore[arg-type] + ) + + # Request counter still fires (it's outside the usage-extraction try). + m.requests.add.assert_called_once_with(1, {"model": "gpt-5", "status": "success"}) + # input_tokens.add fires before the nested attribute access. + m.input_tokens.add.assert_called_once_with(100, {"model": "gpt-5"}) + # cached_input_tokens / reasoning_tokens skipped — the AttributeError + # bailed before they could be called. + m.cached_input_tokens.add.assert_not_called() + m.reasoning_tokens.add.assert_not_called() + + @pytest.mark.asyncio + async def test_none_token_values_emit_as_zero(self, monkeypatch): + """Some providers report None instead of 0 for fields they don't track.""" + m = MagicMock() + monkeypatch.setattr(hooks_module, "get_llm_metrics", lambda: m) + + response = MagicMock() + response.usage.input_tokens = None + response.usage.output_tokens = None + response.usage.input_tokens_details.cached_tokens = None + response.usage.output_tokens_details.reasoning_tokens = None + + await LLMMetricsHooks().on_llm_end( + context=MagicMock(), + agent=_mock_agent(), + response=response, + ) + + attrs = {"model": "gpt-5"} + m.input_tokens.add.assert_called_once_with(0, attrs) + m.output_tokens.add.assert_called_once_with(0, attrs) + m.cached_input_tokens.add.assert_called_once_with(0, attrs) + m.reasoning_tokens.add.assert_called_once_with(0, attrs) + + +class TestRecordLLMFailure: + def test_emits_classified_status(self, monkeypatch): + m = MagicMock() + monkeypatch.setattr(hooks_module, "get_llm_metrics", lambda: m) + + class RateLimitError(Exception): + pass + + record_llm_failure("gpt-5", RateLimitError()) + + m.requests.add.assert_called_once_with(1, {"model": "gpt-5", "status": "rate_limit"}) + + def test_swallows_exporter_failure(self, monkeypatch): + m = MagicMock() + m.requests.add.side_effect = RuntimeError("exporter exploded") + monkeypatch.setattr(hooks_module, "get_llm_metrics", lambda: m) + + # Should not raise. + record_llm_failure("gpt-5", Exception("upstream")) diff --git a/src/agentex/lib/core/observability/tests/test_tracing_metrics.py b/src/agentex/lib/core/observability/tests/test_tracing_metrics.py new file mode 100644 index 000000000..aab4fbfed --- /dev/null +++ b/src/agentex/lib/core/observability/tests/test_tracing_metrics.py @@ -0,0 +1,100 @@ +"""Tests for ``agentex.lib.core.observability.tracing_metrics``.""" + +from __future__ import annotations + +import agentex.lib.core.observability.tracing_metrics as tracing_metrics +from agentex.lib.core.observability.tracing_metrics import ( + TracingMetrics, + processor_label, + get_tracing_metrics, + classify_export_error, +) + + +class TestClassifyExportError: + def test_scale_gp_authentication_error(self): + class AuthenticationError(Exception): + pass + + exc = AuthenticationError("Error code: 401 - {'message': 'Not authorized to access Account'}") + assert classify_export_error(exc) == ("authentication", "401") + + def test_rate_limit_code(self): + class APIError(Exception): + pass + + exc = APIError("Error code: 429 - rate limited") + assert classify_export_error(exc) == ("rate_limit", "429") + + def test_server_error_code(self): + class APIError(Exception): + pass + + exc = APIError("Error code: 503 - unavailable") + assert classify_export_error(exc) == ("server_error", "5xx") + + def test_out_of_range_code_uses_bounded_label(self): + class APIError(Exception): + pass + + exc = APIError("Error code: 100 - continue") + assert classify_export_error(exc) == ("other_error", "other") + + def test_timeout_by_name(self): + class APITimeoutError(Exception): + pass + + assert classify_export_error(APITimeoutError("slow")) == ("timeout", "timeout") + + def test_unknown_error(self): + class WeirdError(Exception): + pass + + assert classify_export_error(WeirdError("boom")) == ("other_error", "unknown") + + +class TestProcessorLabel: + def test_sgp_async_processor(self): + class SGPAsyncTracingProcessor: + pass + + assert processor_label(SGPAsyncTracingProcessor()) == "sgp" + + def test_other_processor(self): + class AgentexAsyncTracingProcessor: + pass + + assert processor_label(AgentexAsyncTracingProcessor()) == "other" + + +class TestGetTracingMetrics: + def test_returns_tracing_metrics_instance(self, monkeypatch): + monkeypatch.setattr(tracing_metrics, "_tracing_metrics", None) + m = get_tracing_metrics() + assert isinstance(m, TracingMetrics) + + def test_singleton_returns_same_instance(self, monkeypatch): + monkeypatch.setattr(tracing_metrics, "_tracing_metrics", None) + first = get_tracing_metrics() + second = get_tracing_metrics() + assert first is second + + def test_instruments_exist(self, monkeypatch): + monkeypatch.setattr(tracing_metrics, "_tracing_metrics", None) + m = get_tracing_metrics() + for name in ( + "span_events_enqueued", + "span_events_dropped", + "queue_depth", + "queue_lag", + "batch_items", + "batch_size", + "batch_drain_duration", + "export_batches", + "export_spans", + "export_batch_failures", + "export_span_failures", + "shutdown_timeouts", + "shutdown_remaining_items", + ): + assert hasattr(m, name), f"missing instrument: {name}" diff --git a/src/agentex/lib/core/observability/tests/test_tracing_metrics_recording.py b/src/agentex/lib/core/observability/tests/test_tracing_metrics_recording.py new file mode 100644 index 000000000..6c50c599f --- /dev/null +++ b/src/agentex/lib/core/observability/tests/test_tracing_metrics_recording.py @@ -0,0 +1,143 @@ +"""Tests for ``agentex.lib.core.observability.tracing_metrics_recording``.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import agentex.lib.core.observability.tracing_metrics_recording as recording + + +class _Item: + def __init__(self, enqueued_at: float | None) -> None: + self.enqueued_at = enqueued_at + + +class TestIsMetricsEnabled: + def setup_method(self) -> None: + recording._metrics_enabled = None + recording._tracing = None + + def test_enabled_by_default(self, monkeypatch): + monkeypatch.delenv("AGENTEX_TRACING_METRICS", raising=False) + assert recording.is_metrics_enabled() is True + + def test_disabled_by_zero(self, monkeypatch): + monkeypatch.setenv("AGENTEX_TRACING_METRICS", "0") + recording._metrics_enabled = None + assert recording.is_metrics_enabled() is False + + +class TestRecordingHelpers: + def setup_method(self) -> None: + recording._metrics_enabled = None + recording._tracing = None + + def test_record_span_enqueued_when_disabled_does_not_load_metrics(self, monkeypatch): + monkeypatch.setenv("AGENTEX_TRACING_METRICS", "0") + recording._metrics_enabled = None + with patch( + "agentex.lib.core.observability.tracing_metrics.get_tracing_metrics" + ) as mock_get: + recording.record_span_enqueued("start") + mock_get.assert_not_called() + + def test_record_span_enqueued_when_enabled(self, monkeypatch): + monkeypatch.setenv("AGENTEX_TRACING_METRICS", "1") + recording._metrics_enabled = None + mock_metrics = MagicMock() + with patch( + "agentex.lib.core.observability.tracing_metrics.get_tracing_metrics", + return_value=mock_metrics, + ): + recording.record_span_enqueued("end") + mock_metrics.span_events_enqueued.add.assert_called_once_with(1, {"event_type": "end"}) + + def test_monotonic_if_enabled_respects_kill_switch(self, monkeypatch): + monkeypatch.setenv("AGENTEX_TRACING_METRICS", "0") + recording._metrics_enabled = None + assert recording.monotonic_if_enabled() is None + + def test_record_batch_coalesced_records_lag(self, monkeypatch): + monkeypatch.setenv("AGENTEX_TRACING_METRICS", "1") + recording._metrics_enabled = None + mock_metrics = MagicMock() + with patch( + "agentex.lib.core.observability.tracing_metrics.get_tracing_metrics", + return_value=mock_metrics, + ), patch("agentex.lib.core.observability.tracing_metrics_recording.time.monotonic", return_value=10.0): + recording.record_batch_coalesced( + queue_depth=3, + batch_items=[_Item(9.5), _Item(9.0)], + ) + mock_metrics.queue_depth.record.assert_called_once_with(3) + mock_metrics.batch_items.record.assert_called_once_with(2) + mock_metrics.queue_lag.record.assert_called_once_with(1000.0) + + def test_record_export_failure(self, monkeypatch): + monkeypatch.setenv("AGENTEX_TRACING_METRICS", "1") + recording._metrics_enabled = None + mock_metrics = MagicMock() + + class AuthenticationError(Exception): + pass + + exc = AuthenticationError("Error code: 401 - denied") + processor = type("SGPAsyncTracingProcessor", (), {})() + + with patch( + "agentex.lib.core.observability.tracing_metrics.get_tracing_metrics", + return_value=mock_metrics, + ): + recording.record_export_failure( + processor=processor, + event_type="start", + span_count=5, + exc=exc, + ) + + mock_metrics.export_batch_failures.add.assert_called_once() + mock_metrics.export_span_failures.add.assert_called_once_with( + 5, + { + "processor": "sgp", + "event_type": "start", + "http_code": "401", + "error_class": "authentication", + }, + ) + + def test_record_export_success(self, monkeypatch): + monkeypatch.setenv("AGENTEX_TRACING_METRICS", "1") + recording._metrics_enabled = None + mock_metrics = MagicMock() + with patch( + "agentex.lib.core.observability.tracing_metrics.get_tracing_metrics", + return_value=mock_metrics, + ): + recording.record_export_success(event_type="end", span_count=12, processor="sgp") + + mock_metrics.export_batches.add.assert_called_once_with( + 1, + {"processor": "sgp", "event_type": "end"}, + ) + mock_metrics.export_spans.add.assert_called_once_with( + 12, + {"processor": "sgp", "event_type": "end"}, + ) + + def test_record_export_success_accepts_processor_label(self, monkeypatch): + monkeypatch.setenv("AGENTEX_TRACING_METRICS", "1") + recording._metrics_enabled = None + mock_metrics = MagicMock() + with patch( + "agentex.lib.core.observability.tracing_metrics.get_tracing_metrics", + return_value=mock_metrics, + ): + recording.record_export_success( + event_type="start", span_count=3, processor="other" + ) + + mock_metrics.export_batches.add.assert_called_once_with( + 1, + {"processor": "other", "event_type": "start"}, + ) diff --git a/src/agentex/lib/core/observability/tracing_metrics.py b/src/agentex/lib/core/observability/tracing_metrics.py new file mode 100644 index 000000000..74960cc4a --- /dev/null +++ b/src/agentex/lib/core/observability/tracing_metrics.py @@ -0,0 +1,164 @@ +"""OTel metrics for async span queue and SGP export telemetry. + +Single source of truth for span-queue / export instrumentation. Import +``get_tracing_metrics()`` or the ``record_*`` helpers in +``tracing_metrics_recording`` from hot paths — never configure a +``MeterProvider`` here. + +The meter is no-op when the application has not configured a +``MeterProvider``. Set ``AGENTEX_TRACING_METRICS=0`` to skip recording +entirely (see ``tracing_metrics_recording.is_metrics_enabled``). + +Cardinality is bounded: +- ``event_type``: ``start`` | ``end`` +- ``processor``: ``sgp`` | ``other`` +- ``http_code``: small fixed set from ``classify_export_error`` (failure counters only) +- ``error_class``: small fixed set from ``classify_export_error`` (failure counters only) +- ``reason``: ``shutdown`` (drops only) +- ``phase``: ``start`` | ``end`` (batch drain histograms) + +Resource attributes (``service.name``, ``k8s.*``, etc.) come from the +host application's OTel resource configuration. +""" + +from __future__ import annotations + +import re +from typing import Optional + +from opentelemetry import metrics + +_HTTP_CODE_RE = re.compile(r"Error code:\s*(\d+)") + + +class TracingMetrics: + """Lazily-created OTel instruments for span queue + export telemetry.""" + + def __init__(self) -> None: + meter = metrics.get_meter("agentex.tracing") + self.span_events_enqueued = meter.create_counter( + name="agentex.tracing.span_events.enqueued", + unit="1", + description="Span queue START/END events accepted by enqueue()", + ) + self.span_events_dropped = meter.create_counter( + name="agentex.tracing.span_events.dropped", + unit="1", + description="Span queue events dropped (e.g. shutdown)", + ) + self.queue_depth = meter.create_histogram( + name="agentex.tracing.queue.depth", + unit="1", + description="asyncio queue depth at the start of a drain batch", + ) + self.queue_lag = meter.create_histogram( + name="agentex.tracing.queue.lag", + unit="ms", + description="Max time from enqueue to drain-batch start for items in the batch", + ) + self.batch_items = meter.create_histogram( + name="agentex.tracing.batch.items", + unit="1", + description="Total span events coalesced in one linger/drain batch", + ) + self.batch_size = meter.create_histogram( + name="agentex.tracing.batch.size", + unit="1", + description="Span events in one START or END dispatch phase", + ) + self.batch_drain_duration = meter.create_histogram( + name="agentex.tracing.batch.drain_duration", + unit="ms", + description="Wall time for one START or END _process_items dispatch", + ) + self.export_batches = meter.create_counter( + name="agentex.tracing.export.batches", + unit="1", + description="Successful HTTP export batches by processor and event type", + ) + self.export_spans = meter.create_counter( + name="agentex.tracing.export.spans", + unit="1", + description="Spans in successful HTTP export batches by processor and event type", + ) + self.export_batch_failures = meter.create_counter( + name="agentex.tracing.export.batch_failures", + unit="1", + description="Failed HTTP export batches by processor and HTTP status", + ) + self.export_span_failures = meter.create_counter( + name="agentex.tracing.export.span_failures", + unit="1", + description="Spans in failed HTTP export batches by processor and HTTP status", + ) + self.shutdown_timeouts = meter.create_counter( + name="agentex.tracing.shutdown.timeouts", + unit="1", + description="Span queue shutdown calls that hit the join timeout", + ) + self.shutdown_remaining_items = meter.create_histogram( + name="agentex.tracing.shutdown.remaining_items", + unit="1", + description="Queue depth when span queue shutdown times out", + ) + + +_tracing_metrics: Optional[TracingMetrics] = None + + +def get_tracing_metrics() -> TracingMetrics: + """Return the tracing metrics singleton, creating it on first use.""" + global _tracing_metrics + if _tracing_metrics is None: + _tracing_metrics = TracingMetrics() + return _tracing_metrics + + +def processor_label(processor: object) -> str: + """Map a tracing processor instance to a low-cardinality label.""" + if type(processor).__name__ == "SGPAsyncTracingProcessor": + return "sgp" + return "other" + + +def classify_export_error(exc: BaseException) -> tuple[str, str]: + """Categorize an export failure into (error_class, http_code_label). + + ``http_code_label`` is a small fixed set suitable for Prometheus labels. + """ + name = type(exc).__name__ + message = str(exc) + + if "Timeout" in name: + return "timeout", "timeout" + if "Connection" in name or "Connect" in name: + return "network_error", "network" + + match = _HTTP_CODE_RE.search(message) + if match: + code = int(match.group(1)) + if code == 401: + return "authentication", "401" + if code == 403: + return "authentication", "403" + if code == 429: + return "rate_limit", "429" + if 400 <= code < 500: + return "client_error", "4xx" + if 500 <= code < 600: + return "server_error", "5xx" + return "other_error", "other" + + if any(s in name for s in ("Authentication", "Permission")): + return "authentication", "unknown" + if "RateLimit" in name: + return "rate_limit", "429" + if any(s in name for s in ("ServerError", "InternalServer", "ServiceUnavailable", "BadGateway")): + return "server_error", "5xx" + if any( + s in name + for s in ("BadRequest", "NotFound", "Conflict", "UnprocessableEntity") + ): + return "client_error", "4xx" + + return "other_error", "unknown" diff --git a/src/agentex/lib/core/observability/tracing_metrics_recording.py b/src/agentex/lib/core/observability/tracing_metrics_recording.py new file mode 100644 index 000000000..4fd8632b0 --- /dev/null +++ b/src/agentex/lib/core/observability/tracing_metrics_recording.py @@ -0,0 +1,153 @@ +"""Best-effort recording helpers for span queue / export OTel metrics. + +This module intentionally does **not** import OpenTelemetry — hot paths can +import it without pulling in the OTel SDK. Instruments are created lazily on +first record when ``is_metrics_enabled()`` is true. +""" + +from __future__ import annotations + +import os +import time +from typing import Protocol, Sequence + + +class _HasEnqueuedAt(Protocol): + enqueued_at: float | None + + +_metrics_enabled: bool | None = None +_tracing = None # lazy-loaded tracing_metrics module (loads OTel on first use) + + +def is_metrics_enabled() -> bool: + """Return whether SDK span-queue metrics recording is enabled.""" + global _metrics_enabled + if _metrics_enabled is None: + raw = os.environ.get("AGENTEX_TRACING_METRICS", "1").strip().lower() + _metrics_enabled = raw not in ("0", "false", "no", "off") + return _metrics_enabled + + +def _tracing_module(): + """Return lazy-loaded ``tracing_metrics`` module (loads OTel on first use).""" + global _tracing + if _tracing is None: + from agentex.lib.core.observability import tracing_metrics + + _tracing = tracing_metrics + return _tracing + + +def monotonic_if_enabled() -> float | None: + """Return ``time.monotonic()`` when metrics are enabled, else ``None``.""" + if not is_metrics_enabled(): + return None + return time.monotonic() + + +def record_span_enqueued(event_type: str) -> None: + if not is_metrics_enabled(): + return + try: + _tracing_module().get_tracing_metrics().span_events_enqueued.add( + 1, {"event_type": event_type} + ) + except Exception: + pass + + +def record_span_dropped(reason: str, count: int = 1) -> None: + if count <= 0 or not is_metrics_enabled(): + return + try: + _tracing_module().get_tracing_metrics().span_events_dropped.add( + count, {"reason": reason} + ) + except Exception: + pass + + +def record_batch_coalesced( + *, + queue_depth: int, + batch_items: Sequence[_HasEnqueuedAt], +) -> None: + if not is_metrics_enabled(): + return + try: + metrics = _tracing_module().get_tracing_metrics() + metrics.queue_depth.record(max(queue_depth, 0)) + metrics.batch_items.record(len(batch_items)) + + now = time.monotonic() + lag_ms = 0.0 + for item in batch_items: + if item.enqueued_at is None: + continue + lag_ms = max(lag_ms, (now - item.enqueued_at) * 1000.0) + if lag_ms > 0: + metrics.queue_lag.record(lag_ms) + except Exception: + pass + + +def record_batch_phase(*, phase: str, size: int, duration_ms: float) -> None: + if not is_metrics_enabled(): + return + try: + attrs = {"phase": phase} + metrics = _tracing_module().get_tracing_metrics() + metrics.batch_size.record(size, attrs) + metrics.batch_drain_duration.record(duration_ms, attrs) + except Exception: + pass + + +def record_export_success(*, event_type: str, span_count: int, processor: str) -> None: + if not is_metrics_enabled(): + return + try: + attrs = {"processor": processor, "event_type": event_type} + metrics = _tracing_module().get_tracing_metrics() + metrics.export_batches.add(1, attrs) + metrics.export_spans.add(span_count, attrs) + except Exception: + pass + + +def record_export_failure( + *, + processor: object, + event_type: str, + span_count: int, + exc: BaseException, +) -> None: + if not is_metrics_enabled(): + return + try: + tm = _tracing_module() + error_class, http_code = tm.classify_export_error(exc) + proc = tm.processor_label(processor) + attrs = { + "processor": proc, + "event_type": event_type, + "http_code": http_code, + "error_class": error_class, + } + metrics = tm.get_tracing_metrics() + metrics.export_batch_failures.add(1, attrs) + metrics.export_span_failures.add(span_count, attrs) + except Exception: + pass + + +def record_shutdown_timeout(*, remaining_items: int) -> None: + if not is_metrics_enabled(): + return + try: + metrics = _tracing_module().get_tracing_metrics() + metrics.shutdown_timeouts.add(1) + metrics.shutdown_remaining_items.record(max(remaining_items, 0)) + except Exception: + pass diff --git a/src/agentex/lib/core/services/__init__.py b/src/agentex/lib/core/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/core/services/adk/__init__.py b/src/agentex/lib/core/services/adk/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/core/services/adk/acp/__init__.py b/src/agentex/lib/core/services/adk/acp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/core/services/adk/acp/acp.py b/src/agentex/lib/core/services/adk/acp/acp.py new file mode 100644 index 000000000..956e1b5db --- /dev/null +++ b/src/agentex/lib/core/services/adk/acp/acp.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +from typing import Any, List, cast + +from agentex import AsyncAgentex +from agentex.types.task import Task +from agentex.types.event import Event +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.temporal import heartbeat_if_in_workflow +from agentex.types.task_message import TaskMessage +from agentex.types.agent_rpc_params import ( + ParamsSendEventRequest as RpcParamsSendEventRequest, + ParamsCancelTaskRequest as RpcParamsCancelTaskRequest, +) +from agentex.lib.core.tracing.tracer import AsyncTracer +from agentex.types.task_message_content import TaskMessageContent +from agentex.types.task_message_content_param import TaskMessageContentParam + +logger = make_logger(__name__) + + +class ACPService: + def __init__( + self, + agentex_client: AsyncAgentex, + tracer: AsyncTracer, + ): + self._agentex_client = agentex_client + self._tracer = tracer + + async def task_create( + self, + name: str | None = None, + agent_id: str | None = None, + agent_name: str | None = None, + params: dict[str, Any] | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + request: dict[str, Any] | None = None, + ) -> Task: + trace = self._tracer.trace(trace_id=trace_id) + async with trace.span( + parent_id=parent_span_id, + name="task_create", + input={ + "name": name, + "agent_id": agent_id, + "agent_name": agent_name, + "params": params, + }, + ) as span: + heartbeat_if_in_workflow("task create") + + # Extract headers from request; pass-through to agent + extra_headers = request.get("headers") if request else None + + if agent_name: + json_rpc_response = await self._agentex_client.agents.rpc_by_name( + agent_name=agent_name, + method="task/create", + params={ + "name": name, + "params": params, + }, + extra_headers=extra_headers, + ) + elif agent_id: + json_rpc_response = await self._agentex_client.agents.rpc( + agent_id=agent_id, + method="task/create", + params={ + "name": name, + "params": params, + }, + extra_headers=extra_headers, + ) + else: + raise ValueError("Either agent_name or agent_id must be provided") + + task_entry = Task.model_validate(json_rpc_response.result) + if span: + span.output = task_entry.model_dump() + return task_entry + + async def message_send( + self, + content: TaskMessageContent, + agent_id: str | None = None, + agent_name: str | None = None, + task_id: str | None = None, + task_name: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + request: dict[str, Any] | None = None, + ) -> List[TaskMessage]: + trace = self._tracer.trace(trace_id=trace_id) + async with trace.span( + parent_id=parent_span_id, + name="message_send", + input={ + "agent_id": agent_id, + "agent_name": agent_name, + "task_id": task_id, + "task_name": task_name, + "message": content, + }, + ) as span: + heartbeat_if_in_workflow("message send") + + # Extract headers from request; pass-through to agent + extra_headers = request.get("headers") if request else None + + if agent_name: + json_rpc_response = await self._agentex_client.agents.rpc_by_name( + agent_name=agent_name, + method="message/send", + params={ + "task_id": task_id, + "content": cast(TaskMessageContentParam, content.model_dump()), + "stream": False, + }, + extra_headers=extra_headers, + ) + elif agent_id: + json_rpc_response = await self._agentex_client.agents.rpc( + agent_id=agent_id, + method="message/send", + params={ + "task_id": task_id, + "content": cast(TaskMessageContentParam, content.model_dump()), + "stream": False, + }, + extra_headers=extra_headers, + ) + else: + raise ValueError("Either agent_name or agent_id must be provided") + + task_messages: List[TaskMessage] = [] + logger.info("json_rpc_response: %s", json_rpc_response) + if isinstance(json_rpc_response.result, list): + for message in json_rpc_response.result: + task_message = TaskMessage.model_validate(message) + task_messages.append(task_message) + else: + task_messages = [TaskMessage.model_validate(json_rpc_response.result)] + + if span: + span.output = [task_message.model_dump() for task_message in task_messages] + return task_messages + + async def event_send( + self, + content: TaskMessageContent, + agent_id: str | None = None, + agent_name: str | None = None, + task_id: str | None = None, + task_name: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + request: dict[str, Any] | None = None, + ) -> Event: + trace = self._tracer.trace(trace_id=trace_id) + async with trace.span( + parent_id=parent_span_id, + name="event_send", + input={ + "agent_id": agent_id, + "agent_name": agent_name, + "task_id": task_id, + "task_name": task_name, + "content": content, + }, + ) as span: + heartbeat_if_in_workflow("event send") + + # Extract headers from request; pass-through to agent + extra_headers = request.get("headers") if request else None + + rpc_event_params: RpcParamsSendEventRequest = { + "task_id": task_id, + "task_name": task_name, + "content": cast(TaskMessageContentParam, content.model_dump()), + } + if agent_name: + json_rpc_response = await self._agentex_client.agents.rpc_by_name( + agent_name=agent_name, + method="event/send", + params=rpc_event_params, + extra_headers=extra_headers, + ) + elif agent_id: + json_rpc_response = await self._agentex_client.agents.rpc( + agent_id=agent_id, + method="event/send", + params=rpc_event_params, + extra_headers=extra_headers, + ) + else: + raise ValueError("Either agent_name or agent_id must be provided") + + event_entry = Event.model_validate(json_rpc_response.result) + if span: + span.output = event_entry.model_dump() + return event_entry + + async def task_cancel( + self, + task_id: str | None = None, + task_name: str | None = None, + agent_id: str | None = None, + agent_name: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + request: dict[str, Any] | None = None, + ) -> Task: + """ + Cancel a task by sending cancel request to the agent that owns the task. + + Args: + task_id: ID of the task to cancel (passed to agent in params) + task_name: Name of the task to cancel (passed to agent in params) + agent_id: ID of the agent that owns the task + agent_name: Name of the agent that owns the task + trace_id: Trace ID for tracing + parent_span_id: Parent span ID for tracing + request: Additional request context including headers to forward to the agent + + Returns: + Task entry representing the cancelled task + + Raises: + ValueError: If neither agent_name nor agent_id is provided, + or if neither task_name nor task_id is provided + """ + # Require agent identification + if not agent_name and not agent_id: + raise ValueError("Either agent_name or agent_id must be provided to identify the agent that owns the task") + + # Require task identification + if not task_name and not task_id: + raise ValueError("Either task_name or task_id must be provided to identify the task to cancel") + trace = self._tracer.trace(trace_id=trace_id) + async with trace.span( + parent_id=parent_span_id, + name="task_cancel", + input={ + "task_id": task_id, + "task_name": task_name, + "agent_id": agent_id, + "agent_name": agent_name, + }, + ) as span: + heartbeat_if_in_workflow("task cancel") + + # Extract headers from request; pass-through to agent + extra_headers = request.get("headers") if request else None + + # Build params for the agent (task identification) + params: RpcParamsCancelTaskRequest = {} + if task_id: + params["task_id"] = task_id + if task_name: + params["task_name"] = task_name + + # Send cancel request to the correct agent + if agent_name: + json_rpc_response = await self._agentex_client.agents.rpc_by_name( + agent_name=agent_name, + method="task/cancel", + params=params, + extra_headers=extra_headers, + ) + else: # agent_id is provided (validated above) + assert agent_id is not None + json_rpc_response = await self._agentex_client.agents.rpc( + agent_id=agent_id, + method="task/cancel", + params=params, + extra_headers=extra_headers, + ) + + task_entry = Task.model_validate(json_rpc_response.result) + if span: + span.output = task_entry.model_dump() + return task_entry diff --git a/src/agentex/lib/core/services/adk/agent_task_tracker.py b/src/agentex/lib/core/services/adk/agent_task_tracker.py new file mode 100644 index 000000000..54ee4f72f --- /dev/null +++ b/src/agentex/lib/core/services/adk/agent_task_tracker.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from agentex import AsyncAgentex +from agentex.lib.utils.logging import make_logger +from agentex.lib.core.tracing.tracer import AsyncTracer +from agentex.types.agent_task_tracker import AgentTaskTracker + +logger = make_logger(__name__) + + +class AgentTaskTrackerService: + def __init__( + self, agentex_client: AsyncAgentex, tracer: AsyncTracer, + ): + self._agentex_client = agentex_client + self._tracer = tracer + + async def get_agent_task_tracker( + self, + tracker_id: str, + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> AgentTaskTracker: + trace = self._tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="get_agent_task_tracker", + input={"tracker_id": tracker_id}, + ) as span: + tracker = await self._agentex_client.tracker.retrieve( + tracker_id + ) + if span: + span.output = tracker.model_dump() + return tracker + + async def get_by_task_and_agent( + self, + task_id: str, + agent_id: str, + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> AgentTaskTracker | None: + trace = self._tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="get_by_task_and_agent", + input={"task_id": task_id, "agent_id": agent_id}, + ) as span: + trackers = await self._agentex_client.tracker.list( + task_id=task_id, + agent_id=agent_id, + ) + tracker = trackers[0] if trackers else None + if span: + span.output = tracker.model_dump() if tracker else None + return tracker + + async def update_agent_task_tracker( + self, + tracker_id: str, + last_processed_event_id: str | None = None, + status: str | None = None, + status_reason: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> AgentTaskTracker: + trace = self._tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="update_agent_task_tracker", + input={ + "tracker_id": tracker_id, + "last_processed_event_id": last_processed_event_id, + "status": status, + "status_reason": status_reason, + }, + ) as span: + tracker = await self._agentex_client.tracker.update( + tracker_id=tracker_id, + last_processed_event_id=last_processed_event_id, + status=status, + status_reason=status_reason, + ) + if span: + span.output = tracker.model_dump() + return tracker diff --git a/src/agentex/lib/core/services/adk/agents.py b/src/agentex/lib/core/services/adk/agents.py new file mode 100644 index 000000000..1d26b9d56 --- /dev/null +++ b/src/agentex/lib/core/services/adk/agents.py @@ -0,0 +1,43 @@ +from typing import Optional + +from agentex import AsyncAgentex +from agentex.types.agent import Agent +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.temporal import heartbeat_if_in_workflow +from agentex.lib.core.tracing.tracer import AsyncTracer + +logger = make_logger(__name__) + + +class AgentsService: + def __init__( + self, + agentex_client: AsyncAgentex, + tracer: AsyncTracer, + ): + self._agentex_client = agentex_client + self._tracer = tracer + + async def get_agent( + self, + agent_id: Optional[str] = None, + agent_name: Optional[str] = None, + trace_id: Optional[str] = None, + parent_span_id: Optional[str] = None, + ) -> Agent: + trace = self._tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="get_agent", + input={"agent_id": agent_id, "agent_name": agent_name}, + ) as span: + heartbeat_if_in_workflow("get agent") + if agent_id: + agent = await self._agentex_client.agents.retrieve(agent_id=agent_id) + elif agent_name: + agent = await self._agentex_client.agents.retrieve_by_name(agent_name=agent_name) + else: + raise ValueError("Either agent_id or agent_name must be provided") + if span: + span.output = agent.model_dump() + return agent diff --git a/src/agentex/lib/core/services/adk/events.py b/src/agentex/lib/core/services/adk/events.py new file mode 100644 index 000000000..fbed9e5af --- /dev/null +++ b/src/agentex/lib/core/services/adk/events.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from agentex import AsyncAgentex +from agentex.types.event import Event +from agentex.lib.utils.logging import make_logger +from agentex.lib.core.tracing.tracer import AsyncTracer + +logger = make_logger(__name__) + + +class EventsService: + def __init__( + self, agentex_client: AsyncAgentex, tracer: AsyncTracer + ): + self._agentex_client = agentex_client + self._tracer = tracer + + async def get_event( + self, + event_id: str, + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> Event | None: + trace = self._tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="get_event", + input={"event_id": event_id}, + ) as span: + event = await self._agentex_client.events.retrieve(event_id=event_id) + if span: + span.output = event.model_dump() + return event + + async def list_events( + self, + task_id: str, + agent_id: str, + last_processed_event_id: str | None = None, + limit: int | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> list[Event]: + trace = self._tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="list_events", + input={ + "task_id": task_id, + "agent_id": agent_id, + "last_processed_event_id": last_processed_event_id, + "limit": limit, + }, + ) as span: + events = await self._agentex_client.events.list( + task_id=task_id, + agent_id=agent_id, + last_processed_event_id=last_processed_event_id, + limit=limit, + ) + if span: + span.output = [event.model_dump() for event in events] + return events diff --git a/src/agentex/lib/core/services/adk/messages.py b/src/agentex/lib/core/services/adk/messages.py new file mode 100644 index 000000000..929100eb1 --- /dev/null +++ b/src/agentex/lib/core/services/adk/messages.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import asyncio +from typing import Any, Coroutine +from datetime import datetime + +from agentex import AsyncAgentex +from agentex._types import omit +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.temporal import heartbeat_if_in_workflow +from agentex.types.task_message import TaskMessage, TaskMessageContent +from agentex.lib.core.tracing.tracer import AsyncTracer +from agentex.types.task_message_update import TaskMessageUpdate, StreamTaskMessageFull +from agentex.lib.core.services.adk.streaming import StreamingService + +logger = make_logger(__name__) + + +class MessagesService: + def __init__( + self, + agentex_client: AsyncAgentex, + streaming_service: StreamingService, + tracer: AsyncTracer, + ): + self._agentex_client = agentex_client + self._streaming_service = streaming_service + self._tracer = tracer + + async def create_message( + self, + task_id: str, + content: TaskMessageContent, + emit_updates: bool = True, + trace_id: str | None = None, + parent_span_id: str | None = None, + created_at: datetime | None = None, + ) -> TaskMessage: + trace = self._tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="create_message", + input={"task_id": task_id, "message": content}, + ) as span: + heartbeat_if_in_workflow("create message") + task_message = await self._agentex_client.messages.create( + task_id=task_id, + content=content.model_dump(), + created_at=created_at if created_at is not None else omit, + ) + if emit_updates: + await self._emit_updates([task_message]) + if span: + span.output = task_message.model_dump() + return task_message + + async def update_message( + self, + task_id: str, + message_id: str, + content: TaskMessageContent, + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> TaskMessage: + trace = self._tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="update_message", + input={ + "task_id": task_id, + "message_id": message_id, + "message": content, + }, + ) as span: + heartbeat_if_in_workflow("update message") + task_message = await self._agentex_client.messages.update( + task_id=task_id, + message_id=message_id, + content=content.model_dump(), + ) + if span: + span.output = task_message.model_dump() + return task_message + + async def create_messages_batch( + self, + task_id: str, + contents: list[TaskMessageContent], + emit_updates: bool = True, + trace_id: str | None = None, + parent_span_id: str | None = None, + created_at: datetime | None = None, + ) -> list[TaskMessage]: + trace = self._tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="create_messages_batch", + input={"task_id": task_id, "messages": contents}, + ) as span: + heartbeat_if_in_workflow("create messages batch") + task_messages = await self._agentex_client.messages.batch.create( + task_id=task_id, + contents=[content.model_dump() for content in contents], + created_at=created_at if created_at is not None else omit, + ) + if emit_updates: + await self._emit_updates(task_messages) + if span: + span.output = [task_message.model_dump() for task_message in task_messages] + return task_messages + + async def update_messages_batch( + self, + task_id: str, + updates: dict[str, TaskMessageContent], + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> list[TaskMessage]: + trace = self._tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="update_messages_batch", + input={"task_id": task_id, "updates": updates}, + ) as span: + heartbeat_if_in_workflow("update messages batch") + task_messages = await self._agentex_client.messages.batch.update( + task_id=task_id, + updates={message_id: content.model_dump() for message_id, content in updates.items()}, + ) + if span: + span.output = [task_message.model_dump() for task_message in task_messages] + return task_messages + + async def list_messages( + self, + task_id: str, + limit: int | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> list[TaskMessage]: + trace = self._tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="list_messages", + input={"task_id": task_id, "limit": limit}, + ) as span: + heartbeat_if_in_workflow("list messages") + task_messages = await self._agentex_client.messages.list( + task_id=task_id, + limit=limit, + ) + if span: + span.output = [task_message.model_dump() for task_message in task_messages] + return task_messages + + async def _emit_updates(self, task_messages: list[TaskMessage]) -> None: + stream_update_handlers: list[Coroutine[Any, Any, TaskMessageUpdate | None]] = [] + for task_message in task_messages: + stream_update_handler = self._streaming_service.stream_update( + update=StreamTaskMessageFull( + type="full", + parent_task_message=task_message, + content=task_message.content, + ) + ) + stream_update_handlers.append(stream_update_handler) + + await asyncio.gather(*stream_update_handlers) diff --git a/src/agentex/lib/core/services/adk/providers/__init__.py b/src/agentex/lib/core/services/adk/providers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/core/services/adk/providers/litellm.py b/src/agentex/lib/core/services/adk/providers/litellm.py new file mode 100644 index 000000000..416077e8f --- /dev/null +++ b/src/agentex/lib/core/services/adk/providers/litellm.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +from datetime import datetime +from collections.abc import AsyncGenerator + +from agentex import AsyncAgentex +from agentex.lib.utils import logging +from agentex.lib.utils.temporal import heartbeat_if_in_workflow +from agentex.types.task_message import TaskMessage +from agentex.lib.utils.completions import concat_completion_chunks +from agentex.lib.types.llm_messages import ( + LLMConfig, + Completion, +) +from agentex.lib.core.tracing.tracer import AsyncTracer +from agentex.types.task_message_delta import TextDelta +from agentex.types.task_message_update import ( + StreamTaskMessageFull, + StreamTaskMessageDelta, +) +from agentex.types.task_message_content import TextContent +from agentex.lib.core.services.adk.streaming import StreamingService +from agentex.lib.core.adapters.llm.adapter_litellm import LiteLLMGateway + +logger = logging.make_logger(__name__) + + +def _stream_kwargs_with_usage(llm_config: LLMConfig) -> dict: + """Completion kwargs with usage reporting enabled on the final stream chunk. + + litellm only reports usage for streaming calls when + ``stream_options={"include_usage": True}`` is set; default it on so usage + reaches the span. Callers can still opt out by explicitly passing + ``stream_options={"include_usage": False}``. + """ + kwargs = llm_config.model_dump() + stream_options = kwargs.get("stream_options") or {} + kwargs["stream_options"] = {"include_usage": True, **stream_options} + return kwargs + + +class LiteLLMService: + def __init__( + self, + agentex_client: AsyncAgentex, + streaming_service: StreamingService, + tracer: AsyncTracer, + llm_gateway: LiteLLMGateway | None = None, + ): + self.agentex_client = agentex_client + self.llm_gateway = llm_gateway + self.streaming_service = streaming_service + self.tracer = tracer + + async def chat_completion( + self, + llm_config: LLMConfig, + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> Completion: + trace = self.tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="chat_completion", + input=llm_config.model_dump(), + ) as span: + heartbeat_if_in_workflow("chat completion") + if self.llm_gateway is None: + raise ValueError("LLM Gateway is not set") + completion = await self.llm_gateway.acompletion(**llm_config.model_dump()) + if span: + span.output = completion.model_dump() + return completion + + async def chat_completion_auto_send( + self, + task_id: str, + llm_config: LLMConfig, + trace_id: str | None = None, + parent_span_id: str | None = None, + created_at: datetime | None = None, + ) -> TaskMessage | None: + """ + Chat completion with automatic TaskMessage creation. This does not stream the completion. To stream use chat_completion_stream_auto_send. + + Args: + task_id (str): The ID of the task to run the agent for. + llm_config (LLMConfig): The configuration for the LLM (must have stream=True). + + Returns: + TaskMessage: A TaskMessage object + """ + + if llm_config.stream: + raise ValueError( + "LLM config must not have stream=True. To stream use `chat_completion_stream` or `chat_completion_stream_auto_send`." + ) + + if self.llm_gateway is None: + raise ValueError("LLM Gateway is not set") + + trace = self.tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="chat_completion_auto_send", + input=llm_config.model_dump(), + ) as span: + heartbeat_if_in_workflow("chat completion auto send") + + async with self.streaming_service.streaming_task_message_context( + task_id=task_id, + initial_content=TextContent( + author="agent", + content="", + format="markdown", + ), + created_at=created_at, + ) as streaming_context: + completion = await self.llm_gateway.acompletion(**llm_config.model_dump()) + if completion.choices and len(completion.choices) > 0 and completion.choices[0].message: + final_content = TextContent( + author="agent", + content=completion.choices[0].message.content or "", + format="markdown", + ) + await streaming_context.stream_update( + update=StreamTaskMessageFull( + parent_task_message=streaming_context.task_message, + content=final_content, + type="full", + ), + ) + else: + raise ValueError("No completion message returned from LLM") + + if span: + if streaming_context.task_message: + output = streaming_context.task_message.model_dump() + # Per-call usage for billing; deduped against any turn aggregate + if completion.usage is not None: + output["usage"] = completion.usage.model_dump() + span.output = output + return streaming_context.task_message if streaming_context.task_message else None + + async def chat_completion_stream( + self, + llm_config: LLMConfig, + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> AsyncGenerator[Completion, None]: + """ + Stream chat completion chunks using LiteLLM. + + Args: + llm_config (LLMConfig): The configuration for the LLM (must have stream=True). + trace_id (Optional[str]): The trace ID for tracing. + parent_span_id (Optional[str]): The parent span ID for tracing. + + Returns: + AsyncGenerator[Completion, None]: Generator yielding completion chunks + + Raises: + ValueError: If called from within a Temporal workflow or if stream=False + """ + if not llm_config.stream: + raise ValueError("LLM config must have stream=True for streaming") + + if self.llm_gateway is None: + raise ValueError("LLM Gateway is not set") + + completion_kwargs = _stream_kwargs_with_usage(llm_config) + trace = self.tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="chat_completion_stream", + input=completion_kwargs, + ) as span: + # Direct streaming outside temporal - yield each chunk as it comes + chunks: list[Completion] = [] + async for chunk in self.llm_gateway.acompletion_stream(**completion_kwargs): + chunks.append(chunk) + yield chunk + if span: + # The usage-bearing final chunk survives concat, so the dumped + # completion carries usage for billing + span.output = concat_completion_chunks(chunks).model_dump() + + async def chat_completion_stream_auto_send( + self, + task_id: str, + llm_config: LLMConfig, + trace_id: str | None = None, + parent_span_id: str | None = None, + created_at: datetime | None = None, + ) -> TaskMessage | None: + """ + Stream chat completion with automatic TaskMessage creation and streaming. + + Args: + task_id (str): The ID of the task to run the agent for. + llm_config (LLMConfig): The configuration for the LLM (must have stream=True). + + Returns: + TaskMessage: A TaskMessage object + """ + heartbeat_if_in_workflow("chat completion stream") + + if self.llm_gateway is None: + raise ValueError("LLM Gateway is not set") + + if not llm_config.stream: + llm_config.stream = True + + completion_kwargs = _stream_kwargs_with_usage(llm_config) + trace = self.tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="chat_completion_stream_auto_send", + input=completion_kwargs, + ) as span: + # Use streaming context manager + async with self.streaming_service.streaming_task_message_context( + task_id=task_id, + initial_content=TextContent( + author="agent", + content="", + format="markdown", + ), + created_at=created_at, + ) as streaming_context: + # Get the streaming response + chunks = [] + async for response in self.llm_gateway.acompletion_stream(**completion_kwargs): + heartbeat_if_in_workflow("chat completion streaming") + # Store every chunk for final message assembly, including + # the usage-only final chunk, which has no choices + chunks.append(response) + if response.choices and len(response.choices) > 0 and response.choices[0].delta: + delta = response.choices[0].delta.content + if delta: + # Stream the chunk via the context manager + await streaming_context.stream_update( + update=StreamTaskMessageDelta( + parent_task_message=streaming_context.task_message, + delta=TextDelta(text_delta=delta, type="text"), + type="delta", + ), + ) + heartbeat_if_in_workflow("content chunk streamed") + + # Update the final message content + complete_message = concat_completion_chunks(chunks) + if complete_message and complete_message.choices and complete_message.choices[0].message: + final_content = TextContent( + author="agent", + content=complete_message.choices[0].message.content or "", + format="markdown", + ) + await streaming_context.stream_update( + update=StreamTaskMessageFull( + parent_task_message=streaming_context.task_message, + content=final_content, + type="full", + ), + ) + + heartbeat_if_in_workflow("chat completion stream complete") + + if span: + if streaming_context.task_message: + output = streaming_context.task_message.model_dump() + # Per-call usage for billing; deduped against any turn aggregate + if complete_message.usage is not None: + output["usage"] = complete_message.usage.model_dump() + span.output = output + + return streaming_context.task_message if streaming_context.task_message else None diff --git a/src/agentex/lib/core/services/adk/providers/openai.py b/src/agentex/lib/core/services/adk/providers/openai.py new file mode 100644 index 000000000..cc411dc30 --- /dev/null +++ b/src/agentex/lib/core/services/adk/providers/openai.py @@ -0,0 +1,934 @@ +# Standard library imports +from __future__ import annotations + +from typing import Any, Literal +from datetime import datetime +from contextlib import AsyncExitStack, asynccontextmanager +from collections.abc import Callable + +from mcp import StdioServerParameters +from agents import Agent, Runner, RunResult, RunResultStreaming +from pydantic import BaseModel +from agents.mcp import MCPServerStdio +from agents.agent import StopAtTools, ToolsToFinalOutputFunction +from agents.guardrail import InputGuardrail, OutputGuardrail +from agents.exceptions import InputGuardrailTripwireTriggered, OutputGuardrailTripwireTriggered +from openai.types.responses import ( + ResponseFunctionWebSearch, + ResponseCodeInterpreterToolCall, +) + +# Local imports +from agentex import AsyncAgentex +from agentex.lib.utils import logging +from agentex.lib.utils.mcp import redact_mcp_server_params +from agentex.lib.utils.temporal import heartbeat_if_in_workflow +from agentex.lib.core.tracing.tracer import AsyncTracer +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.lib.core.tracing.lineage import merge_refs_into_data, resolve_refs_from_items +from agentex.types.task_message_update import StreamTaskMessageFull +from agentex.types.task_message_content import ( + TextContent, + ToolRequestContent, + ToolResponseContent, +) +from agentex.lib.core.services.adk.streaming import StreamingService + +logger = logging.make_logger(__name__) + + +@asynccontextmanager +async def mcp_server_context( + mcp_server_params: list[StdioServerParameters], + mcp_timeout_seconds: int | None = None, +): + """Context manager for MCP servers.""" + servers = [] + for params in mcp_server_params: + server = MCPServerStdio( + name=f"Server: {params.command}", + params=params.model_dump(), + cache_tools_list=True, + client_session_timeout_seconds=mcp_timeout_seconds, + ) + servers.append(server) + + async with AsyncExitStack() as stack: + for server in servers: + await stack.enter_async_context(server) + yield servers + + +def _make_created_at_dispenser(initial: datetime | None) -> Callable[[], datetime | None]: + # Returns a closure that yields the workflow-supplied created_at exactly + # once (on the first call), then None forever after. Used to stamp the + # first agent message of a turn with workflow.now() while letting + # subsequent messages fall back to server wall-clock — see the call sites + # in run_agent_auto_send / run_agent_streamed_auto_send for context. + pending: list[datetime | None] = [initial] + + def take() -> datetime | None: + value = pending[0] + pending[0] = None + return value + + return take + + +class OpenAIService: + """Service for OpenAI agent operations using the agents library.""" + + def __init__( + self, + agentex_client: AsyncAgentex | None = None, + streaming_service: StreamingService | None = None, + tracer: AsyncTracer | None = None, + ): + self.agentex_client = agentex_client + self.streaming_service = streaming_service + self.tracer = tracer + + def _extract_tool_call_info(self, tool_call_item: Any) -> tuple[str, str, dict[str, Any]]: + """ + Extract call_id, tool_name, and tool_arguments from a tool call item. + + Args: + tool_call_item: The tool call item to process + + Returns: + A tuple of (call_id, tool_name, tool_arguments) + """ + # Generic handling for different tool call types + # Try 'call_id' first, then 'id', then generate placeholder + if hasattr(tool_call_item, "call_id"): + call_id = tool_call_item.call_id + elif hasattr(tool_call_item, "id"): + call_id = tool_call_item.id + else: + call_id = f"unknown_call_{id(tool_call_item)}" + logger.warning( + f"Warning: Tool call item {type(tool_call_item)} has " + f"neither 'call_id' nor 'id' attribute, using placeholder: " + f"{call_id}" + ) + + if isinstance(tool_call_item, ResponseFunctionWebSearch): + tool_name = "web_search" + tool_arguments = {"action": tool_call_item.action.model_dump(), "status": tool_call_item.status} + elif isinstance(tool_call_item, ResponseCodeInterpreterToolCall): + tool_name = "code_interpreter" + tool_arguments = {"code": tool_call_item.code, "status": tool_call_item.status} + else: + # Generic handling for any tool call type + tool_name = getattr(tool_call_item, "name", type(tool_call_item).__name__) + tool_arguments = tool_call_item.model_dump() + + return call_id, tool_name, tool_arguments + + def _extract_tool_response_info(self, tool_call_map: dict[str, Any], tool_output_item: Any) -> tuple[str, str, str]: + """ + Extract call_id, tool_name, and content from a tool output item. + + Args: + tool_call_map: Map of call_ids to tool_call items + tool_output_item: The tool output item to process + + Returns: + A tuple of (call_id, tool_name, content) + """ + # Extract call_id and content from the tool_output_item + # Handle both dictionary access and attribute access + if hasattr(tool_output_item, "get") and callable(tool_output_item.get): + # Dictionary-like access + call_id = tool_output_item["call_id"] + content = tool_output_item["output"] + else: + # Attribute access for structured objects + call_id = getattr(tool_output_item, "call_id", "") + content = getattr(tool_output_item, "output", "") + + # Get the name from the tool call map using generic approach + tool_call = tool_call_map[call_id] + if hasattr(tool_call, "name"): + tool_name = tool_call.name + elif hasattr(tool_call, "type"): + tool_name = tool_call.type + else: + tool_name = type(tool_call).__name__ + + return call_id, tool_name, content + + async def run_agent( + self, + input_list: list[dict[str, Any]], + mcp_server_params: list[StdioServerParameters], + agent_name: str, + agent_instructions: str, + trace_id: str | None = None, + parent_span_id: str | None = None, + handoff_description: str | None = None, + handoffs: list[BaseModel] | None = None, + model: str | None = None, + model_settings: BaseModel | None = None, + tools: list[BaseModel] | None = None, + output_type: type[Any] | None = None, + tool_use_behavior: ( + Literal["run_llm_again", "stop_on_first_tool"] | StopAtTools | ToolsToFinalOutputFunction + ) = "run_llm_again", + mcp_timeout_seconds: int | None = None, + input_guardrails: list[InputGuardrail] | None = None, + output_guardrails: list[OutputGuardrail] | None = None, + max_turns: int | None = None, + previous_response_id: str | None = None, # noqa: ARG002 + ) -> RunResult: + """ + Run an agent without streaming or TaskMessage creation. + + Args: + input_list: List of input data for the agent. + mcp_server_params: MCP server parameters for the agent. + agent_name: The name of the agent to run. + agent_instructions: Instructions for the agent. + trace_id: Optional trace ID for tracing. + parent_span_id: Optional parent span ID for tracing. + handoff_description: Optional description of the handoff. + handoffs: Optional list of handoffs. + model: Optional model to use. + model_settings: Optional model settings. + tools: Optional list of tools. + output_type: Optional output type. + tool_use_behavior: Optional tool use behavior. + mcp_timeout_seconds: Optional param to set the timeout threshold + for the MCP servers. Defaults to 5 seconds. + input_guardrails: Optional list of input guardrails to run on + initial user input. + output_guardrails: Optional list of output guardrails to run on + final agent output. + mcp_timeout_seconds: Optional param to set the timeout threshold for the MCP servers. Defaults to 5 seconds. + max_turns: Maximum number of turns the agent can take. Uses Runner's default if None. + Returns: + SerializableRunResult: The result of the agent run. + """ + redacted_params = redact_mcp_server_params(mcp_server_params) + + if self.tracer is None: + raise RuntimeError("Tracer not initialized - ensure tracer is provided to OpenAIService") + trace = self.tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="run_agent", + input={ + "input_list": input_list, + "mcp_server_params": redacted_params, + "agent_name": agent_name, + "agent_instructions": agent_instructions, + "handoff_description": handoff_description, + "handoffs": handoffs, + "model": model, + "model_settings": model_settings, + "tools": tools, + "output_type": output_type, + "tool_use_behavior": tool_use_behavior, + "max_turns": max_turns, + }, + ) as span: + heartbeat_if_in_workflow("run agent") + + async with mcp_server_context(mcp_server_params, mcp_timeout_seconds) as servers: + tools = ( + [ + tool.to_oai_function_tool() if hasattr(tool, "to_oai_function_tool") else tool # type: ignore[attr-defined] + for tool in tools + ] + if tools + else [] + ) + handoffs = [Agent(**handoff.model_dump()) for handoff in handoffs] if handoffs else [] # type: ignore[misc] + + agent_kwargs = { + "name": agent_name, + "instructions": agent_instructions, + "mcp_servers": servers, + "handoff_description": handoff_description, + "handoffs": handoffs, + "model": model, + "tools": tools, + "output_type": output_type, + "tool_use_behavior": tool_use_behavior, + } + if model_settings is not None: + agent_kwargs["model_settings"] = ( + model_settings.to_oai_model_settings() # type: ignore[attr-defined] + if hasattr(model_settings, "to_oai_model_settings") + else model_settings + ) + if input_guardrails is not None: + agent_kwargs["input_guardrails"] = input_guardrails + if output_guardrails is not None: + agent_kwargs["output_guardrails"] = output_guardrails + + agent = Agent(**agent_kwargs) + + # Run without streaming + if max_turns is not None and previous_response_id is not None: + result = await Runner.run( + starting_agent=agent, + input=input_list, + max_turns=max_turns, + previous_response_id=previous_response_id, + ) + elif max_turns is not None: + result = await Runner.run(starting_agent=agent, input=input_list, max_turns=max_turns) + elif previous_response_id is not None: + result = await Runner.run( + starting_agent=agent, input=input_list, previous_response_id=previous_response_id + ) + else: + result = await Runner.run(starting_agent=agent, input=input_list) + + if span: + serialized_items = [ + item.raw_item.model_dump() if isinstance(item.raw_item, BaseModel) else item.raw_item + for item in result.new_items + ] + span.output = { + "new_items": serialized_items, + "final_output": result.final_output, + } + lineage_refs = resolve_refs_from_items(serialized_items) + if lineage_refs: + span.data = merge_refs_into_data(span.data, lineage_refs) + + return result + + async def run_agent_auto_send( + self, + task_id: str, + input_list: list[dict[str, Any]], + mcp_server_params: list[StdioServerParameters], + agent_name: str, + agent_instructions: str, + trace_id: str | None = None, + parent_span_id: str | None = None, + handoff_description: str | None = None, + handoffs: list[BaseModel] | None = None, + model: str | None = None, + model_settings: BaseModel | None = None, + tools: list[BaseModel] | None = None, + output_type: type[Any] | None = None, + tool_use_behavior: ( + Literal["run_llm_again", "stop_on_first_tool"] | StopAtTools | ToolsToFinalOutputFunction + ) = "run_llm_again", + mcp_timeout_seconds: int | None = None, + input_guardrails: list[InputGuardrail] | None = None, + output_guardrails: list[OutputGuardrail] | None = None, + max_turns: int | None = None, + previous_response_id: str | None = None, # noqa: ARG002 + created_at: datetime | None = None, + ) -> RunResult: + """ + Run an agent with automatic TaskMessage creation. + + Args: + task_id: The ID of the task to run the agent for. + input_list: List of input data for the agent. + mcp_server_params: MCP server parameters for the agent. + agent_name: The name of the agent to run. + agent_instructions: Instructions for the agent. + trace_id: Optional trace ID for tracing. + parent_span_id: Optional parent span ID for tracing. + handoff_description: Optional description of the handoff. + handoffs: Optional list of handoffs. + model: Optional model to use. + model_settings: Optional model settings. + tools: Optional list of tools. + output_type: Optional output type. + tool_use_behavior: Optional tool use behavior. + mcp_timeout_seconds: Optional param to set the timeout threshold for the MCP servers. Defaults to 5 seconds. + input_guardrails: Optional list of input guardrails to run on initial user input. + output_guardrails: Optional list of output guardrails to run on final agent output. + max_turns: Maximum number of turns the agent can take. Uses Runner's default if None. + Returns: + SerializableRunResult: The result of the agent run. + """ + if self.streaming_service is None: + raise ValueError("StreamingService must be available for auto_send methods") + if self.agentex_client is None: + raise ValueError("Agentex client must be provided for auto_send methods") + + redacted_params = redact_mcp_server_params(mcp_server_params) + + if self.tracer is None: + raise RuntimeError("Tracer not initialized - ensure tracer is provided to OpenAIService") + trace = self.tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="run_agent_auto_send", + input={ + "task_id": task_id, + "input_list": input_list, + "mcp_server_params": redacted_params, + "agent_name": agent_name, + "agent_instructions": agent_instructions, + "handoff_description": handoff_description, + "handoffs": handoffs, + "model": model, + "model_settings": model_settings, + "tools": tools, + "output_type": output_type, + "tool_use_behavior": tool_use_behavior, + "max_turns": max_turns, + }, + ) as span: + heartbeat_if_in_workflow("run agent auto send") + + _take_created_at = _make_created_at_dispenser(created_at) + + async with mcp_server_context(mcp_server_params, mcp_timeout_seconds) as servers: + tools = ( + [ + tool.to_oai_function_tool() if hasattr(tool, "to_oai_function_tool") else tool # type: ignore[attr-defined] + for tool in tools + ] + if tools + else [] + ) + handoffs = [Agent(**handoff.model_dump()) for handoff in handoffs] if handoffs else [] # type: ignore[misc] + agent_kwargs = { + "name": agent_name, + "instructions": agent_instructions, + "mcp_servers": servers, + "handoff_description": handoff_description, + "handoffs": handoffs, + "model": model, + "tools": tools, + "output_type": output_type, + "tool_use_behavior": tool_use_behavior, + } + if model_settings is not None: + agent_kwargs["model_settings"] = ( + model_settings.to_oai_model_settings() # type: ignore[attr-defined] + if hasattr(model_settings, "to_oai_model_settings") + else model_settings + ) + if input_guardrails is not None: + agent_kwargs["input_guardrails"] = input_guardrails + if output_guardrails is not None: + agent_kwargs["output_guardrails"] = output_guardrails + + agent = Agent(**agent_kwargs) + + # Run without streaming + if max_turns is not None and previous_response_id is not None: + result = await Runner.run( + starting_agent=agent, + input=input_list, + max_turns=max_turns, + previous_response_id=previous_response_id, + ) + elif max_turns is not None: + result = await Runner.run(starting_agent=agent, input=input_list, max_turns=max_turns) + elif previous_response_id is not None: + result = await Runner.run( + starting_agent=agent, input=input_list, previous_response_id=previous_response_id + ) + else: + result = await Runner.run(starting_agent=agent, input=input_list) + + if span: + serialized_items = [ + item.raw_item.model_dump() if isinstance(item.raw_item, BaseModel) else item.raw_item + for item in result.new_items + ] + span.output = { + "new_items": serialized_items, + "final_output": result.final_output, + } + lineage_refs = resolve_refs_from_items(serialized_items) + if lineage_refs: + span.data = merge_refs_into_data(span.data, lineage_refs) + + tool_call_map: dict[str, Any] = {} + + for item in result.new_items: + if item.type == "message_output_item": + text_content = TextContent( + author="agent", + content=item.raw_item.content[0].text, # type: ignore[union-attr] + ) + # Create message for the final result using streaming context + async with self.streaming_service.streaming_task_message_context( + task_id=task_id, + initial_content=text_content, + created_at=_take_created_at(), + ) as streaming_context: + await streaming_context.stream_update( + update=StreamTaskMessageFull( + parent_task_message=streaming_context.task_message, + content=text_content, + type="full", + ), + ) + + elif item.type == "tool_call_item": + tool_call_item = item.raw_item + + # Extract tool call information using the helper method + call_id, tool_name, tool_arguments = self._extract_tool_call_info(tool_call_item) + tool_call_map[call_id] = tool_call_item + + tool_request_content = ToolRequestContent( + author="agent", + tool_call_id=call_id, + name=tool_name, + arguments=tool_arguments, + ) + + # Create tool request using streaming context + async with self.streaming_service.streaming_task_message_context( + task_id=task_id, + initial_content=tool_request_content, + created_at=_take_created_at(), + ) as streaming_context: + await streaming_context.stream_update( + update=StreamTaskMessageFull( + parent_task_message=streaming_context.task_message, + content=tool_request_content, + type="full", + ), + ) + + elif item.type == "tool_call_output_item": + tool_output_item = item.raw_item + + # Extract tool response information using the helper method + call_id, tool_name, content = self._extract_tool_response_info(tool_call_map, tool_output_item) + + tool_response_content = ToolResponseContent( + author="agent", + tool_call_id=call_id, + name=tool_name, + content=content, + ) + # Create tool response using streaming context + async with self.streaming_service.streaming_task_message_context( + task_id=task_id, + initial_content=tool_response_content, + created_at=_take_created_at(), + ) as streaming_context: + await streaming_context.stream_update( + update=StreamTaskMessageFull( + parent_task_message=streaming_context.task_message, + content=tool_response_content, + type="full", + ), + ) + + # Convert to serializable result + return result + + async def run_agent_streamed( + self, + input_list: list[dict[str, Any]], + mcp_server_params: list[StdioServerParameters], + agent_name: str, + agent_instructions: str, + trace_id: str | None = None, + parent_span_id: str | None = None, + handoff_description: str | None = None, + handoffs: list[BaseModel] | None = None, + model: str | None = None, + model_settings: BaseModel | None = None, + tools: list[BaseModel] | None = None, + output_type: type[Any] | None = None, + tool_use_behavior: ( + Literal["run_llm_again", "stop_on_first_tool"] | StopAtTools | ToolsToFinalOutputFunction + ) = "run_llm_again", + mcp_timeout_seconds: int | None = None, + input_guardrails: list[InputGuardrail] | None = None, + output_guardrails: list[OutputGuardrail] | None = None, + max_turns: int | None = None, + previous_response_id: str | None = None, # noqa: ARG002 + ) -> RunResultStreaming: + """ + Run an agent with streaming enabled but no TaskMessage creation. + + Args: + input_list: List of input data for the agent. + mcp_server_params: MCP server parameters for the agent. + agent_name: The name of the agent to run. + agent_instructions: Instructions for the agent. + trace_id: Optional trace ID for tracing. + parent_span_id: Optional parent span ID for tracing. + handoff_description: Optional description of the handoff. + handoffs: Optional list of handoffs. + model: Optional model to use. + model_settings: Optional model settings. + tools: Optional list of tools. + output_type: Optional output type. + tool_use_behavior: Optional tool use behavior. + mcp_timeout_seconds: Optional param to set the timeout threshold + for the MCP servers. Defaults to 5 seconds. + input_guardrails: Optional list of input guardrails to run on + initial user input. + output_guardrails: Optional list of output guardrails to run on + final agent output. + mcp_timeout_seconds: Optional param to set the timeout threshold for the MCP servers. Defaults to 5 seconds. + max_turns: Maximum number of turns the agent can take. Uses Runner's default if None. + Returns: + RunResultStreaming: The result of the agent run with streaming. + """ + if self.tracer is None: + raise RuntimeError("Tracer not initialized - ensure tracer is provided to OpenAIService") + trace = self.tracer.trace(trace_id) + redacted_params = redact_mcp_server_params(mcp_server_params) + + async with trace.span( + parent_id=parent_span_id, + name="run_agent_streamed", + input={ + "input_list": input_list, + "mcp_server_params": redacted_params, + "agent_name": agent_name, + "agent_instructions": agent_instructions, + "handoff_description": handoff_description, + "handoffs": handoffs, + "model": model, + "model_settings": model_settings, + "tools": tools, + "output_type": output_type, + "tool_use_behavior": tool_use_behavior, + "max_turns": max_turns, + }, + ) as span: + heartbeat_if_in_workflow("run agent streamed") + + async with mcp_server_context(mcp_server_params, mcp_timeout_seconds) as servers: + tools = ( + [ + tool.to_oai_function_tool() if hasattr(tool, "to_oai_function_tool") else tool # type: ignore[attr-defined] + for tool in tools + ] + if tools + else [] + ) + handoffs = [Agent(**handoff.model_dump()) for handoff in handoffs] if handoffs else [] # type: ignore[misc] + agent_kwargs = { + "name": agent_name, + "instructions": agent_instructions, + "mcp_servers": servers, + "handoff_description": handoff_description, + "handoffs": handoffs, + "model": model, + "tools": tools, + "output_type": output_type, + "tool_use_behavior": tool_use_behavior, + } + if model_settings is not None: + agent_kwargs["model_settings"] = ( + model_settings.to_oai_model_settings() # type: ignore[attr-defined] + if hasattr(model_settings, "to_oai_model_settings") + else model_settings + ) + if input_guardrails is not None: + agent_kwargs["input_guardrails"] = input_guardrails + if output_guardrails is not None: + agent_kwargs["output_guardrails"] = output_guardrails + + agent = Agent(**agent_kwargs) + + # Run with streaming (but no TaskMessage creation) + if max_turns is not None and previous_response_id is not None: + result = Runner.run_streamed( + starting_agent=agent, + input=input_list, + max_turns=max_turns, + previous_response_id=previous_response_id, + ) + elif max_turns is not None: + result = Runner.run_streamed(starting_agent=agent, input=input_list, max_turns=max_turns) + elif previous_response_id is not None: + result = Runner.run_streamed( + starting_agent=agent, input=input_list, previous_response_id=previous_response_id + ) + else: + result = Runner.run_streamed(starting_agent=agent, input=input_list) + + if span: + serialized_items = [ + item.raw_item.model_dump() if isinstance(item.raw_item, BaseModel) else item.raw_item + for item in result.new_items + ] + span.output = { + "new_items": serialized_items, + "final_output": result.final_output, + } + lineage_refs = resolve_refs_from_items(serialized_items) + if lineage_refs: + span.data = merge_refs_into_data(span.data, lineage_refs) + + return result + + async def run_agent_streamed_auto_send( + self, + task_id: str, + input_list: list[dict[str, Any]], + mcp_server_params: list[StdioServerParameters], + agent_name: str, + agent_instructions: str, + trace_id: str | None = None, + parent_span_id: str | None = None, + handoff_description: str | None = None, + handoffs: list[BaseModel] | None = None, + model: str | None = None, + model_settings: BaseModel | None = None, + tools: list[BaseModel] | None = None, + output_type: type[Any] | None = None, + tool_use_behavior: ( + Literal["run_llm_again", "stop_on_first_tool"] | StopAtTools | ToolsToFinalOutputFunction + ) = "run_llm_again", + mcp_timeout_seconds: int | None = None, + input_guardrails: list[InputGuardrail] | None = None, + output_guardrails: list[OutputGuardrail] | None = None, + max_turns: int | None = None, + previous_response_id: str | None = None, + created_at: datetime | None = None, + ) -> RunResultStreaming: + """ + Run an agent with streaming enabled and automatic TaskMessage creation. + + Args: + task_id: The ID of the task to run the agent for. + input_list: List of input data for the agent. + mcp_server_params: MCP server parameters for the agent. + agent_name: The name of the agent to run. + agent_instructions: Instructions for the agent. + trace_id: Optional trace ID for tracing. + parent_span_id: Optional parent span ID for tracing. + handoff_description: Optional description of the handoff. + handoffs: Optional list of handoffs. + model: Optional model to use. + model_settings: Optional model settings. + tools: Optional list of tools. + output_type: Optional output type. + tool_use_behavior: Optional tool use behavior. + mcp_timeout_seconds: Optional param to set the timeout threshold + for the MCP servers. Defaults to 5 seconds. + input_guardrails: Optional list of input guardrails to run on + initial user input. + output_guardrails: Optional list of output guardrails to run on + final agent output. + mcp_timeout_seconds: Optional param to set the timeout threshold for the MCP servers. Defaults to 5 seconds. + max_turns: Maximum number of turns the agent can take. Uses Runner's default if None. + + Returns: + RunResultStreaming: The result of the agent run with streaming. + """ + if self.streaming_service is None: + raise ValueError("StreamingService must be available for auto_send methods") + if self.agentex_client is None: + raise ValueError("Agentex client must be provided for auto_send methods") + + if self.tracer is None: + raise RuntimeError("Tracer not initialized - ensure tracer is provided to OpenAIService") + trace = self.tracer.trace(trace_id) + redacted_params = redact_mcp_server_params(mcp_server_params) + + async with trace.span( + parent_id=parent_span_id, + name="run_agent_streamed_auto_send", + input={ + "task_id": task_id, + "input_list": input_list, + "mcp_server_params": redacted_params, + "agent_name": agent_name, + "agent_instructions": agent_instructions, + "handoff_description": handoff_description, + "handoffs": handoffs, + "model": model, + "model_settings": model_settings, + "tools": tools, + "output_type": output_type, + "tool_use_behavior": tool_use_behavior, + "max_turns": max_turns, + }, + ) as span: + heartbeat_if_in_workflow("run agent streamed auto send") + + # created_at is threaded through UnifiedEmitter.auto_send_turn -> + # auto_send -> every streaming_task_message_context call, so the + # first agent message of the turn is stamped with the + # workflow-supplied timestamp (e.g. workflow.now()). + # The dispenser is still used below for guardrail-rejection messages, + # which open their own streaming contexts directly. + _take_created_at = _make_created_at_dispenser(created_at) + + async with mcp_server_context(mcp_server_params, mcp_timeout_seconds) as servers: + tools = ( + [ + tool.to_oai_function_tool() if hasattr(tool, "to_oai_function_tool") else tool # type: ignore[attr-defined] + for tool in tools + ] + if tools + else [] + ) + handoffs = [Agent(**handoff.model_dump()) for handoff in handoffs] if handoffs else [] # type: ignore[misc] + agent_kwargs = { + "name": agent_name, + "instructions": agent_instructions, + "mcp_servers": servers, + "handoff_description": handoff_description, + "handoffs": handoffs, + "model": model, + "tools": tools, + "output_type": output_type, + "tool_use_behavior": tool_use_behavior, + } + if model_settings is not None: + agent_kwargs["model_settings"] = ( + model_settings.to_oai_model_settings() # type: ignore[attr-defined] + if hasattr(model_settings, "to_oai_model_settings") + else model_settings + ) + if input_guardrails is not None: + agent_kwargs["input_guardrails"] = input_guardrails + if output_guardrails is not None: + agent_kwargs["output_guardrails"] = output_guardrails + + agent = Agent(**agent_kwargs) + + # Run with streaming. Forward previous_response_id so callers that + # continue a Responses-API conversation resume the prior response + # instead of silently starting a fresh one (mirrors the non-auto-send + # run_agent_streamed path). + if max_turns is not None and previous_response_id is not None: + result = Runner.run_streamed( + starting_agent=agent, + input=input_list, + max_turns=max_turns, + previous_response_id=previous_response_id, + ) + elif max_turns is not None: + result = Runner.run_streamed(starting_agent=agent, input=input_list, max_turns=max_turns) + elif previous_response_id is not None: + result = Runner.run_streamed( + starting_agent=agent, input=input_list, previous_response_id=previous_response_id + ) + else: + result = Runner.run_streamed(starting_agent=agent, input=input_list) + + # Migrate onto the unified harness surface: wrap the streamed run + # as an OpenAITurn (provider -> canonical StreamTaskMessage* + # adapter) and let UnifiedEmitter.auto_send_turn drive delivery + + # tracing + usage. The previous ~270-line inline loop that hand- + # rolled per-item streaming contexts, reasoning handling, and + # span derivation now lives in the shared harness modules. + # Imported lazily: openai_turn pulls in agentex.lib.adk, which + # imports this service module, so an eager import would create a + # circular import at package init. + from agentex.lib.adk.providers._modules.openai_turn import OpenAITurn + + turn = OpenAITurn(result=result, model=model) + emitter = UnifiedEmitter( + task_id=task_id, + trace_id=trace_id, + parent_span_id=parent_span_id, + tracer=self.tracer, + streaming=self.streaming_service, + ) + + try: + await emitter.auto_send_turn(turn, created_at=created_at) + + except InputGuardrailTripwireTriggered as e: + # Handle guardrail trigger by sending a rejection message + rejection_message = "I'm sorry, but I cannot process this request due to a guardrail. Please try a different question." + + # Try to extract rejection message from the guardrail result + if hasattr(e, "guardrail_result") and hasattr(e.guardrail_result, "output"): + output_info = getattr(e.guardrail_result.output, "output_info", {}) + if isinstance(output_info, dict) and "rejection_message" in output_info: + rejection_message = output_info["rejection_message"] + elif hasattr(e.guardrail_result, "guardrail"): + # Fall back to using guardrail name if no custom message + triggered_guardrail_name = getattr(e.guardrail_result.guardrail, "name", None) + if triggered_guardrail_name: + rejection_message = f"I'm sorry, but I cannot process this request. The '{triggered_guardrail_name}' guardrail was triggered." + + # Create and send the rejection message as a TaskMessage + async with self.streaming_service.streaming_task_message_context( + task_id=task_id, + initial_content=TextContent( + author="agent", + content=rejection_message, + ), + created_at=_take_created_at(), + ) as streaming_context: + # Send the full message + await streaming_context.stream_update( + update=StreamTaskMessageFull( + parent_task_message=streaming_context.task_message, + content=TextContent( + author="agent", + content=rejection_message, + ), + type="full", + ), + ) + + # Re-raise to let the activity handle it + raise + + except OutputGuardrailTripwireTriggered as e: + # Handle output guardrail trigger by sending a rejection message + rejection_message = "I'm sorry, but I cannot provide this response due to a guardrail. Please try a different question." + + # Try to extract rejection message from the guardrail result + if hasattr(e, "guardrail_result") and hasattr(e.guardrail_result, "output"): + output_info = getattr(e.guardrail_result.output, "output_info", {}) + if isinstance(output_info, dict) and "rejection_message" in output_info: + rejection_message = output_info["rejection_message"] + elif hasattr(e.guardrail_result, "guardrail"): + # Fall back to using guardrail name if no custom message + triggered_guardrail_name = getattr(e.guardrail_result.guardrail, "name", None) + if triggered_guardrail_name: + rejection_message = f"I'm sorry, but I cannot provide this response. The '{triggered_guardrail_name}' guardrail was triggered." + + # Create and send the rejection message as a TaskMessage + async with self.streaming_service.streaming_task_message_context( + task_id=task_id, + initial_content=TextContent( + author="agent", + content=rejection_message, + ), + created_at=_take_created_at(), + ) as streaming_context: + # Send the full message + await streaming_context.stream_update( + update=StreamTaskMessageFull( + parent_task_message=streaming_context.task_message, + content=TextContent( + author="agent", + content=rejection_message, + ), + type="full", + ), + ) + + # Re-raise to let the activity handle it + raise + + if span: + serialized_items = [ + item.raw_item.model_dump() if isinstance(item.raw_item, BaseModel) else item.raw_item + for item in result.new_items + ] + span.output = { + "new_items": serialized_items, + "final_output": result.final_output, + } + lineage_refs = resolve_refs_from_items(serialized_items) + if lineage_refs: + span.data = merge_refs_into_data(span.data, lineage_refs) + + return result diff --git a/src/agentex/lib/core/services/adk/providers/sgp.py b/src/agentex/lib/core/services/adk/providers/sgp.py new file mode 100644 index 000000000..69f765aa7 --- /dev/null +++ b/src/agentex/lib/core/services/adk/providers/sgp.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import os +import base64 +import tempfile + +from scale_gp import SGPClient + +from agentex.lib.types.files import FileContentResponse +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.temporal import heartbeat_if_in_workflow +from agentex.lib.core.tracing.tracer import AsyncTracer + +logger = make_logger(__name__) + + +class SGPService: + def __init__(self, sgp_client: SGPClient, tracer: AsyncTracer): + self.sgp_client = sgp_client + self.tracer = tracer + + async def download_file_content( + self, + file_id: str, + filename: str, + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> FileContentResponse: + """ + Download file content from SGP. + + Args: + file_id: The ID of the file to download. + filename: The filename of the file to download. + trace_id: The trace ID for tracing. + parent_span_id: The parent span ID for tracing. + + Returns: + FileContentResponse with mime_type and base64_content for constructing LLM input. + """ + trace = self.tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="download_file_content", + input={"file_id": file_id, "filename": filename}, + ) as span: + logger.info(f"Downloading file content for file_id: {file_id}") + heartbeat_if_in_workflow("downloading file content") + + # Get the SGP response + response = self.sgp_client.beta.files.content(file_id) + heartbeat_if_in_workflow("file content downloaded") + + # Determine mime type based on file extension + mime_type = "application/pdf" # Default + file_extension = os.path.splitext(filename)[1].lower() + if file_extension: + if file_extension == ".pdf": + mime_type = "application/pdf" + elif file_extension in [".doc", ".docx"]: + mime_type = "application/msword" + elif file_extension in [".txt", ".text"]: + mime_type = "text/plain" + elif file_extension in [".png"]: + mime_type = "image/png" + elif file_extension in [".jpg", ".jpeg"]: + mime_type = "image/jpeg" + + # Use a named temporary file - simpler approach + with tempfile.NamedTemporaryFile(suffix=file_extension) as temp_file: + heartbeat_if_in_workflow(f"saving to temp file: {temp_file.name}") + + # Use write_to_file method if available + if hasattr(response, "write_to_file"): + response.write_to_file(temp_file.name) + else: + # Fallback to direct writing + content_bytes = response.read() + temp_file.write(content_bytes) + temp_file.flush() + + # Seek to beginning of file for reading + temp_file.seek(0) + + # Read the file in binary mode - exactly like the example + data = temp_file.read() + + # Encode to base64 + base64_content = base64.b64encode(data).decode("utf-8") + + result = FileContentResponse( + mime_type=mime_type, base64_content=base64_content + ) + + # Record metadata for tracing + span.output = { # type: ignore[union-attr] + "file_id": file_id, + "mime_type": result.mime_type, + "content_size": len(result.base64_content), + } + return result diff --git a/src/agentex/lib/core/services/adk/state.py b/src/agentex/lib/core/services/adk/state.py new file mode 100644 index 000000000..93012b933 --- /dev/null +++ b/src/agentex/lib/core/services/adk/state.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from typing import Any, Dict + +from agentex import AsyncAgentex +from agentex.types.state import State +from agentex.lib.utils.logging import make_logger +from agentex.lib.core.tracing.tracer import AsyncTracer + +logger = make_logger(__name__) + + +class StateService: + def __init__( + self, agentex_client: AsyncAgentex, tracer: AsyncTracer + ): + self._agentex_client = agentex_client + self._tracer = tracer + + async def create_state( + self, + task_id: str, + agent_id: str, + state: dict[str, Any], + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> State: + trace = self._tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="create_state", + input={"task_id": task_id, "agent_id": agent_id, "state": state}, + ) as span: + state_model = await self._agentex_client.states.create( + task_id=task_id, + agent_id=agent_id, + state=state, + ) + if span: + span.output = state_model.model_dump() + return state_model + + async def get_state( + self, + state_id: str | None = None, + task_id: str | None = None, + agent_id: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> State | None: + trace = self._tracer.trace(trace_id) if self._tracer else None + if trace is None: + # Handle case without tracing - implement the core logic here + return await self._agentex_client.states.retrieve(state_id) + + async with trace.span( + parent_id=parent_span_id, + name="get_state", + input={ + "state_id": state_id, + "task_id": task_id, + "agent_id": agent_id, + }, + ) as span: + if state_id: + state = await self._agentex_client.states.retrieve(state_id=state_id) + elif task_id and agent_id: + states = await self._agentex_client.states.list( + task_id=task_id, + agent_id=agent_id, + ) + state = states[0] if states else None + else: + raise ValueError( + "Must provide either state_id or both task_id and agent_id" + ) + if span: + span.output = state.model_dump() if state else None + return state + + async def update_state( + self, + state_id: str, + task_id: str, + agent_id: str, + state: Dict[str, object], + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> State: + trace = self._tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="update_state", + input={ + "state_id": state_id, + "task_id": task_id, + "agent_id": agent_id, + "state": state, + }, + ) as span: + # Send task_id/agent_id in the body for backends predating + # scale-agentex#278, which still require them (newer ones ignore them). + state_model = await self._agentex_client.states.update( + state_id=state_id, + state=state, + extra_body={"task_id": task_id, "agent_id": agent_id}, + ) + if span: + span.output = state_model.model_dump() + return state_model + + async def delete_state( + self, + state_id: str, + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> State: + trace = self._tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="delete_state", + input={"state_id": state_id}, + ) as span: + state = await self._agentex_client.states.delete(state_id) + if span: + span.output = state.model_dump() + return state diff --git a/src/agentex/lib/core/services/adk/streaming.py b/src/agentex/lib/core/services/adk/streaming.py new file mode 100644 index 000000000..33ca7bc1c --- /dev/null +++ b/src/agentex/lib/core/services/adk/streaming.py @@ -0,0 +1,573 @@ +from __future__ import annotations + +import json +import asyncio +from typing import Literal, Callable, Awaitable +from datetime import datetime + +from agentex import AsyncAgentex +from agentex._types import omit +from agentex.lib.utils.logging import make_logger +from agentex.types.data_content import DataContent +from agentex.types.task_message import ( + TaskMessage, + TaskMessageContent, +) +from agentex.types.text_content import TextContent +from agentex.types.reasoning_content import ReasoningContent +from agentex.types.task_message_delta import ( + DataDelta, + TextDelta, + ToolRequestDelta, + ToolResponseDelta, + ReasoningContentDelta, + ReasoningSummaryDelta, +) +from agentex.types.task_message_update import ( + TaskMessageDelta, + TaskMessageUpdate, + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.lib.core.adapters.streams.port import StreamRepository + +logger = make_logger(__name__) + + +def _get_stream_topic(task_id: str) -> str: + return f"task:{task_id}" + + +StreamingMode = Literal["off", "per_token", "coalesced"] +"""Controls how a StreamingTaskMessageContext publishes deltas. + +- "off": Feed the accumulator (so the persisted message body is correct) + but never publish per-delta events. Consumers see start + done + only. Lowest latency. +- "per_token": Publish every delta immediately. Highest UX fidelity for + token-by-token rendering, highest Redis cost, and re-introduces + head-of-line blocking on the producer's event loop. +- "coalesced": Buffer deltas in a small time/size window and publish them as + merged batches. The first delta flushes immediately for fast + perceived responsiveness; subsequent deltas flush every 50ms or + whenever 128 buffered chars accumulate, whichever comes first. + Order within each (delta type, index) channel is preserved + exactly; only granularity changes. +""" + + +def _delta_char_len(delta: TaskMessageDelta | None) -> int: + if delta is None: + return 0 + if isinstance(delta, TextDelta): + return len(delta.text_delta or "") + if isinstance(delta, DataDelta): + return len(delta.data_delta or "") + if isinstance(delta, ReasoningSummaryDelta): + return len(delta.summary_delta or "") + if isinstance(delta, ReasoningContentDelta): + return len(delta.content_delta or "") + if isinstance(delta, ToolRequestDelta): + return len(delta.arguments_delta or "") + if isinstance(delta, ToolResponseDelta): + return len(delta.content_delta or "") + return 0 + + +def _can_merge(a: TaskMessageDelta, b: TaskMessageDelta) -> bool: + if type(a) is not type(b): + return False + if isinstance(a, ReasoningSummaryDelta) and isinstance(b, ReasoningSummaryDelta): + return a.summary_index == b.summary_index + if isinstance(a, ReasoningContentDelta) and isinstance(b, ReasoningContentDelta): + return a.content_index == b.content_index + if isinstance(a, ToolRequestDelta) and isinstance(b, ToolRequestDelta): + return a.tool_call_id == b.tool_call_id + if isinstance(a, ToolResponseDelta) and isinstance(b, ToolResponseDelta): + return a.tool_call_id == b.tool_call_id + return True + + +def _merge_pair(a: TaskMessageDelta, b: TaskMessageDelta) -> TaskMessageDelta: + if isinstance(a, TextDelta) and isinstance(b, TextDelta): + return TextDelta(type="text", text_delta=(a.text_delta or "") + (b.text_delta or "")) + if isinstance(a, DataDelta) and isinstance(b, DataDelta): + return DataDelta(type="data", data_delta=(a.data_delta or "") + (b.data_delta or "")) + if isinstance(a, ReasoningSummaryDelta) and isinstance(b, ReasoningSummaryDelta): + return ReasoningSummaryDelta( + type="reasoning_summary", + summary_index=a.summary_index, + summary_delta=(a.summary_delta or "") + (b.summary_delta or ""), + ) + if isinstance(a, ReasoningContentDelta) and isinstance(b, ReasoningContentDelta): + return ReasoningContentDelta( + type="reasoning_content", + content_index=a.content_index, + content_delta=(a.content_delta or "") + (b.content_delta or ""), + ) + if isinstance(a, ToolRequestDelta) and isinstance(b, ToolRequestDelta): + return ToolRequestDelta( + type="tool_request", + tool_call_id=a.tool_call_id, + name=a.name, + arguments_delta=(a.arguments_delta or "") + (b.arguments_delta or ""), + ) + if isinstance(a, ToolResponseDelta) and isinstance(b, ToolResponseDelta): + return ToolResponseDelta( + type="tool_response", + tool_call_id=a.tool_call_id, + name=a.name, + content_delta=(a.content_delta or "") + (b.content_delta or ""), + ) + raise AssertionError( + f"_can_merge approved {type(a).__name__} pair but _merge_pair has no handler — " + "a new TaskMessageDelta variant was added without updating both functions" + ) + + +def _merge_consecutive(updates: list[StreamTaskMessageDelta]) -> list[StreamTaskMessageDelta]: + """Merge consecutive same-channel deltas. Order across channels is preserved exactly.""" + result: list[StreamTaskMessageDelta] = [] + for u in updates: + if u.delta is None or not result: + result.append(u) + continue + last = result[-1] + if last.delta is not None and _can_merge(last.delta, u.delta): + result[-1] = StreamTaskMessageDelta( + parent_task_message=last.parent_task_message, + delta=_merge_pair(last.delta, u.delta), + type="delta", + ) + else: + result.append(u) + return result + + +class CoalescingBuffer: + """Time-and-size-windowed buffer that merges consecutive same-channel deltas. + + Decouples the producer (model event loop) from the publisher (Redis): ``add`` + only enqueues and may signal an early flush; the actual publish always runs + on a background ticker, so the producer never awaits on a Redis round-trip. + """ + + FLUSH_INTERVAL_S = 0.050 + MAX_BUFFERED_CHARS = 128 + + def __init__(self, on_flush: Callable[[StreamTaskMessageDelta], Awaitable[object]]): + self._on_flush = on_flush + self._buf: list[StreamTaskMessageDelta] = [] + self._buf_chars = 0 + self._first_flushed = False + self._closed = False + self._lock = asyncio.Lock() + self._flush_signal = asyncio.Event() + self._task: asyncio.Task[None] | None = None + + def start(self) -> None: + if self._task is None: + self._task = asyncio.create_task(self._run(), name="coalescing-buffer") + + async def add(self, update: StreamTaskMessageDelta) -> None: + if self._closed: + return + async with self._lock: + # Re-check under the lock: a concurrent close() (e.g. from a racing + # Full) may have drained and shut down the ticker after the check + # above but before we acquired the lock. Appending now would strand + # the delta in a dead buffer, never published. + if self._closed: + return + self._buf.append(update) + self._buf_chars += _delta_char_len(update.delta) + if not self._first_flushed or self._buf_chars >= self.MAX_BUFFERED_CHARS: + self._first_flushed = True + self._flush_signal.set() + + async def _run(self) -> None: + try: + while True: + try: + await asyncio.wait_for(self._flush_signal.wait(), timeout=self.FLUSH_INTERVAL_S) + except asyncio.TimeoutError: + pass + async with self._lock: + self._flush_signal.clear() + drained = self._drain_locked() + for u in drained: + try: + await self._on_flush(u) + except Exception as e: + logger.exception(f"CoalescingBuffer flush failed: {e}") + # Check _closed *after* draining so close() always gets a final + # in-loop flush pass. Exiting here (instead of being cancelled + # mid-flush) guarantees each in-flight item is published exactly + # once — close()'s final drain then only picks up items added + # after the last lock release. + if self._closed: + return + except asyncio.CancelledError: + pass + + async def close(self) -> None: + # Signal the ticker to stop and let it exit naturally after its next + # drain. Cancelling mid-flush would risk re-publishing a delta whose + # Redis write already completed but whose await had not yet returned, + # producing the duplicate-tail symptom seen on the UI stream. + self._closed = True + if self._task is not None: + self._flush_signal.set() + try: + await self._task + except asyncio.CancelledError: + # Propagate if our caller is being cancelled; the task itself + # swallows CancelledError so this only fires on outer cancel. + raise + self._task = None + async with self._lock: + drained = self._drain_locked() + for u in drained: + try: + await self._on_flush(u) + except Exception as e: + logger.exception(f"CoalescingBuffer final flush failed: {e}") + + def _drain_locked(self) -> list[StreamTaskMessageDelta]: + if not self._buf: + return [] + merged = _merge_consecutive(self._buf) + self._buf = [] + self._buf_chars = 0 + return merged + + +class DeltaAccumulator: + def __init__(self): + self._accumulated_deltas: list[TaskMessageDelta] = [] + self._delta_type: Literal["text", "data", "tool_request", "tool_response", "reasoning"] | None = None + # For reasoning, we need to track both summary and content deltas + self._reasoning_summaries: dict[int, str] = {} + self._reasoning_contents: dict[int, str] = {} + + def add_delta(self, delta: TaskMessageDelta): + if self._delta_type is None: + if delta.type == "text": + self._delta_type = "text" + elif delta.type == "data": + self._delta_type = "data" + elif delta.type == "tool_request": + self._delta_type = "tool_request" + elif delta.type == "tool_response": + self._delta_type = "tool_response" + elif delta.type in ["reasoning_summary", "reasoning_content"]: + self._delta_type = "reasoning" + else: + raise ValueError(f"Unknown delta type: {delta.type}") + else: + # For reasoning, we allow both summary and content deltas + if self._delta_type == "reasoning": + if delta.type not in ["reasoning_summary", "reasoning_content"]: + raise ValueError(f"Expected reasoning delta but got: {delta.type}") + elif self._delta_type != delta.type: + raise ValueError(f"Delta type mismatch: {self._delta_type} != {delta.type}") + + # Handle reasoning deltas specially + if delta.type == "reasoning_summary": + if isinstance(delta, ReasoningSummaryDelta): + if delta.summary_index not in self._reasoning_summaries: + self._reasoning_summaries[delta.summary_index] = "" + self._reasoning_summaries[delta.summary_index] += delta.summary_delta or "" + elif delta.type == "reasoning_content": + if isinstance(delta, ReasoningContentDelta): + if delta.content_index not in self._reasoning_contents: + self._reasoning_contents[delta.content_index] = "" + self._reasoning_contents[delta.content_index] += delta.content_delta or "" + else: + self._accumulated_deltas.append(delta) + + def convert_to_content(self) -> TaskMessageContent: + if self._delta_type == "text": + # Type assertion: we know all deltas are TextDelta when _delta_type is TEXT + text_deltas = [delta for delta in self._accumulated_deltas if isinstance(delta, TextDelta)] + text_content_str = "".join([delta.text_delta or "" for delta in text_deltas]) + return TextContent( + author="agent", + content=text_content_str, + ) + elif self._delta_type == "data": + # Type assertion: we know all deltas are DataDelta when _delta_type is DATA + data_deltas = [delta for delta in self._accumulated_deltas if isinstance(delta, DataDelta)] + data_content_str = "".join([delta.data_delta or "" for delta in data_deltas]) + try: + data = json.loads(data_content_str) + except json.JSONDecodeError as e: + raise ValueError(f"Accumulated data content is not valid JSON: {data_content_str}") from e + return DataContent( + author="agent", + data=data, + ) + elif self._delta_type == "tool_request": + # Type assertion: we know all deltas are ToolRequestDelta when _delta_type is TOOL_REQUEST + tool_request_deltas = [delta for delta in self._accumulated_deltas if isinstance(delta, ToolRequestDelta)] + arguments_content_str = "".join([delta.arguments_delta or "" for delta in tool_request_deltas]) + try: + arguments = json.loads(arguments_content_str) + except json.JSONDecodeError as e: + raise ValueError( + f"Accumulated tool request arguments is not valid JSON: {arguments_content_str}" + ) from e + return ToolRequestContent( + author="agent", + tool_call_id=tool_request_deltas[0].tool_call_id, + name=tool_request_deltas[0].name, + arguments=arguments, + ) + elif self._delta_type == "tool_response": + # Type assertion: we know all deltas are ToolResponseDelta when _delta_type is TOOL_RESPONSE + tool_response_deltas = [delta for delta in self._accumulated_deltas if isinstance(delta, ToolResponseDelta)] + tool_response_content_str = "".join([delta.content_delta or "" for delta in tool_response_deltas]) + return ToolResponseContent( + author="agent", + tool_call_id=tool_response_deltas[0].tool_call_id, + name=tool_response_deltas[0].name, + content=tool_response_content_str, + ) + elif self._delta_type == "reasoning": + # Convert accumulated reasoning deltas to ReasoningContent + # Sort by index to maintain order + summary_list = [ + self._reasoning_summaries[i] + for i in sorted(self._reasoning_summaries.keys()) + if self._reasoning_summaries[i] + ] + content_list = [ + self._reasoning_contents[i] + for i in sorted(self._reasoning_contents.keys()) + if self._reasoning_contents[i] + ] + + # Only return reasoning content if we have non-empty summaries or content + if summary_list or content_list: + return ReasoningContent( + author="agent", + summary=summary_list, + content=content_list if content_list else None, + type="reasoning", + style="static", + ) + else: + # Return empty text content instead of empty reasoning + return TextContent( + author="agent", + content="", + ) + else: + raise ValueError(f"Unknown delta type: {self._delta_type}") + + +class StreamingTaskMessageContext: + def __init__( + self, + task_id: str, + initial_content: TaskMessageContent, + agentex_client: AsyncAgentex, + streaming_service: "StreamingService", + streaming_mode: StreamingMode = "coalesced", + created_at: datetime | None = None, + ): + self.task_id = task_id + self.initial_content = initial_content + self.task_message: TaskMessage | None = None + self._agentex_client = agentex_client + self._streaming_service = streaming_service + self._is_closed = False + self._delta_accumulator = DeltaAccumulator() + self._streaming_mode: StreamingMode = streaming_mode + self._buffer: CoalescingBuffer | None = None + self._created_at = created_at + + async def __aenter__(self) -> "StreamingTaskMessageContext": + return await self.open() + + async def __aexit__(self, exc_type, exc_val, exc_tb): + return await self.close() + + async def open(self) -> "StreamingTaskMessageContext": + self._is_closed = False + + self.task_message = await self._agentex_client.messages.create( + task_id=self.task_id, + content=self.initial_content.model_dump(), + streaming_status="IN_PROGRESS", + created_at=self._created_at if self._created_at is not None else omit, + ) + + # Send the START event + start_event = StreamTaskMessageStart( + parent_task_message=self.task_message, + content=self.initial_content, + type="start", + ) + await self._streaming_service.stream_update(start_event) + + if self._streaming_mode == "coalesced": + self._buffer = CoalescingBuffer(on_flush=self._streaming_service.stream_update) + self._buffer.start() + + return self + + async def _reap_buffer(self) -> None: + """Drain and stop the coalescing buffer, releasing its background ticker. + + Idempotent: a no-op once the buffer has already been reaped. + """ + if self._buffer is not None: + await self._buffer.close() + self._buffer = None + + async def close(self) -> TaskMessage: + """Close the streaming context.""" + if not self.task_message: + raise ValueError("Context not properly initialized - no task message") + + # Reap the buffer (stopping its ticker) before the _is_closed + # short-circuit, so a context already marked done by a Full update can't + # leave the ticker orphaned. Draining here also lets consumers see the + # full delta sequence in order before DONE. + await self._reap_buffer() + + if self._is_closed: + return self.task_message # Already done (buffer reaped above) + + # Send the DONE event + done_event = StreamTaskMessageDone( + parent_task_message=self.task_message, + type="done", + ) + await self._streaming_service.stream_update(done_event) + + # Update the task message with the final content + has_deltas = ( + self._delta_accumulator._accumulated_deltas + or self._delta_accumulator._reasoning_summaries + or self._delta_accumulator._reasoning_contents + ) + if has_deltas: + self.task_message.content = self._delta_accumulator.convert_to_content() + + await self._agentex_client.messages.update( + task_id=self.task_id, + message_id=self.task_message.id, + content=self.task_message.content.model_dump(), + streaming_status="DONE", + ) + + # Mark the context as done + self._is_closed = True + return self.task_message + + async def stream_update(self, update: TaskMessageUpdate) -> TaskMessageUpdate | None: + """Stream an update to the repository. + + Behavior depends on the context's ``streaming_mode``: + - "off": delta updates feed the accumulator (so the persisted message + body is correct) but are never published. + - "per_token": delta updates are published immediately. + - "coalesced": delta updates are queued in a 50ms / 128-char window and + flushed as merged batches on a background ticker; the first delta + flushes immediately for fast perceived responsiveness. + + ``StreamTaskMessageDone`` and ``StreamTaskMessageFull`` updates always + publish synchronously regardless of mode so consumers and persistence + stay in sync. + """ + if self._is_closed: + raise ValueError("Context is already done") + + if not self.task_message: + raise ValueError("Context not properly initialized - no task message") + + if isinstance(update, StreamTaskMessageDelta): + if update.delta is not None: + self._delta_accumulator.add_delta(update.delta) + if self._streaming_mode == "off": + return update + if self._streaming_mode == "coalesced" and self._buffer is not None: + await self._buffer.add(update) + return update + + # A Full ends the stream and supersedes buffered deltas. Drain and stop + # the buffer BEFORE publishing the Full, so leftover deltas land in order + # (deltas -> Full) instead of trailing the terminal Full as a stale + # duplicate tail. This also stops the ticker, which would otherwise be + # orphaned when __aexit__'s close() short-circuits on _is_closed. + if isinstance(update, StreamTaskMessageFull): + await self._reap_buffer() + + result = await self._streaming_service.stream_update(update) + + if isinstance(update, StreamTaskMessageDone): + await self.close() + return update + elif isinstance(update, StreamTaskMessageFull): + await self._agentex_client.messages.update( + task_id=self.task_id, + message_id=update.parent_task_message.id, # type: ignore[union-attr] + content=update.content.model_dump(), + streaming_status="DONE", + ) + self._is_closed = True + return result + + +class StreamingService: + def __init__( + self, + agentex_client: AsyncAgentex, + stream_repository: StreamRepository, + ): + self._agentex_client = agentex_client + self._stream_repository = stream_repository + + def streaming_task_message_context( + self, + task_id: str, + initial_content: TaskMessageContent, + streaming_mode: StreamingMode = "coalesced", + created_at: datetime | None = None, + ) -> StreamingTaskMessageContext: + return StreamingTaskMessageContext( + task_id=task_id, + initial_content=initial_content, + agentex_client=self._agentex_client, + streaming_service=self, + streaming_mode=streaming_mode, + created_at=created_at, + ) + + async def stream_update(self, update: TaskMessageUpdate) -> TaskMessageUpdate | None: + """ + Stream an update to the repository. + + Args: + update: The update to stream + + Returns: + True if event was streamed successfully, False otherwise + """ + stream_topic = _get_stream_topic(update.parent_task_message.task_id) # type: ignore[union-attr] + + try: + await self._stream_repository.send_event( + topic=stream_topic, + event=update.model_dump(mode="json"), # type: ignore + ) + return update + except Exception as e: + logger.exception(f"Failed to stream event: {e}") + return None diff --git a/src/agentex/lib/core/services/adk/tasks.py b/src/agentex/lib/core/services/adk/tasks.py new file mode 100644 index 000000000..f1dba08bd --- /dev/null +++ b/src/agentex/lib/core/services/adk/tasks.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +from agentex import AsyncAgentex +from agentex.types.task import Task +from agentex.types.shared import DeleteResponse +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.temporal import heartbeat_if_in_workflow +from agentex.lib.core.tracing.tracer import AsyncTracer +from agentex.types.task_retrieve_response import TaskRetrieveResponse +from agentex.types.task_query_workflow_response import TaskQueryWorkflowResponse +from agentex.types.task_retrieve_by_name_response import TaskRetrieveByNameResponse + +logger = make_logger(__name__) + + +class TasksService: + def __init__( + self, + agentex_client: AsyncAgentex, + tracer: AsyncTracer, + ): + self._agentex_client = agentex_client + self._tracer = tracer + + async def get_task( + self, + task_id: str | None = None, + task_name: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> TaskRetrieveResponse | TaskRetrieveByNameResponse: + trace = self._tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="get_task", + input={"task_id": task_id, "task_name": task_name}, + ) as span: + heartbeat_if_in_workflow("get task") + if not task_id and not task_name: + raise ValueError("Either task_id or task_name must be provided.") + if task_id: + task_model = await self._agentex_client.tasks.retrieve(task_id=task_id) + elif task_name: + task_model = await self._agentex_client.tasks.retrieve_by_name(task_name=task_name) + else: + raise ValueError("Either task_id or task_name must be provided.") + if span: + span.output = task_model.model_dump() + return task_model + + async def delete_task( + self, + task_id: str | None = None, + task_name: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> Task | DeleteResponse: + trace = self._tracer.trace(trace_id) if self._tracer else None + if trace is None: + # Handle case without tracing + response = await self._agentex_client.tasks.delete(task_id) + return Task(**response.model_dump()) + + async with trace.span( + parent_id=parent_span_id, + name="delete_task", + input={"task_id": task_id, "task_name": task_name}, + ) as span: + heartbeat_if_in_workflow("delete task") + if not task_id and not task_name: + raise ValueError("Either task_id or task_name must be provided.") + if task_id: + task_model = await self._agentex_client.tasks.delete(task_id=task_id) + elif task_name: + task_model = await self._agentex_client.tasks.delete_by_name(task_name=task_name) + else: + raise ValueError("Either task_id or task_name must be provided.") + if span: + span.output = task_model.model_dump() + return task_model + + async def cancel_task( + self, + task_id: str, + reason: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> Task: + trace = self._tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="cancel_task", + input={"task_id": task_id, "reason": reason}, + ) as span: + heartbeat_if_in_workflow("cancel task") + task_model = await self._agentex_client.tasks.cancel(task_id=task_id, reason=reason) + if span: + span.output = task_model.model_dump() + return task_model + + async def interrupt_task( + self, + task_id: str, + reason: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> Task: + trace = self._tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="interrupt_task", + input={"task_id": task_id, "reason": reason}, + ) as span: + heartbeat_if_in_workflow("interrupt task") + task_model = await self._agentex_client.tasks.interrupt(task_id=task_id, reason=reason) + if span: + span.output = task_model.model_dump() + return task_model + + async def complete_task( + self, + task_id: str, + reason: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> Task: + trace = self._tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="complete_task", + input={"task_id": task_id, "reason": reason}, + ) as span: + heartbeat_if_in_workflow("complete task") + task_model = await self._agentex_client.tasks.complete(task_id=task_id, reason=reason) + if span: + span.output = task_model.model_dump() + return task_model + + async def fail_task( + self, + task_id: str, + reason: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> Task: + trace = self._tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="fail_task", + input={"task_id": task_id, "reason": reason}, + ) as span: + heartbeat_if_in_workflow("fail task") + task_model = await self._agentex_client.tasks.fail(task_id=task_id, reason=reason) + if span: + span.output = task_model.model_dump() + return task_model + + async def terminate_task( + self, + task_id: str, + reason: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> Task: + trace = self._tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="terminate_task", + input={"task_id": task_id, "reason": reason}, + ) as span: + heartbeat_if_in_workflow("terminate task") + task_model = await self._agentex_client.tasks.terminate(task_id=task_id, reason=reason) + if span: + span.output = task_model.model_dump() + return task_model + + async def timeout_task( + self, + task_id: str, + reason: str | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> Task: + trace = self._tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="timeout_task", + input={"task_id": task_id, "reason": reason}, + ) as span: + heartbeat_if_in_workflow("timeout task") + task_model = await self._agentex_client.tasks.timeout(task_id=task_id, reason=reason) + if span: + span.output = task_model.model_dump() + return task_model + + async def update_task( + self, + task_id: str | None = None, + task_name: str | None = None, + task_metadata: dict[str, object] | None = None, + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> Task: + trace = self._tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="update_task", + input={"task_id": task_id, "task_name": task_name, "task_metadata": task_metadata}, + ) as span: + heartbeat_if_in_workflow("update task") + if not task_id and not task_name: + raise ValueError("Either task_id or task_name must be provided.") + if task_id: + task_model = await self._agentex_client.tasks.update_by_id(task_id=task_id, task_metadata=task_metadata) + elif task_name: + task_model = await self._agentex_client.tasks.update_by_name( + task_name=task_name, task_metadata=task_metadata + ) + else: + raise ValueError("Either task_id or task_name must be provided.") + if span: + span.output = task_model.model_dump() + return task_model + + async def query_workflow( + self, + task_id: str, + query_name: str, + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> TaskQueryWorkflowResponse: + trace = self._tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="query_workflow", + input={"task_id": task_id, "query_name": query_name}, + ) as span: + heartbeat_if_in_workflow("query workflow") + result = await self._agentex_client.tasks.query_workflow(query_name=query_name, task_id=task_id) + if span: + span.output = result + return result diff --git a/src/agentex/lib/core/services/adk/tracing.py b/src/agentex/lib/core/services/adk/tracing.py new file mode 100644 index 000000000..77efffd9e --- /dev/null +++ b/src/agentex/lib/core/services/adk/tracing.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from typing import Any + +from agentex.types.span import Span +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.temporal import heartbeat_if_in_workflow +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.core.tracing.tracer import AsyncTracer + +logger = make_logger(__name__) + + +class TracingService: + def __init__(self, tracer: AsyncTracer): + self._tracer = tracer + + async def start_span( + self, + trace_id: str, + name: str, + parent_id: str | None = None, + input: list[Any] | dict[str, Any] | BaseModel | None = None, + data: list[Any] | dict[str, Any] | BaseModel | None = None, + task_id: str | None = None, + ) -> Span | None: + trace = self._tracer.trace(trace_id) + span = await trace.start_span( + name=name, + parent_id=parent_id, + input=input or {}, + data=data, + task_id=task_id, + ) + heartbeat_if_in_workflow("start span") + return span + + async def end_span(self, trace_id: str, span: Span) -> Span: + trace = self._tracer.trace(trace_id) + await trace.end_span(span) + return span diff --git a/src/agentex/lib/core/services/adk/utils/__init__.py b/src/agentex/lib/core/services/adk/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/core/services/adk/utils/templating.py b/src/agentex/lib/core/services/adk/utils/templating.py new file mode 100644 index 000000000..1cd0ebbfc --- /dev/null +++ b/src/agentex/lib/core/services/adk/utils/templating.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from typing import Any +from datetime import datetime + +from jinja2 import BaseLoader, Environment + +from agentex.lib.utils.temporal import heartbeat_if_in_workflow +from agentex.lib.core.tracing.tracer import AsyncTracer + +# Create a Jinja environment +JINJA_ENV = Environment( + loader=BaseLoader(), + trim_blocks=True, + lstrip_blocks=True, + extensions=["jinja2.ext.do"], +) + + +class TemplatingService: + def __init__(self, tracer: AsyncTracer | None = None): + self.tracer = tracer + + async def render_jinja( + self, + template: str, + variables: dict[str, Any], + trace_id: str | None = None, + parent_span_id: str | None = None, + ) -> str: + """ + Activity that renders a Jinja template with the provided data. + + Args: + template: The template string to render. + variables: The variables to render the template with. + trace_id: The trace ID for tracing. + parent_span_id: The parent span ID for tracing. + + Returns: + The rendered template as a string + """ + if self.tracer is None: + raise RuntimeError("Tracer not initialized - ensure tracer is provided to TemplatingService") + trace = self.tracer.trace(trace_id) + async with trace.span( + parent_id=parent_span_id, + name="render_jinja", + input={"template": template, "variables": variables}, + ) as span: + heartbeat_if_in_workflow("render jinja") + global_variables = { + "datetime": datetime, + } + jinja_template = JINJA_ENV.from_string(template, globals=global_variables) + try: + rendered_template = jinja_template.render(variables) + if span: + span.output = {"jinja_output": rendered_template} + return rendered_template + except Exception as e: + raise ValueError(f"Error rendering Jinja template: {str(e)}") from e diff --git a/src/agentex/lib/core/temporal/__init__.py b/src/agentex/lib/core/temporal/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/core/temporal/activities/__init__.py b/src/agentex/lib/core/temporal/activities/__init__.py new file mode 100644 index 000000000..93e6b69e6 --- /dev/null +++ b/src/agentex/lib/core/temporal/activities/__init__.py @@ -0,0 +1,219 @@ +import httpx +from scale_gp import SGPClient, SGPClientError + +from agentex import AsyncAgentex # noqa: F401 +from agentex.lib.core.tracing import AsyncTracer +from agentex.lib.core.services.adk.state import StateService +from agentex.lib.core.services.adk.tasks import TasksService +from agentex.lib.core.services.adk.events import EventsService +from agentex.lib.adk.utils._modules.client import create_async_agentex_client +from agentex.lib.core.services.adk.acp.acp import ACPService +from agentex.lib.core.services.adk.tracing import TracingService +from agentex.lib.core.services.adk.messages import MessagesService +from agentex.lib.core.services.adk.streaming import StreamingService +from agentex.lib.core.services.adk.providers.sgp import SGPService +from agentex.lib.core.adapters.llm.adapter_litellm import LiteLLMGateway +from agentex.lib.core.services.adk.providers.openai import OpenAIService +from agentex.lib.core.services.adk.utils.templating import TemplatingService +from agentex.lib.core.adapters.streams.adapter_redis import RedisStreamRepository +from agentex.lib.core.services.adk.providers.litellm import LiteLLMService +from agentex.lib.core.services.adk.agent_task_tracker import AgentTaskTrackerService +from agentex.lib.core.temporal.activities.adk.state_activities import StateActivities +from agentex.lib.core.temporal.activities.adk.tasks_activities import TasksActivities +from agentex.lib.core.temporal.activities.adk.events_activities import EventsActivities +from agentex.lib.core.temporal.activities.adk.acp.acp_activities import ACPActivities +from agentex.lib.core.temporal.activities.adk.tracing_activities import TracingActivities +from agentex.lib.core.temporal.activities.adk.messages_activities import MessagesActivities +from agentex.lib.core.temporal.activities.adk.streaming_activities import ( + StreamingActivities, +) +from agentex.lib.core.temporal.activities.adk.providers.sgp_activities import SGPActivities +from agentex.lib.core.temporal.activities.adk.providers.openai_activities import ( + OpenAIActivities, +) +from agentex.lib.core.temporal.activities.adk.utils.templating_activities import ( + TemplatingActivities, +) +from agentex.lib.core.temporal.activities.adk.providers.litellm_activities import ( + LiteLLMActivities, +) +from agentex.lib.core.temporal.activities.adk.agent_task_tracker_activities import ( + AgentTaskTrackerActivities, +) + + +def get_all_activities(sgp_client=None): + """ + Returns a list of all standard activity functions that can be directly passed to worker.run(). + + Args: + sgp_client: Optional SGP client instance. If not provided, SGP activities will not be included. + + Returns: + list: A list of activity functions ready to be passed to worker.run() + """ + # Initialize common dependencies + try: + sgp_client = SGPClient() + except SGPClientError: + sgp_client = None + + llm_gateway = LiteLLMGateway() + stream_repository = RedisStreamRepository() + agentex_client = create_async_agentex_client( + timeout=httpx.Timeout(timeout=1000), + ) + tracer = AsyncTracer(agentex_client) + + # Services + + ## ADK + streaming_service = StreamingService( + agentex_client=agentex_client, + stream_repository=stream_repository, + ) + messages_service = MessagesService( + agentex_client=agentex_client, + streaming_service=streaming_service, + tracer=tracer, + ) + events_service = EventsService( + agentex_client=agentex_client, + tracer=tracer, + ) + agent_task_tracker_service = AgentTaskTrackerService( + agentex_client=agentex_client, + tracer=tracer, + ) + state_service = StateService( + agentex_client=agentex_client, + tracer=tracer, + ) + tasks_service = TasksService( + agentex_client=agentex_client, + tracer=tracer, + ) + tracing_service = TracingService( + tracer=tracer, + ) + + ## ACP + acp_service = ACPService( + agentex_client=agentex_client, + tracer=tracer, + ) + + ## Providers + litellm_service = LiteLLMService( + agentex_client=agentex_client, + llm_gateway=llm_gateway, + streaming_service=streaming_service, + tracer=tracer, + ) + openai_service = OpenAIService( + agentex_client=agentex_client, + streaming_service=streaming_service, + tracer=tracer, + ) + sgp_service = None + if sgp_client is not None: + sgp_service = SGPService( + sgp_client=sgp_client, + tracer=tracer, + ) + + ## Utils + templating_service = TemplatingService( + tracer=tracer, + ) + + # ADK + + ## Core activities + messages_activities = MessagesActivities(messages_service=messages_service) + events_activities = EventsActivities(events_service=events_service) + agent_task_tracker_activities = AgentTaskTrackerActivities( + agent_task_tracker_service=agent_task_tracker_service + ) + state_activities = StateActivities(state_service=state_service) + streaming_activities = StreamingActivities(streaming_service=streaming_service) + tasks_activities = TasksActivities(tasks_service=tasks_service) + tracing_activities = TracingActivities(tracing_service=tracing_service) + + ## ACP + acp_activities = ACPActivities(acp_service=acp_service) + + ## Providers + litellm_activities = LiteLLMActivities(litellm_service=litellm_service) + openai_activities = OpenAIActivities(openai_service=openai_service) + if sgp_client is not None: + sgp_activities = SGPActivities(sgp_service=sgp_service) + else: + sgp_activities = None + + ## Utils + templating_activities = TemplatingActivities(templating_service=templating_service) + + # Build list of standard activities + activities = [ + # Core activities + ## Messages activities + messages_activities.create_message, + messages_activities.update_message, + messages_activities.create_messages_batch, + messages_activities.update_messages_batch, + messages_activities.list_messages, + ## Events activities + events_activities.get_event, + events_activities.list_events, + ## Agent Task Tracker activities + agent_task_tracker_activities.get_agent_task_tracker, + agent_task_tracker_activities.get_agent_task_tracker_by_task_and_agent, + agent_task_tracker_activities.update_agent_task_tracker, + ## State activities + state_activities.create_state, + state_activities.get_state, + state_activities.update_state, + state_activities.delete_state, + ## Streaming activities + streaming_activities.stream_update, + ## Tasks activities + tasks_activities.get_task, + tasks_activities.delete_task, + tasks_activities.cancel_task, + tasks_activities.interrupt_task, + tasks_activities.complete_task, + tasks_activities.fail_task, + tasks_activities.terminate_task, + tasks_activities.timeout_task, + tasks_activities.update_task, + tasks_activities.query_workflow, + ## Tracing activities + tracing_activities.start_span, + tracing_activities.end_span, + # ACP activities + acp_activities.task_create, + acp_activities.message_send, + acp_activities.event_send, + acp_activities.task_cancel, + # Providers + ## LiteLLM activities + litellm_activities.chat_completion, + litellm_activities.chat_completion_auto_send, + litellm_activities.chat_completion_stream_auto_send, + ## OpenAI activities + openai_activities.run_agent, + openai_activities.run_agent_auto_send, + openai_activities.run_agent_streamed_auto_send, + # Utils + templating_activities.render_jinja, + ] + + # SGP activities + if sgp_client is not None: + sgp_all_activities = [ + sgp_activities.download_file_content, # type: ignore[union-attr] + ] + activities.extend(sgp_all_activities) + + return activities diff --git a/src/agentex/lib/core/temporal/activities/activity_helpers.py b/src/agentex/lib/core/temporal/activities/activity_helpers.py new file mode 100644 index 000000000..53ec3a451 --- /dev/null +++ b/src/agentex/lib/core/temporal/activities/activity_helpers.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import Any, TypeVar +from datetime import timedelta + +from pydantic import TypeAdapter +from temporalio import workflow +from temporalio.common import RetryPolicy + +from agentex.lib.utils.model_utils import BaseModel + +T = TypeVar("T", bound="BaseModel") + + +class ActivityHelpers: + @staticmethod + async def execute_activity( + activity_name: str, + request: BaseModel | str | int | float | bool | dict[str, Any] | list[Any], + response_type: Any, + start_to_close_timeout: timedelta | None = None, + heartbeat_timeout: timedelta | None = None, + retry_policy: RetryPolicy | None = None, + ) -> Any: + + response = await workflow.execute_activity( + activity=activity_name, + arg=request, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + heartbeat_timeout=heartbeat_timeout, + ) + + adapter = TypeAdapter(response_type) + return adapter.validate_python(response) diff --git a/src/agentex/lib/core/temporal/activities/adk/__init__.py b/src/agentex/lib/core/temporal/activities/adk/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/core/temporal/activities/adk/acp/__init__.py b/src/agentex/lib/core/temporal/activities/adk/acp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/core/temporal/activities/adk/acp/acp_activities.py b/src/agentex/lib/core/temporal/activities/adk/acp/acp_activities.py new file mode 100644 index 000000000..634892ec5 --- /dev/null +++ b/src/agentex/lib/core/temporal/activities/adk/acp/acp_activities.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any, List + +from temporalio import activity + +from agentex.types.task import Task +from agentex.types.event import Event +from agentex.lib.types.tracing import BaseModelWithTraceParams +from agentex.lib.utils.logging import make_logger +from agentex.types.task_message import TaskMessage +from agentex.types.task_message_content import TaskMessageContent +from agentex.lib.core.services.adk.acp.acp import ACPService + +logger = make_logger(__name__) + + +class ACPActivityName(str, Enum): + TASK_CREATE = "task-create" + MESSAGE_SEND = "message-send" + EVENT_SEND = "event-send" + TASK_CANCEL = "task-cancel" + + +class TaskCreateParams(BaseModelWithTraceParams): + name: str | None = None + agent_id: str | None = None + agent_name: str | None = None + params: dict[str, Any] | None = None + request: dict[str, Any] | None = None + + +class MessageSendParams(BaseModelWithTraceParams): + agent_id: str | None = None + agent_name: str | None = None + task_id: str | None = None + content: TaskMessageContent + request: dict[str, Any] | None = None + + +class EventSendParams(BaseModelWithTraceParams): + agent_id: str | None = None + agent_name: str | None = None + task_id: str | None = None + content: TaskMessageContent + request: dict[str, Any] | None = None + + +class TaskCancelParams(BaseModelWithTraceParams): + task_id: str | None = None + task_name: str | None = None + agent_id: str | None = None + agent_name: str | None = None + request: dict[str, Any] | None = None + + +class ACPActivities: + def __init__(self, acp_service: ACPService): + self._acp_service = acp_service + + @activity.defn(name=ACPActivityName.TASK_CREATE) + async def task_create(self, params: TaskCreateParams) -> Task: + return await self._acp_service.task_create( + name=params.name, + agent_id=params.agent_id, + agent_name=params.agent_name, + params=params.params, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + request=params.request, + ) + + @activity.defn(name=ACPActivityName.MESSAGE_SEND) + async def message_send(self, params: MessageSendParams) -> List[TaskMessage]: + return await self._acp_service.message_send( + agent_id=params.agent_id, + agent_name=params.agent_name, + task_id=params.task_id, + content=params.content, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + request=params.request, + ) + + @activity.defn(name=ACPActivityName.EVENT_SEND) + async def event_send(self, params: EventSendParams) -> Event: + return await self._acp_service.event_send( + agent_id=params.agent_id, + agent_name=params.agent_name, + task_id=params.task_id, + content=params.content, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + request=params.request, + ) + + @activity.defn(name=ACPActivityName.TASK_CANCEL) + async def task_cancel(self, params: TaskCancelParams) -> Task: + return await self._acp_service.task_cancel( + task_id=params.task_id, + task_name=params.task_name, + agent_id=params.agent_id, + agent_name=params.agent_name, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + request=params.request, + ) diff --git a/src/agentex/lib/core/temporal/activities/adk/agent_task_tracker_activities.py b/src/agentex/lib/core/temporal/activities/adk/agent_task_tracker_activities.py new file mode 100644 index 000000000..e20e4dd1d --- /dev/null +++ b/src/agentex/lib/core/temporal/activities/adk/agent_task_tracker_activities.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from enum import Enum + +from temporalio import activity + +from agentex.lib.types.tracing import BaseModelWithTraceParams +from agentex.lib.utils.logging import make_logger +from agentex.types.agent_task_tracker import AgentTaskTracker +from agentex.lib.core.services.adk.agent_task_tracker import AgentTaskTrackerService + +logger = make_logger(__name__) + + +class AgentTaskTrackerActivityName(str, Enum): + GET_AGENT_TASK_TRACKER = "get-agent-task-tracker" + GET_AGENT_TASK_TRACKER_BY_TASK_AND_AGENT = ( + "get-agent-task-tracker-by-task-and-agent" + ) + UPDATE_AGENT_TASK_TRACKER = "update-agent-task-tracker" + + +class GetAgentTaskTrackerParams(BaseModelWithTraceParams): + tracker_id: str + + +class GetAgentTaskTrackerByTaskAndAgentParams(BaseModelWithTraceParams): + task_id: str + agent_id: str + + +class UpdateAgentTaskTrackerParams(BaseModelWithTraceParams): + tracker_id: str + last_processed_event_id: str | None + status: str | None + status_reason: str | None + + +class AgentTaskTrackerActivities: + def __init__(self, agent_task_tracker_service: AgentTaskTrackerService): + self._agent_task_tracker_service = agent_task_tracker_service + + @activity.defn(name=AgentTaskTrackerActivityName.GET_AGENT_TASK_TRACKER) + async def get_agent_task_tracker( + self, params: GetAgentTaskTrackerParams + ) -> AgentTaskTracker: + return await self._agent_task_tracker_service.get_agent_task_tracker( + tracker_id=params.tracker_id, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) + + @activity.defn( + name=AgentTaskTrackerActivityName.GET_AGENT_TASK_TRACKER_BY_TASK_AND_AGENT + ) + async def get_agent_task_tracker_by_task_and_agent( + self, + params: GetAgentTaskTrackerByTaskAndAgentParams, + ) -> AgentTaskTracker | None: + return await self._agent_task_tracker_service.get_by_task_and_agent( + task_id=params.task_id, + agent_id=params.agent_id, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) + + @activity.defn(name=AgentTaskTrackerActivityName.UPDATE_AGENT_TASK_TRACKER) + async def update_agent_task_tracker( + self, params: UpdateAgentTaskTrackerParams + ) -> AgentTaskTracker: + return await self._agent_task_tracker_service.update_agent_task_tracker( + tracker_id=params.tracker_id, + last_processed_event_id=params.last_processed_event_id, + status=params.status, + status_reason=params.status_reason, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) diff --git a/src/agentex/lib/core/temporal/activities/adk/agents_activities.py b/src/agentex/lib/core/temporal/activities/adk/agents_activities.py new file mode 100644 index 000000000..7b7e2b7af --- /dev/null +++ b/src/agentex/lib/core/temporal/activities/adk/agents_activities.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from enum import Enum +from typing import Optional + +from temporalio import activity + +from agentex.types.agent import Agent +from agentex.lib.types.tracing import BaseModelWithTraceParams +from agentex.lib.utils.logging import make_logger +from agentex.lib.core.services.adk.agents import AgentsService + +logger = make_logger(__name__) + + +class AgentsActivityName(str, Enum): + GET_AGENT = "get-agent" + + +class GetAgentParams(BaseModelWithTraceParams): + agent_id: Optional[str] = None + agent_name: Optional[str] = None + + +class AgentsActivities: + def __init__(self, agents_service: AgentsService): + self._agents_service = agents_service + + @activity.defn(name=AgentsActivityName.GET_AGENT) + async def get_agent(self, params: GetAgentParams) -> Agent | None: + return await self._agents_service.get_agent( + agent_id=params.agent_id, + agent_name=params.agent_name, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) + diff --git a/src/agentex/lib/core/temporal/activities/adk/events_activities.py b/src/agentex/lib/core/temporal/activities/adk/events_activities.py new file mode 100644 index 000000000..59d5b3601 --- /dev/null +++ b/src/agentex/lib/core/temporal/activities/adk/events_activities.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from enum import Enum + +from temporalio import activity + +from agentex.types.event import Event +from agentex.lib.types.tracing import BaseModelWithTraceParams +from agentex.lib.utils.logging import make_logger +from agentex.lib.core.services.adk.events import EventsService + +logger = make_logger(__name__) + + +class EventsActivityName(str, Enum): + GET_EVENT = "get-event" + LIST_EVENTS = "list-events" + + +class GetEventParams(BaseModelWithTraceParams): + event_id: str + + +class ListEventsParams(BaseModelWithTraceParams): + task_id: str + agent_id: str + last_processed_event_id: str | None = None + limit: int | None = None + + +class EventsActivities: + def __init__(self, events_service: EventsService): + self._events_service = events_service + + @activity.defn(name=EventsActivityName.GET_EVENT) + async def get_event(self, params: GetEventParams) -> Event | None: + return await self._events_service.get_event( + event_id=params.event_id, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) + + @activity.defn(name=EventsActivityName.LIST_EVENTS) + async def list_events(self, params: ListEventsParams) -> list[Event]: + return await self._events_service.list_events( + task_id=params.task_id, + agent_id=params.agent_id, + last_processed_event_id=params.last_processed_event_id, + limit=params.limit, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) diff --git a/src/agentex/lib/core/temporal/activities/adk/messages_activities.py b/src/agentex/lib/core/temporal/activities/adk/messages_activities.py new file mode 100644 index 000000000..3ae5aaf5b --- /dev/null +++ b/src/agentex/lib/core/temporal/activities/adk/messages_activities.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from enum import Enum +from datetime import datetime + +from temporalio import activity + +from agentex.lib.types.tracing import BaseModelWithTraceParams +from agentex.lib.utils.logging import make_logger +from agentex.types.task_message import TaskMessage +from agentex.types.task_message_content import TaskMessageContent +from agentex.lib.core.services.adk.messages import MessagesService + +logger = make_logger(__name__) + + +class MessagesActivityName(str, Enum): + CREATE_MESSAGE = "create-message" + UPDATE_MESSAGE = "update-message" + CREATE_MESSAGES_BATCH = "create-messages-batch" + UPDATE_MESSAGES_BATCH = "update-messages-batch" + LIST_MESSAGES = "list-messages" + + +class CreateMessageParams(BaseModelWithTraceParams): + task_id: str + content: TaskMessageContent + emit_updates: bool = True + created_at: datetime | None = None + + +class UpdateMessageParams(BaseModelWithTraceParams): + task_id: str + message_id: str + content: TaskMessageContent + + +class CreateMessagesBatchParams(BaseModelWithTraceParams): + task_id: str + contents: list[TaskMessageContent] + emit_updates: bool = True + created_at: datetime | None = None + + +class UpdateMessagesBatchParams(BaseModelWithTraceParams): + task_id: str + updates: dict[str, TaskMessageContent] + + +class ListMessagesParams(BaseModelWithTraceParams): + task_id: str + limit: int | None = None + + +class MessagesActivities: + def __init__(self, messages_service: MessagesService): + self._messages_service = messages_service + + @activity.defn(name=MessagesActivityName.CREATE_MESSAGE) + async def create_message(self, params: CreateMessageParams) -> TaskMessage: + return await self._messages_service.create_message( + task_id=params.task_id, + content=params.content, + emit_updates=params.emit_updates, + created_at=params.created_at, + ) + + @activity.defn(name=MessagesActivityName.UPDATE_MESSAGE) + async def update_message(self, params: UpdateMessageParams) -> TaskMessage: + return await self._messages_service.update_message( + task_id=params.task_id, + message_id=params.message_id, + content=params.content, + ) + + @activity.defn(name=MessagesActivityName.CREATE_MESSAGES_BATCH) + async def create_messages_batch(self, params: CreateMessagesBatchParams) -> list[TaskMessage]: + return await self._messages_service.create_messages_batch( + task_id=params.task_id, + contents=params.contents, + emit_updates=params.emit_updates, + created_at=params.created_at, + ) + + @activity.defn(name=MessagesActivityName.UPDATE_MESSAGES_BATCH) + async def update_messages_batch(self, params: UpdateMessagesBatchParams) -> list[TaskMessage]: + return await self._messages_service.update_messages_batch( + task_id=params.task_id, + updates=params.updates, + ) + + @activity.defn(name=MessagesActivityName.LIST_MESSAGES) + async def list_messages(self, params: ListMessagesParams) -> list[TaskMessage]: + return await self._messages_service.list_messages( + task_id=params.task_id, + limit=params.limit, + ) diff --git a/src/agentex/lib/core/temporal/activities/adk/providers/__init__.py b/src/agentex/lib/core/temporal/activities/adk/providers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/core/temporal/activities/adk/providers/litellm_activities.py b/src/agentex/lib/core/temporal/activities/adk/providers/litellm_activities.py new file mode 100644 index 000000000..d1c052a23 --- /dev/null +++ b/src/agentex/lib/core/temporal/activities/adk/providers/litellm_activities.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from enum import Enum +from datetime import datetime + +from temporalio import activity + +from agentex.lib.utils import logging +from agentex.lib.types.tracing import BaseModelWithTraceParams +from agentex.types.task_message import TaskMessage +from agentex.lib.types.llm_messages import LLMConfig, Completion +from agentex.lib.core.services.adk.providers.litellm import LiteLLMService + +logger = logging.make_logger(__name__) + + +class LiteLLMActivityName(str, Enum): + CHAT_COMPLETION = "chat-completion" + CHAT_COMPLETION_AUTO_SEND = "chat-completion-auto-send" + # Note: CHAT_COMPLETION_STREAM is not supported in Temporal due to generator limitations + CHAT_COMPLETION_STREAM_AUTO_SEND = "chat-completion-stream-auto-send" + + +class ChatCompletionParams(BaseModelWithTraceParams): + llm_config: LLMConfig + + +class ChatCompletionAutoSendParams(BaseModelWithTraceParams): + task_id: str + llm_config: LLMConfig + created_at: datetime | None = None + + +class ChatCompletionStreamAutoSendParams(BaseModelWithTraceParams): + task_id: str + llm_config: LLMConfig + created_at: datetime | None = None + + +class LiteLLMActivities: + def __init__(self, litellm_service: LiteLLMService): + self._litellm_service = litellm_service + + @activity.defn(name=LiteLLMActivityName.CHAT_COMPLETION) + async def chat_completion(self, params: ChatCompletionParams) -> Completion: + return await self._litellm_service.chat_completion( + llm_config=params.llm_config, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) + + @activity.defn(name=LiteLLMActivityName.CHAT_COMPLETION_AUTO_SEND) + async def chat_completion_auto_send(self, params: ChatCompletionAutoSendParams) -> TaskMessage | None: + """ + Activity for non-streaming chat completion with automatic TaskMessage creation. + """ + return await self._litellm_service.chat_completion_auto_send( + task_id=params.task_id, + llm_config=params.llm_config, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + created_at=params.created_at, + ) + + @activity.defn(name=LiteLLMActivityName.CHAT_COMPLETION_STREAM_AUTO_SEND) + async def chat_completion_stream_auto_send(self, params: ChatCompletionStreamAutoSendParams) -> TaskMessage | None: + """ + Activity for streaming chat completion with automatic TaskMessage creation. + """ + return await self._litellm_service.chat_completion_stream_auto_send( + task_id=params.task_id, + llm_config=params.llm_config, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + created_at=params.created_at, + ) diff --git a/src/agentex/lib/core/temporal/activities/adk/providers/openai_activities.py b/src/agentex/lib/core/temporal/activities/adk/providers/openai_activities.py new file mode 100644 index 000000000..5f81b20d5 --- /dev/null +++ b/src/agentex/lib/core/temporal/activities/adk/providers/openai_activities.py @@ -0,0 +1,689 @@ +# Standard library imports +from __future__ import annotations + +import base64 +from enum import Enum +from typing import Any, Literal, Optional +from datetime import datetime +from contextlib import AsyncExitStack, asynccontextmanager +from collections.abc import Callable + +import cloudpickle +from mcp import StdioServerParameters +from agents import RunResult, RunContextWrapper, RunResultStreaming +from pydantic import Field, PrivateAttr +from agents.mcp import MCPServerStdio, MCPServerStdioParams +from temporalio import activity +from agents.tool import ( + ComputerTool as OAIComputerTool, + FunctionTool as OAIFunctionTool, + WebSearchTool as OAIWebSearchTool, + FileSearchTool as OAIFileSearchTool, + LocalShellTool as OAILocalShellTool, + CodeInterpreterTool as OAICodeInterpreterTool, + ImageGenerationTool as OAIImageGenerationTool, +) +from agents.guardrail import InputGuardrail, OutputGuardrail +from agents.exceptions import InputGuardrailTripwireTriggered, OutputGuardrailTripwireTriggered +from agents.model_settings import ModelSettings as OAIModelSettings +from openai.types.shared.reasoning import Reasoning +from openai.types.responses.response_includable import ResponseIncludable + +from agentex.lib.utils import logging + +# Third-party imports +from agentex.lib.types.tracing import BaseModelWithTraceParams + +# Local imports +from agentex.lib.types.agent_results import ( + SerializableRunResult, + SerializableRunResultStreaming, +) +from agentex.lib.core.services.adk.providers.openai import OpenAIService + +logger = logging.make_logger(__name__) + + +class OpenAIActivityName(str, Enum): + """Names of OpenAI agent activities.""" + + RUN_AGENT = "run_agent" + RUN_AGENT_AUTO_SEND = "run_agent_auto_send" + # Note: RUN_AGENT_STREAMED is not supported in Temporal due to generator limitations + RUN_AGENT_STREAMED_AUTO_SEND = "run_agent_streamed_auto_send" + + +class WebSearchTool(BaseModelWithTraceParams): + """Temporal-compatible wrapper for WebSearchTool.""" + + user_location: Optional[dict[str, Any]] = None # UserLocation object + search_context_size: Optional[Literal["low", "medium", "high"]] = "medium" + + def to_oai_function_tool(self) -> OAIWebSearchTool: + kwargs = {} + if self.user_location is not None: + kwargs["user_location"] = self.user_location + if self.search_context_size is not None: + kwargs["search_context_size"] = self.search_context_size + return OAIWebSearchTool(**kwargs) + + +class FileSearchTool(BaseModelWithTraceParams): + """Temporal-compatible wrapper for FileSearchTool.""" + + vector_store_ids: list[str] + max_num_results: Optional[int] = None + include_search_results: bool = False + ranking_options: Optional[dict[str, Any]] = None + filters: Optional[dict[str, Any]] = None + + def to_oai_function_tool(self): + return OAIFileSearchTool( + vector_store_ids=self.vector_store_ids, + max_num_results=self.max_num_results, + include_search_results=self.include_search_results, + ranking_options=self.ranking_options, + filters=self.filters, + ) + + +class ComputerTool(BaseModelWithTraceParams): + """Temporal-compatible wrapper for ComputerTool.""" + + # We need to serialize the computer object and safety check function + computer_serialized: str = Field(default="", description="Serialized computer object") + on_safety_check_serialized: str = Field(default="", description="Serialized safety check function") + + _computer: Any = PrivateAttr() + _on_safety_check: Optional[Callable] = PrivateAttr() + + def __init__( + self, + *, + computer: Any = None, + on_safety_check: Optional[Callable] = None, + **data, + ): + super().__init__(**data) + if computer is not None: + self.computer_serialized = self._serialize_callable(computer) + self._computer = computer + elif self.computer_serialized: + self._computer = self._deserialize_callable(self.computer_serialized) + + if on_safety_check is not None: + self.on_safety_check_serialized = self._serialize_callable(on_safety_check) + self._on_safety_check = on_safety_check + elif self.on_safety_check_serialized: + self._on_safety_check = self._deserialize_callable(self.on_safety_check_serialized) + + @classmethod + def _deserialize_callable(cls, serialized: str) -> Any: + encoded = serialized.encode() + serialized_bytes = base64.b64decode(encoded) + return cloudpickle.loads(serialized_bytes) + + @classmethod + def _serialize_callable(cls, func: Any) -> str: + serialized_bytes = cloudpickle.dumps(func) + encoded = base64.b64encode(serialized_bytes) + return encoded.decode() + + def to_oai_function_tool(self): + return OAIComputerTool( + computer=self._computer, + on_safety_check=self._on_safety_check, + ) + + +class CodeInterpreterTool(BaseModelWithTraceParams): + """Temporal-compatible wrapper for CodeInterpreterTool.""" + + tool_config: dict[str, Any] = Field( + default_factory=lambda: {"type": "code_interpreter"}, description="Tool configuration dict" + ) + + def to_oai_function_tool(self): + return OAICodeInterpreterTool(tool_config=self.tool_config) + + +class ImageGenerationTool(BaseModelWithTraceParams): + """Temporal-compatible wrapper for ImageGenerationTool.""" + + tool_config: dict[str, Any] = Field( + default_factory=lambda: {"type": "image_generation"}, description="Tool configuration dict" + ) + + def to_oai_function_tool(self): + return OAIImageGenerationTool(tool_config=self.tool_config) + + +class LocalShellTool(BaseModelWithTraceParams): + """Temporal-compatible wrapper for LocalShellTool.""" + + executor_serialized: str = Field(default="", description="Serialized LocalShellExecutor object") + + _executor: Any = PrivateAttr() + + def __init__( + self, + *, + executor: Any = None, + **data, + ): + super().__init__(**data) + if executor is not None: + self.executor_serialized = self._serialize_callable(executor) + self._executor = executor + elif self.executor_serialized: + self._executor = self._deserialize_callable(self.executor_serialized) + + @classmethod + def _deserialize_callable(cls, serialized: str) -> Any: + encoded = serialized.encode() + serialized_bytes = base64.b64decode(encoded) + return cloudpickle.loads(serialized_bytes) + + @classmethod + def _serialize_callable(cls, func: Any) -> str: + serialized_bytes = cloudpickle.dumps(func) + encoded = base64.b64encode(serialized_bytes) + return encoded.decode() + + def to_oai_function_tool(self): + return OAILocalShellTool(executor=self._executor) + + +class FunctionTool(BaseModelWithTraceParams): + name: str + description: str + params_json_schema: dict[str, Any] + + strict_json_schema: bool = True + is_enabled: bool = True + + _on_invoke_tool: Callable[[RunContextWrapper, str], Any] = PrivateAttr() + on_invoke_tool_serialized: str = Field( + default="", + description=( + "Normally will be set automatically during initialization and" + " doesn't need to be passed. " + "Instead, pass `on_invoke_tool` to the constructor. " + "See the __init__ method for details." + ), + ) + + def __init__( + self, + *, + on_invoke_tool: Optional[Callable[[RunContextWrapper, str], Any]] = None, + **data, + ): + """ + Initialize a FunctionTool with hacks to support serialization of the + on_invoke_tool callable arg. This is required to facilitate over-the-wire + communication of this object to/from temporal services/workers. + + Args: + on_invoke_tool: The callable to invoke when the tool is called. + **data: Additional data to initialize the FunctionTool. + """ + super().__init__(**data) + if not on_invoke_tool: + if not self.on_invoke_tool_serialized: + raise ValueError("One of `on_invoke_tool` or `on_invoke_tool_serialized` should be set") + else: + on_invoke_tool = self._deserialize_callable(self.on_invoke_tool_serialized) + else: + self.on_invoke_tool_serialized = self._serialize_callable(on_invoke_tool) + + self._on_invoke_tool = on_invoke_tool + + @classmethod + def _deserialize_callable(cls, serialized: str) -> Callable[[RunContextWrapper, str], Any]: + encoded = serialized.encode() + serialized_bytes = base64.b64decode(encoded) + return cloudpickle.loads(serialized_bytes) + + @classmethod + def _serialize_callable(cls, func: Callable) -> str: + serialized_bytes = cloudpickle.dumps(func) + encoded = base64.b64encode(serialized_bytes) + return encoded.decode() + + @property + def on_invoke_tool(self) -> Callable[[RunContextWrapper, str], Any]: + if self._on_invoke_tool is None and self.on_invoke_tool_serialized: + self._on_invoke_tool = self._deserialize_callable(self.on_invoke_tool_serialized) + return self._on_invoke_tool + + @on_invoke_tool.setter + def on_invoke_tool(self, value: Callable[[RunContextWrapper, str], Any]): + self.on_invoke_tool_serialized = self._serialize_callable(value) + self._on_invoke_tool = value + + def to_oai_function_tool(self) -> OAIFunctionTool: + """Convert to OpenAI function tool, excluding serialization fields.""" + # Create a dictionary with only the fields OAIFunctionTool expects + data = self.model_dump( + exclude={ + "trace_id", + "parent_span_id", + "_on_invoke_tool", + "on_invoke_tool_serialized", + } + ) + # Add the callable for OAI tool since properties are not serialized + data["on_invoke_tool"] = self.on_invoke_tool + return OAIFunctionTool(**data) + + +class TemporalInputGuardrail(BaseModelWithTraceParams): + """Temporal-compatible wrapper for InputGuardrail with function + serialization.""" + + name: str + _guardrail_function: Callable = PrivateAttr() + guardrail_function_serialized: str = Field( + default="", + description=( + "Serialized guardrail function. Set automatically during initialization. " + "Pass `guardrail_function` to the constructor instead." + ), + ) + + def __init__( + self, + *, + guardrail_function: Optional[Callable] = None, + **data, + ): + """Initialize with function serialization support for Temporal.""" + super().__init__(**data) + if not guardrail_function: + if not self.guardrail_function_serialized: + raise ValueError("One of `guardrail_function` or `guardrail_function_serialized` should be set") + else: + guardrail_function = self._deserialize_callable(self.guardrail_function_serialized) + else: + self.guardrail_function_serialized = self._serialize_callable(guardrail_function) + + self._guardrail_function = guardrail_function + + @classmethod + def _deserialize_callable(cls, serialized: str) -> Callable: + encoded = serialized.encode() + serialized_bytes = base64.b64decode(encoded) + return cloudpickle.loads(serialized_bytes) + + @classmethod + def _serialize_callable(cls, func: Callable) -> str: + serialized_bytes = cloudpickle.dumps(func) + encoded = base64.b64encode(serialized_bytes) + return encoded.decode() + + @property + def guardrail_function(self) -> Callable: + if self._guardrail_function is None and self.guardrail_function_serialized: + self._guardrail_function = self._deserialize_callable(self.guardrail_function_serialized) + return self._guardrail_function + + @guardrail_function.setter + def guardrail_function(self, value: Callable): + self.guardrail_function_serialized = self._serialize_callable(value) + self._guardrail_function = value + + def to_oai_input_guardrail(self) -> InputGuardrail: + """Convert to OpenAI InputGuardrail.""" + return InputGuardrail(guardrail_function=self.guardrail_function, name=self.name) + + +class TemporalOutputGuardrail(BaseModelWithTraceParams): + """Temporal-compatible wrapper for OutputGuardrail with function + serialization.""" + + name: str + _guardrail_function: Callable = PrivateAttr() + guardrail_function_serialized: str = Field( + default="", + description=( + "Serialized guardrail function. Set automatically during initialization. " + "Pass `guardrail_function` to the constructor instead." + ), + ) + + def __init__( + self, + *, + guardrail_function: Optional[Callable] = None, + **data, + ): + """Initialize with function serialization support for Temporal.""" + super().__init__(**data) + if not guardrail_function: + if not self.guardrail_function_serialized: + raise ValueError("One of `guardrail_function` or `guardrail_function_serialized` should be set") + else: + guardrail_function = self._deserialize_callable(self.guardrail_function_serialized) + else: + self.guardrail_function_serialized = self._serialize_callable(guardrail_function) + + self._guardrail_function = guardrail_function + + @classmethod + def _deserialize_callable(cls, serialized: str) -> Callable: + encoded = serialized.encode() + serialized_bytes = base64.b64decode(encoded) + return cloudpickle.loads(serialized_bytes) + + @classmethod + def _serialize_callable(cls, func: Callable) -> str: + serialized_bytes = cloudpickle.dumps(func) + encoded = base64.b64encode(serialized_bytes) + return encoded.decode() + + @property + def guardrail_function(self) -> Callable: + if self._guardrail_function is None and self.guardrail_function_serialized: + self._guardrail_function = self._deserialize_callable(self.guardrail_function_serialized) + return self._guardrail_function + + @guardrail_function.setter + def guardrail_function(self, value: Callable): + self.guardrail_function_serialized = self._serialize_callable(value) + self._guardrail_function = value + + def to_oai_output_guardrail(self) -> OutputGuardrail: + """Convert to OpenAI OutputGuardrail.""" + return OutputGuardrail(guardrail_function=self.guardrail_function, name=self.name) + + +class ModelSettings(BaseModelWithTraceParams): + temperature: float | None = None + top_p: float | None = None + frequency_penalty: float | None = None + presence_penalty: float | None = None + tool_choice: Literal["auto", "required", "none"] | str | None = None + parallel_tool_calls: bool | None = None + truncation: Literal["auto", "disabled"] | None = None + max_tokens: int | None = None + reasoning: Reasoning | None = None + metadata: dict[str, str] | None = None + store: bool | None = None + include_usage: bool | None = None + response_include: list[ResponseIncludable] | None = None + extra_body: dict[str, str] | None = None + extra_headers: dict[str, str] | None = None + extra_args: dict[str, Any] | None = None + + def to_oai_model_settings(self) -> OAIModelSettings: + return OAIModelSettings(**self.model_dump(exclude=["trace_id", "parent_span_id"])) + + +class RunAgentParams(BaseModelWithTraceParams): + """Parameters for running an agent without streaming.""" + + input_list: list[dict] + mcp_server_params: list[StdioServerParameters] + agent_name: str + agent_instructions: str + handoff_description: str | None = None + handoffs: list["RunAgentParams"] | None = None + model: str | None = None + model_settings: ModelSettings | None = None + tools: ( + list[ + FunctionTool + | WebSearchTool + | FileSearchTool + | ComputerTool + | CodeInterpreterTool + | ImageGenerationTool + | LocalShellTool + ] + | None + ) = None + output_type: Any = None + tool_use_behavior: Literal["run_llm_again", "stop_on_first_tool"] = "run_llm_again" + mcp_timeout_seconds: int | None = None + input_guardrails: list[TemporalInputGuardrail] | None = None + output_guardrails: list[TemporalOutputGuardrail] | None = None + max_turns: int | None = None + previous_response_id: str | None = None + + +class RunAgentAutoSendParams(RunAgentParams): + """Parameters for running an agent with automatic TaskMessage creation.""" + + task_id: str + created_at: datetime | None = None + + +class RunAgentStreamedAutoSendParams(RunAgentParams): + """Parameters for running an agent with streaming and automatic TaskMessage creation.""" + + task_id: str + created_at: datetime | None = None + + +@asynccontextmanager +async def mcp_server_context(mcp_server_params: list[StdioServerParameters]): + """Context manager for MCP servers.""" + servers: list[MCPServerStdio] = [] + for params in mcp_server_params: + server = MCPServerStdio( + name=f"Server: {params.command}", + params=MCPServerStdioParams(**params.model_dump()), + cache_tools_list=True, + client_session_timeout_seconds=60, + ) + servers.append(server) + + async with AsyncExitStack() as stack: + for server in servers: + await stack.enter_async_context(server) + yield servers + + +class OpenAIActivities: + """Activities for OpenAI agent operations.""" + + def __init__(self, openai_service: OpenAIService): + self._openai_service = openai_service + + @activity.defn(name=OpenAIActivityName.RUN_AGENT) + async def run_agent(self, params: RunAgentParams) -> SerializableRunResult: + """Run an agent without streaming or TaskMessage creation.""" + # Convert Temporal guardrails to OpenAI guardrails + input_guardrails = None + if params.input_guardrails: + input_guardrails = [g.to_oai_input_guardrail() for g in params.input_guardrails] + + output_guardrails = None + if params.output_guardrails: + output_guardrails = [g.to_oai_output_guardrail() for g in params.output_guardrails] + + result = await self._openai_service.run_agent( + input_list=params.input_list, + mcp_server_params=params.mcp_server_params, + agent_name=params.agent_name, + agent_instructions=params.agent_instructions, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + handoff_description=params.handoff_description, + handoffs=params.handoffs, + model=params.model, + model_settings=params.model_settings, + tools=params.tools, + output_type=params.output_type, + tool_use_behavior=params.tool_use_behavior, + input_guardrails=input_guardrails, + output_guardrails=output_guardrails, + mcp_timeout_seconds=params.mcp_timeout_seconds, + max_turns=params.max_turns, + previous_response_id=params.previous_response_id, + ) + return self._to_serializable_run_result(result) + + @activity.defn(name=OpenAIActivityName.RUN_AGENT_AUTO_SEND) + async def run_agent_auto_send(self, params: RunAgentAutoSendParams) -> SerializableRunResult: + """Run an agent with automatic TaskMessage creation.""" + # Convert Temporal guardrails to OpenAI guardrails + input_guardrails = None + if params.input_guardrails: + input_guardrails = [g.to_oai_input_guardrail() for g in params.input_guardrails] + + output_guardrails = None + if params.output_guardrails: + output_guardrails = [g.to_oai_output_guardrail() for g in params.output_guardrails] + + try: + result = await self._openai_service.run_agent_auto_send( + task_id=params.task_id, + input_list=params.input_list, + mcp_server_params=params.mcp_server_params, + agent_name=params.agent_name, + agent_instructions=params.agent_instructions, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + handoff_description=params.handoff_description, + handoffs=params.handoffs, + model=params.model, + model_settings=params.model_settings, + tools=params.tools, + output_type=params.output_type, + tool_use_behavior=params.tool_use_behavior, + input_guardrails=input_guardrails, + output_guardrails=output_guardrails, + mcp_timeout_seconds=params.mcp_timeout_seconds, + max_turns=params.max_turns, + previous_response_id=params.previous_response_id, + created_at=params.created_at, + ) + return self._to_serializable_run_result(result) + except InputGuardrailTripwireTriggered as e: + # Handle guardrail trigger gracefully + rejection_message = ( + "I'm sorry, but I cannot process this request due to a guardrail. Please try a different question." + ) + + # Try to extract rejection message from the guardrail result + if hasattr(e, "guardrail_result") and hasattr(e.guardrail_result, "output"): + output_info = getattr(e.guardrail_result.output, "output_info", {}) + if isinstance(output_info, dict) and "rejection_message" in output_info: + rejection_message = output_info["rejection_message"] + + # Build the final input list with the rejection message + final_input_list = list(params.input_list or []) + final_input_list.append({"role": "assistant", "content": rejection_message}) + + return SerializableRunResult(final_output=rejection_message, final_input_list=final_input_list) + except OutputGuardrailTripwireTriggered as e: + # Handle output guardrail trigger gracefully + rejection_message = ( + "I'm sorry, but I cannot provide this response due to a guardrail. Please try a different question." + ) + + # Try to extract rejection message from the guardrail result + if hasattr(e, "guardrail_result") and hasattr(e.guardrail_result, "output"): + output_info = getattr(e.guardrail_result.output, "output_info", {}) + if isinstance(output_info, dict) and "rejection_message" in output_info: + rejection_message = output_info["rejection_message"] + + # Build the final input list with the rejection message + final_input_list = list(params.input_list or []) + final_input_list.append({"role": "assistant", "content": rejection_message}) + + return SerializableRunResult(final_output=rejection_message, final_input_list=final_input_list) + + @activity.defn(name=OpenAIActivityName.RUN_AGENT_STREAMED_AUTO_SEND) + async def run_agent_streamed_auto_send( + self, params: RunAgentStreamedAutoSendParams + ) -> SerializableRunResultStreaming: + """Run an agent with streaming and automatic TaskMessage creation.""" + + # Convert Temporal guardrails to OpenAI guardrails + input_guardrails = None + if params.input_guardrails: + input_guardrails = [g.to_oai_input_guardrail() for g in params.input_guardrails] + + output_guardrails = None + if params.output_guardrails: + output_guardrails = [g.to_oai_output_guardrail() for g in params.output_guardrails] + + try: + result = await self._openai_service.run_agent_streamed_auto_send( + task_id=params.task_id, + input_list=params.input_list, + mcp_server_params=params.mcp_server_params, + agent_name=params.agent_name, + agent_instructions=params.agent_instructions, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + handoff_description=params.handoff_description, + handoffs=params.handoffs, + model=params.model, + model_settings=params.model_settings, + tools=params.tools, + output_type=params.output_type, + tool_use_behavior=params.tool_use_behavior, + input_guardrails=input_guardrails, + output_guardrails=output_guardrails, + mcp_timeout_seconds=params.mcp_timeout_seconds, + max_turns=params.max_turns, + previous_response_id=params.previous_response_id, + created_at=params.created_at, + ) + return self._to_serializable_run_result_streaming(result) + except InputGuardrailTripwireTriggered as e: + # Handle guardrail trigger gracefully + rejection_message = ( + "I'm sorry, but I cannot process this request due to a guardrail. Please try a different question." + ) + + # Try to extract rejection message from the guardrail result + if hasattr(e, "guardrail_result") and hasattr(e.guardrail_result, "output"): + output_info = getattr(e.guardrail_result.output, "output_info", {}) + if isinstance(output_info, dict) and "rejection_message" in output_info: + rejection_message = output_info["rejection_message"] + + # Build the final input list with the rejection message + final_input_list = list(params.input_list or []) + final_input_list.append({"role": "assistant", "content": rejection_message}) + + return SerializableRunResultStreaming(final_output=rejection_message, final_input_list=final_input_list) + except OutputGuardrailTripwireTriggered as e: + # Handle output guardrail trigger gracefully + rejection_message = ( + "I'm sorry, but I cannot provide this response due to a guardrail. Please try a different question." + ) + + # Try to extract rejection message from the guardrail result + if hasattr(e, "guardrail_result") and hasattr(e.guardrail_result, "output"): + output_info = getattr(e.guardrail_result.output, "output_info", {}) + if isinstance(output_info, dict) and "rejection_message" in output_info: + rejection_message = output_info["rejection_message"] + + # Build the final input list with the rejection message + final_input_list = list(params.input_list or []) + final_input_list.append({"role": "assistant", "content": rejection_message}) + + return SerializableRunResultStreaming(final_output=rejection_message, final_input_list=final_input_list) + + @staticmethod + def _to_serializable_run_result(result: RunResult) -> SerializableRunResult: + """Convert RunResult to SerializableRunResult.""" + return SerializableRunResult( + final_output=result.final_output, + final_input_list=result.to_input_list(), + ) + + @staticmethod + def _to_serializable_run_result_streaming( + result: RunResultStreaming, + ) -> SerializableRunResultStreaming: + """Convert RunResultStreaming to SerializableRunResultStreaming.""" + return SerializableRunResultStreaming( + final_output=result.final_output, + final_input_list=result.to_input_list(), + ) diff --git a/src/agentex/lib/core/temporal/activities/adk/providers/sgp_activities.py b/src/agentex/lib/core/temporal/activities/adk/providers/sgp_activities.py new file mode 100644 index 000000000..3905eb166 --- /dev/null +++ b/src/agentex/lib/core/temporal/activities/adk/providers/sgp_activities.py @@ -0,0 +1,42 @@ +from enum import Enum + +from temporalio import activity + +from agentex.lib.types.files import FileContentResponse +from agentex.lib.types.tracing import BaseModelWithTraceParams +from agentex.lib.utils.logging import make_logger +from agentex.lib.core.services.adk.providers.sgp import SGPService + +logger = make_logger(__name__) + + +class SGPActivityName(str, Enum): + DOWNLOAD_FILE_CONTENT = "download-file-content" + + +class DownloadFileParams(BaseModelWithTraceParams): + file_id: str + filename: str + + +class SGPActivities: + def __init__(self, sgp_service: SGPService): + self.sgp_service = sgp_service + + @activity.defn(name=SGPActivityName.DOWNLOAD_FILE_CONTENT) + async def download_file_content(self, params: DownloadFileParams) -> FileContentResponse: + """ + Download file content from SGP. + + Args: + params: DownloadFileParams containing file_id and filename. + + Returns: + FileContentResponse with mime_type and base64_content for constructing LLM input. + """ + return await self.sgp_service.download_file_content( + file_id=params.file_id, + filename=params.filename, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) diff --git a/src/agentex/lib/core/temporal/activities/adk/state_activities.py b/src/agentex/lib/core/temporal/activities/adk/state_activities.py new file mode 100644 index 000000000..4eaf83fb2 --- /dev/null +++ b/src/agentex/lib/core/temporal/activities/adk/state_activities.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any + +from temporalio import activity + +from agentex.types.state import State +from agentex.lib.types.tracing import BaseModelWithTraceParams +from agentex.lib.utils.logging import make_logger +from agentex.lib.core.services.adk.state import StateService + +logger = make_logger(__name__) + + +class StateActivityName(str, Enum): + CREATE_STATE = "create-state" + GET_STATE = "get-state" + UPDATE_STATE = "update-state" + DELETE_STATE = "delete-state" + + +class CreateStateParams(BaseModelWithTraceParams): + task_id: str + agent_id: str + state: dict[str, Any] + + +class GetStateParams(BaseModelWithTraceParams): + state_id: str | None = None + task_id: str | None = None + agent_id: str | None = None + + +class UpdateStateParams(BaseModelWithTraceParams): + state_id: str + task_id: str + agent_id: str + state: dict[str, Any] + + +class DeleteStateParams(BaseModelWithTraceParams): + state_id: str + + +class StateActivities: + def __init__(self, state_service: StateService): + self._state_service = state_service + + @activity.defn(name=StateActivityName.CREATE_STATE) + async def create_state(self, params: CreateStateParams) -> State: + return await self._state_service.create_state( + task_id=params.task_id, + agent_id=params.agent_id, + state=params.state, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) + + @activity.defn(name=StateActivityName.GET_STATE) + async def get_state(self, params: GetStateParams) -> State | None: + return await self._state_service.get_state( + state_id=params.state_id, + task_id=params.task_id, + agent_id=params.agent_id, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) + + @activity.defn(name=StateActivityName.UPDATE_STATE) + async def update_state(self, params: UpdateStateParams) -> State: + return await self._state_service.update_state( + state_id=params.state_id, + task_id=params.task_id, + agent_id=params.agent_id, + state=params.state, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) + + @activity.defn(name=StateActivityName.DELETE_STATE) + async def delete_state(self, params: DeleteStateParams) -> State: + return await self._state_service.delete_state( + state_id=params.state_id, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) diff --git a/src/agentex/lib/core/temporal/activities/adk/streaming_activities.py b/src/agentex/lib/core/temporal/activities/adk/streaming_activities.py new file mode 100644 index 000000000..2d9faf352 --- /dev/null +++ b/src/agentex/lib/core/temporal/activities/adk/streaming_activities.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from enum import Enum + +from temporalio import activity + +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.temporal import heartbeat_if_in_workflow +from agentex.lib.utils.model_utils import BaseModel +from agentex.types.task_message_update import TaskMessageUpdate +from agentex.lib.core.services.adk.streaming import StreamingService + +logger = make_logger(__name__) + + +class StreamingActivityName(str, Enum): + STREAM_UPDATE = "stream-update" + + +class StreamUpdateParams(BaseModel): + update: TaskMessageUpdate + + +class StreamingActivities: + """ + Temporal activities for streaming events to clients (ADK pattern). + """ + + def __init__(self, streaming_service: StreamingService): + self._streaming_service = streaming_service + + @activity.defn(name=StreamingActivityName.STREAM_UPDATE) + async def stream_update(self, params: StreamUpdateParams) -> TaskMessageUpdate | None: + heartbeat_if_in_workflow("stream update") + return await self._streaming_service.stream_update(update=params.update) diff --git a/src/agentex/lib/core/temporal/activities/adk/tasks_activities.py b/src/agentex/lib/core/temporal/activities/adk/tasks_activities.py new file mode 100644 index 000000000..bd1c430c4 --- /dev/null +++ b/src/agentex/lib/core/temporal/activities/adk/tasks_activities.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from enum import Enum + +from temporalio import activity + +from agentex.types.task import Task +from agentex.lib.types.tracing import BaseModelWithTraceParams +from agentex.lib.utils.logging import make_logger +from agentex.lib.core.services.adk.tasks import TasksService +from agentex.types.task_retrieve_response import TaskRetrieveResponse +from agentex.types.task_retrieve_by_name_response import TaskRetrieveByNameResponse + +logger = make_logger(__name__) + + +class TasksActivityName(str, Enum): + GET_TASK = "get-task" + DELETE_TASK = "delete-task" + CANCEL_TASK = "cancel-task" + INTERRUPT_TASK = "interrupt-task" + COMPLETE_TASK = "complete-task" + FAIL_TASK = "fail-task" + TERMINATE_TASK = "terminate-task" + TIMEOUT_TASK = "timeout-task" + UPDATE_TASK = "update-task" + QUERY_WORKFLOW = "query-workflow" + + +class GetTaskParams(BaseModelWithTraceParams): + task_id: str | None = None + task_name: str | None = None + + +class DeleteTaskParams(BaseModelWithTraceParams): + task_id: str | None = None + task_name: str | None = None + + +class TaskStatusTransitionParams(BaseModelWithTraceParams): + task_id: str + reason: str | None = None + + +class UpdateTaskParams(BaseModelWithTraceParams): + task_id: str | None = None + task_name: str | None = None + task_metadata: dict[str, object] | None = None + + +class QueryWorkflowParams(BaseModelWithTraceParams): + task_id: str + query_name: str + + +class TasksActivities: + def __init__(self, tasks_service: TasksService): + self._tasks_service = tasks_service + + @activity.defn(name=TasksActivityName.GET_TASK) + async def get_task(self, params: GetTaskParams) -> TaskRetrieveResponse | TaskRetrieveByNameResponse: + return await self._tasks_service.get_task( + task_id=params.task_id, + task_name=params.task_name, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) + + @activity.defn(name=TasksActivityName.DELETE_TASK) + async def delete_task(self, params: DeleteTaskParams) -> Task: + return await self._tasks_service.delete_task( # type: ignore[return-value] + task_id=params.task_id, + task_name=params.task_name, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) + + @activity.defn(name=TasksActivityName.CANCEL_TASK) + async def cancel_task(self, params: TaskStatusTransitionParams) -> Task: + return await self._tasks_service.cancel_task( + task_id=params.task_id, + reason=params.reason, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) + + @activity.defn(name=TasksActivityName.INTERRUPT_TASK) + async def interrupt_task(self, params: TaskStatusTransitionParams) -> Task: + return await self._tasks_service.interrupt_task( + task_id=params.task_id, + reason=params.reason, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) + + @activity.defn(name=TasksActivityName.COMPLETE_TASK) + async def complete_task(self, params: TaskStatusTransitionParams) -> Task: + return await self._tasks_service.complete_task( + task_id=params.task_id, + reason=params.reason, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) + + @activity.defn(name=TasksActivityName.FAIL_TASK) + async def fail_task(self, params: TaskStatusTransitionParams) -> Task: + return await self._tasks_service.fail_task( + task_id=params.task_id, + reason=params.reason, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) + + @activity.defn(name=TasksActivityName.TERMINATE_TASK) + async def terminate_task(self, params: TaskStatusTransitionParams) -> Task: + return await self._tasks_service.terminate_task( + task_id=params.task_id, + reason=params.reason, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) + + @activity.defn(name=TasksActivityName.TIMEOUT_TASK) + async def timeout_task(self, params: TaskStatusTransitionParams) -> Task: + return await self._tasks_service.timeout_task( + task_id=params.task_id, + reason=params.reason, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) + + @activity.defn(name=TasksActivityName.UPDATE_TASK) + async def update_task(self, params: UpdateTaskParams) -> Task: + return await self._tasks_service.update_task( + task_id=params.task_id, + task_name=params.task_name, + task_metadata=params.task_metadata, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) + + @activity.defn(name=TasksActivityName.QUERY_WORKFLOW) + async def query_workflow(self, params: QueryWorkflowParams) -> dict[str, object]: + return await self._tasks_service.query_workflow( + task_id=params.task_id, + query_name=params.query_name, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) diff --git a/src/agentex/lib/core/temporal/activities/adk/tracing_activities.py b/src/agentex/lib/core/temporal/activities/adk/tracing_activities.py new file mode 100644 index 000000000..aec541afe --- /dev/null +++ b/src/agentex/lib/core/temporal/activities/adk/tracing_activities.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any + +from temporalio import activity + +from agentex.types.span import Span +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.core.services.adk.tracing import TracingService + +logger = make_logger(__name__) + + +class TracingActivityName(str, Enum): + START_SPAN = "start-span" + END_SPAN = "end-span" + + +class StartSpanParams(BaseModel): + trace_id: str + parent_id: str | None = None + name: str + input: list[Any] | dict[str, Any] | BaseModel | None = None + data: list[Any] | dict[str, Any] | BaseModel | None = None + task_id: str | None = None + + +class EndSpanParams(BaseModel): + trace_id: str + span: Span + + +class TracingActivities: + """ + Temporal activities for tracing (spans), ADK pattern. + """ + + def __init__(self, tracing_service: TracingService): + self._tracing_service = tracing_service + + @activity.defn(name=TracingActivityName.START_SPAN) + async def start_span(self, params: StartSpanParams) -> Span | None: + return await self._tracing_service.start_span( + trace_id=params.trace_id, + parent_id=params.parent_id, + name=params.name, + input=params.input, + data=params.data, + task_id=params.task_id, + ) + + @activity.defn(name=TracingActivityName.END_SPAN) + async def end_span(self, params: EndSpanParams) -> Span: + return await self._tracing_service.end_span( + trace_id=params.trace_id, + span=params.span, + ) diff --git a/src/agentex/lib/core/temporal/activities/adk/utils/__init__.py b/src/agentex/lib/core/temporal/activities/adk/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/core/temporal/activities/adk/utils/templating_activities.py b/src/agentex/lib/core/temporal/activities/adk/utils/templating_activities.py new file mode 100644 index 000000000..a2cc4ff10 --- /dev/null +++ b/src/agentex/lib/core/temporal/activities/adk/utils/templating_activities.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any + +from temporalio import activity + +from agentex.lib.types.tracing import BaseModelWithTraceParams +from agentex.lib.core.services.adk.utils.templating import TemplatingService + + +class JinjaActivityName(str, Enum): + RENDER_JINJA = "render-jinja" + + +class RenderJinjaParams(BaseModelWithTraceParams): + """Parameters for the Jinja activity""" + + template: str + variables: dict[str, Any] + + +class TemplatingActivities: + def __init__(self, templating_service: TemplatingService): + self.templating_service = templating_service + + @activity.defn(name=JinjaActivityName.RENDER_JINJA) + async def render_jinja(self, params: RenderJinjaParams) -> str: + """ + Activity that renders a Jinja template with the provided data. + + Args: + params: JinjaParams containing the data and template string + + Returns: + The rendered template as a string + """ + return await self.templating_service.render_jinja( + template=params.template, + variables=params.variables, + trace_id=params.trace_id, + parent_span_id=params.parent_span_id, + ) diff --git a/src/agentex/lib/core/temporal/plugins/__init__.py b/src/agentex/lib/core/temporal/plugins/__init__.py new file mode 100644 index 000000000..4da2e9ca0 --- /dev/null +++ b/src/agentex/lib/core/temporal/plugins/__init__.py @@ -0,0 +1,55 @@ +"""OpenAI Agents SDK Temporal Plugin with Streaming Support. + +This module provides streaming capabilities for the OpenAI Agents SDK in Temporal +using interceptors to thread task_id through workflows to activities. + +The streaming implementation works by: +1. Using Temporal interceptors to thread task_id through the execution +2. Streaming LLM responses to Redis in real-time from activities +3. Returning complete responses to maintain Temporal determinism + +Example: + >>> from agentex.lib.core.temporal.plugins.openai_agents import ( + ... TemporalStreamingModelProvider, + ... ContextInterceptor, + ... ) + >>> from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters + >>> from datetime import timedelta + >>> + >>> # Create streaming model provider + >>> model_provider = TemporalStreamingModelProvider() + >>> + >>> # Create STANDARD plugin with streaming model provider + >>> plugin = OpenAIAgentsPlugin( + ... model_params=ModelActivityParameters( + ... start_to_close_timeout=timedelta(seconds=120), + ... ), + ... model_provider=model_provider, + ... ) + >>> + >>> # Register interceptor with worker + >>> interceptor = ContextInterceptor() + >>> # Add interceptor to worker configuration +""" + +from agentex.lib.core.temporal.plugins.openai_agents import ( + ContextInterceptor, + TemporalStreamingHooks, + TemporalStreamingModel, + TemporalStreamingModelProvider, + streaming_task_id, + streaming_trace_id, + stream_lifecycle_content, + streaming_parent_span_id, +) + +__all__ = [ + "TemporalStreamingModel", + "TemporalStreamingModelProvider", + "ContextInterceptor", + "streaming_task_id", + "streaming_trace_id", + "streaming_parent_span_id", + "TemporalStreamingHooks", + "stream_lifecycle_content", +] diff --git a/src/agentex/lib/core/temporal/plugins/claude_agents/__init__.py b/src/agentex/lib/core/temporal/plugins/claude_agents/__init__.py new file mode 100644 index 000000000..1e9ee694a --- /dev/null +++ b/src/agentex/lib/core/temporal/plugins/claude_agents/__init__.py @@ -0,0 +1,85 @@ +"""Claude Agents SDK integration with Temporal. + +.. deprecated:: + This is the original Claude Code integration: it drives the Python + ``claude-agent-sdk`` directly and hand-rolls its own streaming + tracing + (and does not derive reasoning spans). It is superseded by the unified + harness tap (``agentex.lib.adk.ClaudeCodeTurn`` over the ``claude -p + --output-format stream-json`` CLI stdout, delivered via ``UnifiedEmitter``), + which routes Claude Code through the same canonical ``StreamTaskMessage*`` + stream as every other harness. It still works, but new agents should use the + tap and existing ones should plan to migrate; see + ``adk/docs/migration-0.16.0.md`` for the before/after. + +This plugin provides integration between Claude Agents SDK and AgentEx's +Temporal-based orchestration platform. + +Features: +- Temporal activity wrapper for Claude SDK calls +- Real-time streaming to Redis/UI +- Session resume for conversation context +- Tool call visibility (Read, Write, Bash, etc.) +- Subagent support with nested tracing +- Workspace isolation per task + +Architecture: +- activities.py: Temporal activity definitions +- message_handler.py: Message parsing and streaming logic +- Reuses OpenAI's ContextInterceptor for context threading + +Usage: + from agentex.lib.core.temporal.plugins.claude_agents import ( + run_claude_agent_activity, + create_workspace_directory, + ContextInterceptor, + ) + + # In worker + worker = AgentexWorker( + task_queue=queue_name, + interceptors=[ContextInterceptor()], + ) + + activities = get_all_activities() + activities.extend([run_claude_agent_activity, create_workspace_directory]) + + await worker.run(activities=activities, workflow=YourWorkflow) +""" + +from agentex.lib.core.temporal.plugins.claude_agents.hooks import ( + TemporalStreamingHooks, + create_streaming_hooks, +) +from agentex.lib.core.temporal.plugins.claude_agents.activities import ( + claude_options_to_dict, + run_claude_agent_activity, + create_workspace_directory, +) +from agentex.lib.core.temporal.plugins.claude_agents.message_handler import ( + ClaudeMessageHandler, +) + +# Reuse OpenAI's context threading - this is the key to streaming! +from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ( + ContextInterceptor, + streaming_task_id, + streaming_trace_id, + streaming_parent_span_id, +) + +__all__ = [ + # Activities + "run_claude_agent_activity", + "create_workspace_directory", + "claude_options_to_dict", + # Message handling + "ClaudeMessageHandler", + # Hooks + "create_streaming_hooks", + "TemporalStreamingHooks", + # Context threading (reused from OpenAI) + "ContextInterceptor", + "streaming_task_id", + "streaming_trace_id", + "streaming_parent_span_id", +] diff --git a/src/agentex/lib/core/temporal/plugins/claude_agents/activities.py b/src/agentex/lib/core/temporal/plugins/claude_agents/activities.py new file mode 100644 index 000000000..57313d8a9 --- /dev/null +++ b/src/agentex/lib/core/temporal/plugins/claude_agents/activities.py @@ -0,0 +1,418 @@ +"""Temporal activities for Claude Agents SDK integration. + +Processes all content blocks from the AssistantMessage stream in iteration order +(TextBlock, ThinkingBlock, ToolUseBlock) with correct timestamps. Tool results +come from PostToolUse/PostToolUseFailure hooks which fire between message yields. +""" + +from __future__ import annotations + +import os +import dataclasses +from typing import Any + +from temporalio import activity +from claude_agent_sdk import AgentDefinition, ClaudeSDKClient, ClaudeAgentOptions +from claude_agent_sdk.types import ( + HookEvent, + TextBlock, + HookMatcher, + ToolUseBlock, + ResultMessage, + SystemMessage, + ThinkingBlock, + AssistantMessage, +) + +from agentex.lib import adk +from agentex.types.text_delta import TextDelta +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.types.reasoning_content import ReasoningContent +from agentex.types.task_message_update import StreamTaskMessageFull, StreamTaskMessageDelta +from agentex.types.tool_request_content import ToolRequestContent +from agentex.lib.core.temporal.plugins.claude_agents.hooks.hooks import create_streaming_hooks +from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ( + streaming_task_id, + streaming_trace_id, + streaming_parent_span_id, +) + +logger = make_logger(__name__) + +# Fields that are not serializable across the Temporal boundary and should be +# excluded from claude_options_to_dict output. +_NON_SERIALIZABLE_FIELDS = {"debug_stderr", "stderr", "can_use_tool", "hooks"} + + +def claude_options_to_dict(options: ClaudeAgentOptions) -> dict[str, Any]: + """Convert a ClaudeAgentOptions to a Temporal-serializable dict. + + Use this at the workflow call site so you get full type safety and + autocomplete when constructing options, while Temporal gets a plain dict. + + Non-serializable fields (callbacks, file objects, hooks) are excluded — + the activity injects AgentEx streaming hooks automatically. + + Example:: + + extra = ClaudeAgentOptions( + mcp_servers={"my-server": McpServerConfig(command="npx", args=[...])}, + model="sonnet", + ) + + result = await workflow.execute_activity( + run_claude_agent_activity, + args=[prompt, workspace, tools, "acceptEdits", None, None, None, + claude_options_to_dict(extra)], + ... + ) + """ + result = {} + for field in dataclasses.fields(options): + if field.name in _NON_SERIALIZABLE_FIELDS: + continue + value = getattr(options, field.name) + # Skip fields left at their default to keep the dict minimal + if value == field.default or ( + callable(field.default_factory) and value == field.default_factory() # type: ignore[arg-type] + ): + continue + result[field.name] = value + return result + + +def _reconstruct_agent_defs(agents: dict[str, Any] | None) -> dict[str, AgentDefinition] | None: + """Reconstruct AgentDefinition objects from Temporal-serialized dicts.""" + if not agents: + return None + agent_defs = {} + for name, agent_data in agents.items(): + if isinstance(agent_data, AgentDefinition): + agent_defs[name] = agent_data + else: + agent_defs[name] = AgentDefinition( + description=agent_data.get("description", ""), + prompt=agent_data.get("prompt", ""), + tools=agent_data.get("tools"), + model=agent_data.get("model"), + ) + return agent_defs + + +@activity.defn +async def create_workspace_directory(task_id: str, workspace_root: str | None = None) -> str: + """Create workspace directory for task - runs as Temporal activity + + Args: + task_id: Task ID for workspace directory name + workspace_root: Root directory for workspaces (defaults to .claude-workspace/ in cwd) + + Returns: + Absolute path to created workspace + """ + if workspace_root is None: + # Default to .claude-workspace in current directory + # Follows Claude SDK's .claude/ convention + workspace_root = os.path.join(os.getcwd(), ".claude-workspace") + + workspace_path = os.path.join(workspace_root, task_id) + os.makedirs(workspace_path, exist_ok=True) + logger.info(f"Created workspace: {workspace_path}") + return workspace_path + + +@activity.defn(name="run_claude_agent_activity") +async def run_claude_agent_activity( + prompt: str, + workspace_path: str, + allowed_tools: list[str], + permission_mode: str | None = None, + system_prompt: str | None = None, + resume_session_id: str | None = None, + agents: dict[str, Any] | None = None, + claude_options: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Execute Claude SDK - wrapped in Temporal activity. + + Streams all content block types to the Agentex UI: + - TextBlock → streamed as text deltas (from message stream) + - ThinkingBlock → streamed as ReasoningContent (from message stream) + - ToolUseBlock → streamed as tool_request (from message stream) + - Tool results → streamed as tool_response (from PostToolUse hook) + + Args: + prompt: User message to send to Claude + workspace_path: Directory for file operations (cwd) + allowed_tools: List of tools Claude can use (include "Task" for subagents) + permission_mode: Permission mode (default: acceptEdits) + system_prompt: Optional system prompt override + resume_session_id: Optional session ID to resume conversation context + agents: Optional dict of subagent definitions for Task tool + claude_options: Optional dict of additional ClaudeAgentOptions kwargs. + Any field supported by the Claude SDK can be passed here + (e.g. mcp_servers, model, max_turns, max_budget_usd, etc.). + These are merged with the explicit params above, with explicit + params taking precedence. + + Returns: + dict with "messages", "session_id", "usage", and "cost_usd" keys + """ + + # Get streaming context from ContextVars (set by interceptor) + task_id = streaming_task_id.get() + trace_id = streaming_trace_id.get() + parent_span_id = streaming_parent_span_id.get() + + logger.info( + f"[run_claude_agent_activity] Starting - " + f"task_id={task_id}, workspace={workspace_path}, tools={allowed_tools}, " + f"resume={'YES' if resume_session_id else 'NO (new session)'}, " + f"subagents={list(agents.keys()) if agents else 'NONE'}" + ) + + # Reconstruct AgentDefinition objects from serialized dicts + # Temporal serializes dataclasses to dicts, need to recreate them + agent_defs = _reconstruct_agent_defs(agents) + + # Only include explicit params that were actually supplied (non-None), + # so claude_options values are not masked. + explicit_params: dict[str, Any] = { + k: v + for k, v in { + "cwd": workspace_path, + "allowed_tools": allowed_tools, + "permission_mode": permission_mode, + "system_prompt": system_prompt, + "resume": resume_session_id, + "agents": agent_defs, + }.items() + if v is not None + } + + # Merge in any additional claude_options (explicit params take precedence) + if claude_options: + claude_options = dict(claude_options) # avoid mutating caller's dict + if "agents" in claude_options: + claude_options["agents"] = _reconstruct_agent_defs(claude_options["agents"]) + options_dict = {**claude_options, **explicit_params} + else: + options_dict = explicit_params + + if "permission_mode" not in options_dict: + options_dict["permission_mode"] = "acceptEdits" + + # Shared subagent span tracking — hooks and message-level streaming both use this + subagent_spans: dict[str, Any] = {} + + # PreToolUse: auto-allow permissions + # PostToolUse/PostToolUseFailure: stream tool results (richer than ToolResultBlock) + # Subagent spans tracked for Task tool tracing + activity_hooks: dict[HookEvent, list[HookMatcher]] = create_streaming_hooks( + task_id=task_id, + trace_id=trace_id, + parent_span_id=parent_span_id, + subagent_spans=subagent_spans, + ) + + # Merge with any user-provided hooks from claude_options + user_hooks = options_dict.pop("hooks", None) + if user_hooks: + for event, matchers in user_hooks.items(): + if event in activity_hooks: + activity_hooks[event] = activity_hooks[event] + matchers # type: ignore[operator] + else: + activity_hooks[event] = matchers # type: ignore[assignment] + + options_dict["hooks"] = activity_hooks + options = ClaudeAgentOptions(**options_dict) + + text_streaming_cm: Any = None # the context manager itself + text_streaming_ctx: Any = None # the value returned by __aenter__ + session_id: str | None = None + usage_info: dict[str, Any] | None = None + cost_info: float | None = None + serialized_messages: list[dict[str, Any]] = [] + + async def close_text_stream() -> None: + nonlocal text_streaming_cm, text_streaming_ctx + if text_streaming_ctx and text_streaming_cm: + try: + await text_streaming_cm.__aexit__(None, None, None) + except Exception as e: + logger.warning(f"Failed to close text stream: {e}") + text_streaming_cm = None + text_streaming_ctx = None + + async def ensure_text_stream() -> Any: + nonlocal text_streaming_cm, text_streaming_ctx + if text_streaming_ctx is None and task_id: + text_streaming_cm = adk.streaming.streaming_task_message_context( + task_id=task_id, + initial_content=TextContent(author="agent", content="", format="markdown"), + ) + text_streaming_ctx = await text_streaming_cm.__aenter__() + return text_streaming_ctx + + async def stream_text_delta(text: str) -> None: + if not text: + return + ctx = await ensure_text_stream() + if not ctx: + return + try: + await ctx.stream_update( + StreamTaskMessageDelta( + parent_task_message=ctx.task_message, + delta=TextDelta(type="text", text_delta=text), + type="delta", + ) + ) + except Exception as e: + logger.warning(f"Failed to stream text delta: {e}") + + async def stream_tool_request(block: ToolUseBlock) -> None: + await close_text_stream() + + # Subagent tracing + if block.name == "Task" and trace_id and parent_span_id: + subagent_type = block.input.get("subagent_type", "unknown") + logger.info(f"Subagent started: {subagent_type}") + subagent_ctx = adk.tracing.span( + trace_id=trace_id, + parent_id=parent_span_id, + name=f"Subagent: {subagent_type}", + input=block.input, + ) + subagent_span = await subagent_ctx.__aenter__() + subagent_spans[block.id] = (subagent_ctx, subagent_span) + + if not task_id: + return + try: + async with adk.streaming.streaming_task_message_context( + task_id=task_id, + initial_content=ToolRequestContent( + author="agent", + name=block.name, + arguments=block.input, + tool_call_id=block.id, + ), + ) as ctx: + await ctx.stream_update( + StreamTaskMessageFull( + parent_task_message=ctx.task_message, + content=ToolRequestContent( + author="agent", + name=block.name, + arguments=block.input, + tool_call_id=block.id, + ), + type="full", + ) + ) + except Exception as e: + logger.warning(f"Failed to stream tool request: {e}") + + async def stream_reasoning(block: ThinkingBlock) -> None: + if not task_id or not block.thinking: + return + lines = block.thinking.strip().split("\n", 1) + summary = [lines[0]] + content = ReasoningContent( + author="agent", + summary=summary, + content=[block.thinking], + style="static", + type="reasoning", + ) + try: + async with adk.streaming.streaming_task_message_context( + task_id=task_id, + initial_content=content, + ) as ctx: + await ctx.stream_update( + StreamTaskMessageFull( + parent_task_message=ctx.task_message, + content=content, + type="full", + ) + ) + except Exception as e: + logger.warning(f"Failed to stream reasoning: {e}") + + async def handle_assistant_message(message: AssistantMessage) -> None: + text_parts: list[str] = [] + for block in message.content: + if isinstance(block, TextBlock): + await stream_text_delta(block.text) + if block.text: + text_parts.append(block.text) + + elif isinstance(block, ThinkingBlock): + if block.thinking: + await close_text_stream() + await stream_reasoning(block) + + elif isinstance(block, ToolUseBlock): + await stream_tool_request(block) + + # ToolResultBlock skipped — tool results come from PostToolUse hook + + if text_parts: + serialized_messages.append( + { + "role": "assistant", + "content": "\n".join(text_parts), + } + ) + + async def handle_system_message(message: SystemMessage) -> None: + nonlocal session_id + if message.subtype == "init": + session_id = message.data.get("session_id") + logger.debug(f"Session initialized: {session_id[:16] if session_id else 'unknown'}...") + + async def handle_result_message(message: ResultMessage) -> None: + nonlocal session_id, usage_info, cost_info + usage_info = message.usage + cost_info = message.total_cost_usd + if message.session_id: + session_id = message.session_id + cost_str = f"${cost_info:.4f}" if cost_info is not None else "N/A" + logger.info(f"Cost: {cost_str}, Duration: {message.duration_ms}ms, Turns: {message.num_turns}") + + try: + async with ClaudeSDKClient(options=options) as client: + await client.query(prompt) + async for message in client.receive_response(): + if isinstance(message, AssistantMessage): + await handle_assistant_message(message) + elif isinstance(message, SystemMessage): + await handle_system_message(message) + elif isinstance(message, ResultMessage): + await handle_result_message(message) + + logger.debug("Message loop completed, cleaning up...") + await close_text_stream() + + results = { + "messages": serialized_messages, + "task_id": task_id, + "session_id": session_id, + "usage": usage_info, + "cost_usd": cost_info, + } + logger.debug(f"Returning results with keys: {results.keys()}") + return results + + except Exception as e: + logger.error(f"[run_claude_agent_activity] Error: {e}", exc_info=True) + await close_text_stream() + for _ctx, _span in list(subagent_spans.values()): + try: + await _ctx.__aexit__(None, None, None) + except Exception: + pass + subagent_spans.clear() + raise diff --git a/src/agentex/lib/core/temporal/plugins/claude_agents/hooks/__init__.py b/src/agentex/lib/core/temporal/plugins/claude_agents/hooks/__init__.py new file mode 100644 index 000000000..39c086515 --- /dev/null +++ b/src/agentex/lib/core/temporal/plugins/claude_agents/hooks/__init__.py @@ -0,0 +1,11 @@ +"""Claude SDK hooks for streaming lifecycle events to AgentEx UI.""" + +from agentex.lib.core.temporal.plugins.claude_agents.hooks.hooks import ( + TemporalStreamingHooks, + create_streaming_hooks, +) + +__all__ = [ + "create_streaming_hooks", + "TemporalStreamingHooks", +] diff --git a/src/agentex/lib/core/temporal/plugins/claude_agents/hooks/hooks.py b/src/agentex/lib/core/temporal/plugins/claude_agents/hooks/hooks.py new file mode 100644 index 000000000..71acb0b4b --- /dev/null +++ b/src/agentex/lib/core/temporal/plugins/claude_agents/hooks/hooks.py @@ -0,0 +1,212 @@ +"""Claude SDK hooks for streaming tool calls and subagent execution to AgentEx UI. + +This module provides hook callbacks that integrate with Claude SDK's hooks system +to stream tool execution lifecycle events in real-time. + +Hooks: +- PreToolUse (auto_allow): Auto-allows all tool permissions +- PostToolUse: Streams tool results to the AgentEx UI +- PostToolUseFailure: Streams tool errors to the AgentEx UI +""" + +from __future__ import annotations + +from typing import Any + +from claude_agent_sdk.types import ( + HookEvent, + HookInput, + HookContext, + HookMatcher, + HookJSONOutput, + SyncHookJSONOutput, + PreToolUseHookSpecificOutput, +) + +from agentex.lib import adk +from agentex.lib.utils.logging import make_logger +from agentex.types.task_message_update import StreamTaskMessageFull +from agentex.types.tool_response_content import ToolResponseContent + +logger = make_logger(__name__) + + +class TemporalStreamingHooks: + """Hooks for streaming Claude SDK lifecycle events to AgentEx UI. + + Implements Claude SDK hook callbacks: + - PreToolUse: Auto-allow tool permissions + - PostToolUse: Stream tool result to UI + - PostToolUseFailure: Stream tool error to UI + + Also handles subagent span cleanup for nested tracing. + """ + + def __init__( + self, + task_id: str | None, + trace_id: str | None = None, + parent_span_id: str | None = None, + subagent_spans: dict[str, Any] | None = None, + ): + """Initialize streaming hooks. + + Args: + task_id: AgentEx task ID for routing streams + trace_id: Trace ID for nested spans + parent_span_id: Parent span ID for subagent spans + subagent_spans: Shared dict tracking active subagent spans + (tool_use_id → (ctx, span)). Passed by reference from the + activity so hooks and message-level streaming share state. + """ + self.task_id = task_id + self.trace_id = trace_id + self.parent_span_id = parent_span_id + self.subagent_spans = subagent_spans if subagent_spans is not None else {} + + async def auto_allow_hook( + self, + _input_data: HookInput, + _tool_use_id: str | None, + _context: HookContext, + ) -> HookJSONOutput: + """Hook called before tool execution — auto-allows all tools.""" + return SyncHookJSONOutput( + continue_=True, + hookSpecificOutput=PreToolUseHookSpecificOutput( + hookEventName="PreToolUse", + permissionDecision="allow", + ), + ) + + async def post_tool_use_hook( + self, + input_data: HookInput, + _tool_use_id: str | None, + _context: HookContext, + ) -> HookJSONOutput: + """Hook called after tool execution — streams tool result to UI.""" + _continue = SyncHookJSONOutput(continue_=True) + if input_data["hook_event_name"] != "PostToolUse": + return _continue + + tool_name = input_data["tool_name"] + tool_use_id = input_data["tool_use_id"] + tool_output = input_data.get("tool_response") or input_data.get("tool_output", "") # type: ignore[arg-type] + + logger.info(f"Tool result: {tool_name}") + + # Close subagent span before the task_id guard — spans are opened + # based on trace_id/parent_span_id, not task_id. + if tool_use_id in self.subagent_spans: + subagent_ctx, subagent_span = self.subagent_spans.pop(tool_use_id) + subagent_span.output = {"result": tool_output} + try: + await subagent_ctx.__aexit__(None, None, None) + except Exception as e: + logger.warning(f"Failed to close subagent span: {e}") + + if not self.task_id: + return _continue + + response_content = ToolResponseContent( + author="agent", + name=tool_name, + content=tool_output, + tool_call_id=tool_use_id, + ) + try: + async with adk.streaming.streaming_task_message_context( + task_id=self.task_id, + initial_content=response_content, + ) as ctx: + await ctx.stream_update( + StreamTaskMessageFull( + parent_task_message=ctx.task_message, + content=response_content, + type="full", + ) + ) + except Exception as e: + logger.warning(f"Failed to stream tool response: {e}") + return _continue + + async def post_tool_use_failure_hook( + self, + input_data: HookInput, + _tool_use_id: str | None, + _context: HookContext, + ) -> HookJSONOutput: + """Hook called after tool failure — streams tool error to UI.""" + _continue = SyncHookJSONOutput(continue_=True) + if input_data["hook_event_name"] != "PostToolUseFailure": + return _continue + + tool_name = input_data["tool_name"] + tool_use_id = input_data["tool_use_id"] + error = input_data["error"] + + logger.warning(f"Tool failed: {tool_name} — {error}") + + # Close subagent span before the task_id guard — spans are opened + # based on trace_id/parent_span_id, not task_id. + if tool_use_id in self.subagent_spans: + subagent_ctx, subagent_span = self.subagent_spans.pop(tool_use_id) + subagent_span.output = {"error": error} + try: + await subagent_ctx.__aexit__(None, None, None) + except Exception as e: + logger.warning(f"Failed to close subagent span: {e}") + + if not self.task_id: + return _continue + + response_content = ToolResponseContent( + author="agent", + name=tool_name, + content=f"Error: {error}", + tool_call_id=tool_use_id, + ) + try: + async with adk.streaming.streaming_task_message_context( + task_id=self.task_id, + initial_content=response_content, + ) as ctx: + await ctx.stream_update( + StreamTaskMessageFull( + parent_task_message=ctx.task_message, + content=response_content, + type="full", + ) + ) + except Exception as e: + logger.warning(f"Failed to stream tool failure: {e}") + return _continue + + +def create_streaming_hooks( + task_id: str | None, + trace_id: str | None = None, + parent_span_id: str | None = None, + subagent_spans: dict[str, Any] | None = None, +) -> dict[HookEvent, list[HookMatcher]]: + """Create Claude SDK hooks configuration for streaming. + + Returns hooks dict suitable for ClaudeAgentOptions(hooks=...). + + Args: + task_id: AgentEx task ID for streaming + trace_id: Trace ID for nested spans + parent_span_id: Parent span ID for subagent spans + subagent_spans: Shared dict tracking active subagent spans + + Returns: + Dict with PreToolUse, PostToolUse, and PostToolUseFailure hook configurations + """ + hooks_instance = TemporalStreamingHooks(task_id, trace_id, parent_span_id, subagent_spans) + + return { + "PreToolUse": [HookMatcher(matcher=None, hooks=[hooks_instance.auto_allow_hook])], + "PostToolUse": [HookMatcher(matcher=None, hooks=[hooks_instance.post_tool_use_hook])], + "PostToolUseFailure": [HookMatcher(matcher=None, hooks=[hooks_instance.post_tool_use_failure_hook])], + } diff --git a/src/agentex/lib/core/temporal/plugins/claude_agents/message_handler.py b/src/agentex/lib/core/temporal/plugins/claude_agents/message_handler.py new file mode 100644 index 000000000..c0d414a23 --- /dev/null +++ b/src/agentex/lib/core/temporal/plugins/claude_agents/message_handler.py @@ -0,0 +1,178 @@ +"""Message handling and streaming for Claude Agents SDK. + +Simplified message handler that focuses on: +- Streaming text content to UI +- Extracting session_id for conversation continuity +- Extracting usage and cost information + +Tool requests/responses are handled by Claude SDK hooks (see hooks/hooks.py). +""" + +from __future__ import annotations + +from typing import Any + +from claude_agent_sdk import ( + TextBlock, + ResultMessage, + SystemMessage, + AssistantMessage, +) + +from agentex.lib import adk +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.types.task_message_delta import TextDelta +from agentex.types.task_message_update import StreamTaskMessageDelta + +logger = make_logger(__name__) + + +class ClaudeMessageHandler: + """Handles Claude SDK messages and streams them to AgentEx UI. + + Simplified handler focused on: + - Streaming text blocks to UI + - Extracting session_id from SystemMessage/ResultMessage + - Extracting usage and cost from ResultMessage + - Serializing responses for Temporal + + Note: Tool lifecycle events (requests/responses) are handled by + TemporalStreamingHooks, not this class. + """ + + def __init__( + self, + task_id: str | None, + trace_id: str | None, + parent_span_id: str | None, + ): + self.task_id = task_id + self.trace_id = trace_id + self.parent_span_id = parent_span_id + + # Message tracking + self.messages: list[Any] = [] + self.serialized_messages: list[dict] = [] + + # Streaming context for text + self.streaming_ctx = None + + # Result data + self.session_id: str | None = None + self.usage_info: dict | None = None + self.cost_info: float | None = None + + async def initialize(self): + """Initialize streaming context if task_id is available.""" + if self.task_id: + logger.debug(f"Creating streaming context for task: {self.task_id}") + self.streaming_ctx = await adk.streaming.streaming_task_message_context( + task_id=self.task_id, + initial_content=TextContent( + author="agent", + content="", + format="markdown" + ) + ).__aenter__() + + async def handle_message(self, message: Any): + """Process a single message from Claude SDK.""" + self.messages.append(message) + msg_num = len(self.messages) + + # Debug logging (verbose - only for troubleshooting) + logger.debug(f"📨 [{msg_num}] Message type: {type(message).__name__}") + if isinstance(message, AssistantMessage): + block_types = [type(b).__name__ for b in message.content] + logger.debug(f" [{msg_num}] Content blocks: {block_types}") + + # Route to specific handlers + # Note: Tool requests/responses are handled by hooks, not here! + if isinstance(message, AssistantMessage): + await self._handle_assistant_message(message, msg_num) + elif isinstance(message, SystemMessage): + await self._handle_system_message(message) + elif isinstance(message, ResultMessage): + await self._handle_result_message(message) + + async def _handle_assistant_message(self, message: AssistantMessage, _msg_num: int): + """Handle AssistantMessage - contains text blocks. + + Note: Tool calls (ToolUseBlock/ToolResultBlock) are handled by hooks, not here. + We only process TextBlock for streaming text to UI. + """ + # Stream text blocks to UI + for block in message.content: + if isinstance(block, TextBlock): + await self._handle_text_block(block) + + # Collect text for final response + text_content = [] + for block in message.content: + if isinstance(block, TextBlock): + text_content.append(block.text) + + if text_content: + self.serialized_messages.append({ + "role": "assistant", + "content": "\n".join(text_content) + }) + + async def _handle_text_block(self, block: TextBlock): + """Handle text content block.""" + if not block.text or not self.streaming_ctx: + return + + logger.debug(f"💬 Text block: {block.text[:50]}...") + + delta = TextDelta(type="text", text_delta=block.text) + + try: + await self.streaming_ctx.stream_update( + StreamTaskMessageDelta( + parent_task_message=self.streaming_ctx.task_message, + delta=delta, + type="delta" + ) + ) + except Exception as e: + logger.warning(f"Failed to stream text delta: {e}") + + async def _handle_system_message(self, message: SystemMessage): + """Handle system message - extract session_id.""" + if message.subtype == "init": + self.session_id = message.data.get("session_id") + logger.debug(f"Session initialized: {self.session_id[:16] if self.session_id else 'unknown'}...") + else: + logger.debug(f"SystemMessage: {message.subtype}") + + async def _handle_result_message(self, message: ResultMessage): + """Handle result message - extract usage and cost.""" + self.usage_info = message.usage + self.cost_info = message.total_cost_usd + + # Update session_id if available + if message.session_id: + self.session_id = message.session_id + + logger.info(f"💰 Cost: ${self.cost_info:.4f}, Duration: {message.duration_ms}ms, Turns: {message.num_turns}") + + async def cleanup(self): + """Clean up open streaming contexts.""" + if self.streaming_ctx: + try: + await self.streaming_ctx.close() + logger.debug(f"Closed streaming context") + except Exception as e: + logger.warning(f"Failed to close streaming context: {e}") + + def get_results(self) -> dict[str, Any]: + """Get final results for Temporal.""" + return { + "messages": self.serialized_messages, + "task_id": self.task_id, + "session_id": self.session_id, + "usage": self.usage_info, + "cost_usd": self.cost_info, + } diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/README.md b/src/agentex/lib/core/temporal/plugins/openai_agents/README.md new file mode 100644 index 000000000..5497c4666 --- /dev/null +++ b/src/agentex/lib/core/temporal/plugins/openai_agents/README.md @@ -0,0 +1,750 @@ +# Temporal + OpenAI Agents SDK Streaming Implementation + +## TL;DR + +We use Temporal interceptors to add real-time streaming to Redis/UI while maintaining workflow determinism with the STANDARD OpenAI Agents plugin. The key challenge was threading `task_id` (only known at runtime) through a plugin system initialized at startup. We solved this using Temporal's interceptor pattern to inject task_id into activity headers, making it available via context variables in the model. + +**What we built**: Real-time streaming of LLM responses to users while preserving Temporal's durability guarantees. + +**How**: Interceptors thread task_id → Model reads from context → stream to Redis during activity → return complete response for determinism. + +**The win**: NO forked plugin needed - uses standard `temporalio.contrib.openai_agents.OpenAIAgentsPlugin`! + +## Table of Contents +1. [Background: How OpenAI Agents SDK Works](#background-how-openai-agents-sdk-works) +2. [How Temporal's OpenAI Plugin Works](#how-temporals-openai-plugin-works) +3. [The Streaming Challenge](#the-streaming-challenge) +4. [Our Streaming Solution](#our-streaming-solution) +5. [Implementation Details](#implementation-details) +6. [Usage](#usage) +7. [Drawbacks and Maintenance](#drawbacks-and-maintenance) + +--- + +## Background: How OpenAI Agents SDK Works + +Before diving into Temporal integration, let's understand the basic OpenAI Agents SDK flow: + +```python +# Standard OpenAI Agents SDK usage +agent = Agent( + name="Assistant", + model="gpt-4", + instructions="You are a helpful assistant" +) + +# Under the hood, this happens: +runner = AgentRunner() +result = await runner.run(agent, "Hello") +# ↓ +# runner.run() calls agent.model.get_response() +# ↓ +# model.get_response() makes the actual LLM API call to OpenAI +``` + +The key insight: **`model.get_response()`** is where the actual LLM call happens. + +--- + +## How Temporal's OpenAI Plugin Works + +The Temporal plugin intercepts this flow to make LLM calls durable by converting them into Temporal activities. Here's how: + +### 1. Plugin Setup and Runner Override + +When you create the Temporal plugin and pass it to the worker: + +```python +# In _temporal_openai_agents.py (lines ~72-112) +@contextmanager +def set_open_ai_agent_temporal_overrides(model_params): + # This is the critical line - replaces the default runner! + set_default_agent_runner(TemporalOpenAIRunner(model_params)) +``` + +### 2. Model Interception Chain + +Here's the clever interception that happens: + +``` +Original OpenAI SDK Flow: +┌─────────┐ ┌──────────────┐ ┌───────────────────┐ ┌────────────┐ +│ Agent │ --> │ Runner.run() │ --> │ Model.get_response│ --> │ OpenAI API │ +└─────────┘ └──────────────┘ └───────────────────┘ └────────────┘ + +Temporal Plugin Flow: +┌─────────┐ ┌────────────────────┐ ┌──────────────────────┐ +│ Agent │ --> │ TemporalRunner.run │ --> │ _TemporalModelStub │ +└─────────┘ └────────────────────┘ │ .get_response() │ + └──────────┬───────────┘ + ↓ + ┌──────────────────────┐ + │ Temporal Activity │ + │ "invoke_model_activity"│ + └──────────┬───────────┘ + ↓ + ┌──────────────────────┐ ┌────────────┐ + │ Model.get_response() │ --> │ OpenAI API │ + └──────────────────────┘ └────────────┘ +``` + +### 3. The Model Stub Trick + +The `TemporalOpenAIRunner` replaces the agent's model with `_TemporalModelStub`: + +```python +# In _openai_runner.py +def _convert_agent(agent): + # Replace the model with a stub + new_agent.model = _TemporalModelStub( + model_name=agent.model, + model_params=model_params + ) + return new_agent +``` + +### 4. Activity Creation + +The `_TemporalModelStub` doesn't call the LLM directly. Instead, it creates a Temporal activity: + +```python +# In _temporal_model_stub.py +class _TemporalModelStub: + async def get_response(self, ...): + # Instead of calling the LLM, create an activity! + return await workflow.execute_activity_method( + ModelActivity.invoke_model_activity, # ← This becomes visible in Temporal UI + activity_input, + ... + ) +``` + +### 5. Actual LLM Call in Activity + +Finally, inside the activity, the real LLM call happens: + +```python +# In _invoke_model_activity.py +class ModelActivity: + async def invoke_model_activity(self, input): + model = self._model_provider.get_model(input["model_name"]) + # NOW we actually call the LLM + return await model.get_response(...) # ← Real OpenAI API call +``` + +**Summary**: The plugin intercepts at TWO levels: +1. **Runner level**: Replaces default runner with TemporalRunner +2. **Model level**: Replaces agent.model with _TemporalModelStub that creates activities + +--- + +## The Streaming Challenge + +### Why Temporal Doesn't Support Streaming by Default + +Temporal's philosophy is that activities should be: +- **Idempotent**: Same input → same output +- **Retriable**: Can restart from beginning on failure +- **Deterministic**: Replays produce identical results + +Streaming breaks these guarantees: +- If streaming fails halfway, where do you restart? +- How do you replay a stream deterministically? +- Partial responses violate idempotency + +### Why We Need Streaming Anyway + +For Scale/AgentEx customers, **latency is critical**: +- Time to first token matters more than total generation time +- Users expect to see responses as they're generated +- 10-30 second waits for long responses are unacceptable + +Our pragmatic decision: **Accept the tradeoff**. If streaming fails midway, we restart from the beginning. This may cause a brief UX hiccup but enables the streaming experience users expect. + +--- + +## Our Streaming Solution + +### The Key Insight: Where We Can Hook In + +When we instantiate the OpenAI plugin for Temporal, we can pass in a **model provider**: + +```python +plugin = OpenAIAgentsPlugin( + model_provider=StreamingModelProvider() # ← This is our hook! +) +``` + +**IMPORTANT**: This model provider returns the ACTUAL model that makes the LLM call - this is the final layer, NOT the stub. This is where `model.get_response()` actually calls OpenAI's API. By providing our own model here, we can: + +1. Make the same OpenAI chat completion call with `stream=True` +2. Capture chunks as they arrive +3. Stream them to Redis +4. Still return the complete response for Temporal + +Our `StreamingModel` implementation: +1. **Streams to Redis** using XADD commands +2. **Returns complete response** to maintain Temporal determinism + +### The Task ID Problem + +Here's the critical issue we had to solve: + +``` +Timeline of Execution: +═══════════════════════════════════════════════════════════════════ +Time T0: Application Startup + plugin = CustomStreamingOpenAIAgentsPlugin( + model_provider=StreamingModelProvider() ← No task_id exists yet! + ) + +Time T1: Worker Creation + worker = Worker(plugins=[plugin]) ← Still no task_id! + +Time T2: Worker Starts + await worker.run() ← Still no task_id! + +Time T3: Workflow Receives Request + @workflow.defn + async def on_task_create(params): + task_id = params.task.id ← task_id CREATED HERE! 🎯 + +Time T4: Model Needs to Stream + StreamingModel.get_response(...?) ← Need task_id but how?! +═══════════════════════════════════════════════════════════════════ +``` + +**The problem**: The model provider is configured before we know the task_id, but streaming requires task_id to route to the correct Redis channel. + +### Our Solution: Temporal Interceptors + Context Variables + +Instead of forking the plugin, we use Temporal's interceptor pattern to thread task_id through the system. This elegant solution uses standard Temporal features and requires NO custom plugin components! + +Here's exactly how task_id flows through the interceptor chain: + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ WORKFLOW EXECUTION │ +│ self._task_id = params.task.id <-- Store in instance variable │ +└────────────────────────────┬─────────────────────────────────────┘ + ↓ workflow.instance() +┌──────────────────────────────────────────────────────────────────┐ +│ StreamingWorkflowOutboundInterceptor │ +│ • Reads _task_id from workflow.instance() │ +│ • Injects into activity headers │ +└────────────────────────────┬─────────────────────────────────────┘ + ↓ headers["streaming-task-id"]="abc123" +┌──────────────────────────────────────────────────────────────────┐ +│ STANDARD Temporal Plugin │ +│ • Uses standard TemporalRunner (no fork!) │ +│ • Uses standard TemporalModelStub (no fork!) │ +│ • Creates standard invoke_model_activity │ +└────────────────────────────┬─────────────────────────────────────┘ + ↓ activity with headers +┌──────────────────────────────────────────────────────────────────┐ +│ StreamingActivityInboundInterceptor │ +│ • Extracts task_id from headers │ +│ • Sets streaming_task_id ContextVar │ +└────────────────────────────┬─────────────────────────────────────┘ + ↓ streaming_task_id.set("abc123") +┌──────────────────────────────────────────────────────────────────┐ +│ StreamingModel.get_response() │ +│ • Reads task_id from streaming_task_id.get() │ +│ • Streams chunks to Redis channel: "stream:abc123" │ +│ • Returns complete response for Temporal │ +└──────────────────────────────────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────────────────────────────────┐ +│ REDIS │ +│ XADD stream:abc123 chunk1, chunk2, chunk3... │ +└────────────────────────────┬─────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────────────────────────────────┐ +│ UI SUBSCRIBER │ +│ Reads from stream:abc123 and displays real-time updates │ +└──────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Implementation Details + +### The Interceptor Approach - Clean and Maintainable + +Instead of forking components, we use Temporal's interceptor system. Here's what we built: + +### 1. StreamingInterceptor - The Main Component + +```python +# streaming_interceptor.py +class StreamingInterceptor(Interceptor): + """Main interceptor that enables task_id threading.""" + + def intercept_activity(self, next): + # Create activity interceptor to extract headers + return StreamingActivityInboundInterceptor(next, self._payload_converter) + + def workflow_interceptor_class(self, input): + # Return workflow interceptor class + return StreamingWorkflowInboundInterceptor +``` + +### 2. Task ID Flow - Using Standard Components + +Here's EXACTLY how task_id flows through the system without any forked components: + +#### Step 1: Workflow stores task_id in instance variable +```python +# workflow.py +self._task_id = params.task.id # Store in instance variable +result = await Runner.run(agent, input) # No context needed! +``` + +#### Step 2: Outbound Interceptor injects task_id into headers +```python +# StreamingWorkflowOutboundInterceptor +def start_activity(self, input): + workflow_instance = workflow.instance() + task_id = getattr(workflow_instance, '_task_id', None) + if task_id and "invoke_model_activity" in str(input.activity): + input.headers["streaming-task-id"] = self._payload_converter.to_payload(task_id) +``` + +#### Step 3: Inbound Interceptor extracts from headers and sets context +```python +# StreamingActivityInboundInterceptor +async def execute_activity(self, input): + if input.headers and "streaming-task-id" in input.headers: + task_id = self._payload_converter.from_payload(input.headers["streaming-task-id"], str) + streaming_task_id.set(task_id) # Set ContextVar! +``` + +#### Step 4: StreamingModel reads from context variable +```python +# StreamingModel.get_response() +from agentex.lib.core.temporal.plugins.openai_agents.streaming_interceptor import ( + streaming_task_id, + streaming_trace_id, + streaming_parent_span_id +) + +async def get_response(self, ...): + # Read from ContextVar - set by interceptor! + task_id = streaming_task_id.get() + trace_id = streaming_trace_id.get() + parent_span_id = streaming_parent_span_id.get() + + if task_id: + # Open streaming context to Redis + async with adk.streaming.streaming_task_message_context( + task_id=task_id, + ... + ) as streaming_context: + # Stream tokens as they arrive + ... +``` + +### 3. Worker Configuration - Simply Add the Interceptor + +```python +# run_worker.py +from temporalio.contrib.openai_agents import OpenAIAgentsPlugin # STANDARD! +from agentex.lib.core.temporal.plugins.openai_agents import ( + StreamingInterceptor, + StreamingModelProvider, +) + +# Create the interceptor +interceptor = StreamingInterceptor() + +# Use STANDARD plugin with streaming model provider +plugin = OpenAIAgentsPlugin( + model_provider=StreamingModelProvider(), + model_params=ModelActivityParameters(...) +) + +# Create worker with interceptor +worker = Worker( + client, + task_queue="example_tutorial_queue", + workflows=[ExampleTutorialWorkflow], + activities=[...], + interceptors=[interceptor], # Just add interceptor! +) +``` + +### 4. The Streaming Model - Where Magic Happens + +This is where the actual streaming happens. Our `StreamingModel` is what gets called inside the activity: + +```python +# streaming_model.py +class StreamingModel(Model): + async def get_response(self, ..., task_id=None): + # 1. Open Redis streaming context with task_id + async with adk.streaming.streaming_task_message_context( + task_id=task_id, # ← This creates Redis channel stream:abc123 + initial_content=TextContent(author="agent", content="") + ) as streaming_context: + + # 2. Make OpenAI call WITH STREAMING + stream = await self.client.chat.completions.create( + model=self.model_name, + messages=messages, + stream=True, # ← Enable streaming! + # ... other params ... + ) + + # 3. Process chunks as they arrive + full_content = "" + async for chunk in stream: + if chunk.choices and chunk.choices[0].delta.content: + content = chunk.choices[0].delta.content + full_content += content + + # 4. Stream to Redis (UI sees this immediately!) + delta = TextDelta(type="text", text_delta=content) + update = StreamTaskMessageDelta( + parent_task_message=streaming_context.task_message, + delta=delta, + type="delta" + ) + await streaming_context.stream_update(update) + + # 5. Handle tool calls (sent as complete messages, not streamed) + if tool_calls: + for tool_call_data in tool_calls.values(): + tool_request = ToolRequestContent( + author="agent", + tool_call_id=tool_call_data["id"], + name=tool_call_data["function"]["name"], + arguments=json.loads(tool_call_data["function"]["arguments"]) + ) + + # Tool calls use StreamTaskMessageFull (complete message) + async with adk.streaming.streaming_task_message_context( + task_id=task_id, + initial_content=tool_request + ) as tool_context: + await tool_context.stream_update( + StreamTaskMessageFull( + parent_task_message=tool_context.task_message, + content=tool_request, + type="full" + ) + ) + + # 6. Handle reasoning tokens (o1 models) + if reasoning_content: # For o1 models + reasoning = ReasoningContent( + author="agent", + summary=[reasoning_content], + type="reasoning" + ) + # Stream reasoning as complete message + await stream_reasoning_update(reasoning) + + # 7. Context auto-closes and saves to DB + # The streaming_task_message_context: + # - Accumulates all chunks + # - Saves complete message to database + # - Sends DONE signal to Redis + + # 8. Return complete response for Temporal determinism + return ModelResponse( + output=output_items, # Complete response + usage=usage, + response_id=completion_id + ) +``` + +### 5. Redis and AgentEx Streaming Infrastructure + +Here's what happens under the hood with AgentEx's streaming system: + +#### Redis Implementation Details + +1. **Channel Creation**: `stream:{task_id}` - Each task gets its own Redis stream +2. **XADD Commands**: Each chunk is appended using Redis XADD +3. **Message Types**: + - `StreamTaskMessageDelta`: For text chunks (token by token) + - `StreamTaskMessageFull`: For complete messages (tool calls, reasoning) +4. **Auto-accumulation**: The streaming context accumulates all chunks +5. **Database Persistence**: Complete message saved to DB when context closes +6. **DONE Signal**: Sent to Redis when streaming completes + +#### What Gets Streamed + +```python +# Text content - streamed token by token +await streaming_context.stream_update( + StreamTaskMessageDelta(delta=TextDelta(text_delta=chunk)) +) + +# Tool calls - sent as complete messages +await streaming_context.stream_update( + StreamTaskMessageFull(content=ToolRequestContent(...)) +) + +# Reasoning (o1 models) - sent as complete +await streaming_context.stream_update( + StreamTaskMessageFull(content=ReasoningContent(...)) +) + +# Guardrails - sent as complete +await streaming_context.stream_update( + StreamTaskMessageFull(content=GuardrailContent(...)) +) +``` + +#### UI Subscription + +The frontend subscribes to `stream:{task_id}` and receives: +1. Real-time text chunks as they're generated +2. Complete tool calls when they're ready +3. Reasoning summaries for o1 models +4. DONE signal when complete + +This decoupling means we can stream anything we want through Redis! + +### 6. Workflow Integration + +```python +# workflow.py +@workflow.defn +class ExampleWorkflow: + async def on_task_event_send(self, params): + # Pass task_id through context + context = {"task_id": params.task.id} # ← Critical line! + + runner = get_default_agent_runner() # Gets our StreamingTemporalRunner + result = await runner.run(agent, input, context=context) +``` + +--- + +## Usage + +### Installation + +This plugin is included in the agentex-python package. No additional installation needed. + +### Basic Setup + +```python +from agentex.lib.core.temporal.plugins.openai_agents import ( + CustomStreamingOpenAIAgentsPlugin, + StreamingModelProvider, +) +from temporalio.contrib.openai_agents import ModelActivityParameters +from temporalio.client import Client +from temporalio.worker import Worker +from datetime import timedelta + +# Create streaming model provider +model_provider = StreamingModelProvider() + +# Create plugin with streaming support +plugin = CustomStreamingOpenAIAgentsPlugin( + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(seconds=120), + ), + model_provider=model_provider, +) + +# Use with Temporal client +client = await Client.connect( + "localhost:7233", + plugins=[plugin] +) + +# Create worker with the plugin +worker = Worker( + client, + task_queue="my-task-queue", + workflows=[MyWorkflow], +) +``` + +### In Your Workflow + +```python +from agents import Agent +from agents.run import get_default_agent_runner + +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self, params): + # Create an agent + agent = Agent( + name="Assistant", + instructions="You are a helpful assistant", + model="gpt-4o", + ) + + # Pass task_id through context for streaming + context = {"task_id": params.task.id} + + # Run the agent - streaming happens automatically! + runner = get_default_agent_runner() + result = await runner.run( + agent, + params.event.content, + context=context # task_id enables streaming + ) + + return result.final_output +``` + +### Comparison with Original Temporal Plugin + +| Feature | Original Plugin | Streaming Plugin | +|---------|----------------|------------------| +| **Response Time** | Complete response only (10-30s wait) | Real-time streaming (immediate feedback) | +| **User Experience** | No feedback during generation | See response as it's generated | +| **Task ID Support** | Not supported | Runtime extraction and threading | +| **Activity Name** | `invoke_model_activity` | `invoke_model_activity_streaming` | +| **Model Stub** | `_TemporalModelStub` | `StreamingTemporalModelStub` | +| **Runner** | `TemporalOpenAIRunner` | `StreamingTemporalRunner` | +| **Redis Integration** | None | Full streaming via AgentEx ADK | +| **Temporal Determinism** | ✅ Yes | ✅ Yes (returns complete response) | +| **Replay Safety** | ✅ Yes | ✅ Yes (streaming is side-effect only) | + +--- + +## Benefits of the Interceptor Approach + +### Major Advantages Over Forking + +1. **No Code Duplication**: Uses standard `temporalio.contrib.openai_agents` plugin + - Automatic compatibility with Temporal updates + - No risk of divergence from upstream features + - Zero maintenance of forked code + +2. **Clean Architecture**: + - Interceptors are Temporal's official extension mechanism + - Clear separation between streaming logic and core plugin + - Easy to enable/disable streaming by adding/removing interceptor + +3. **Simplicity**: + - Single interceptor handles all task_id threading + - Uses Python's ContextVar for thread-safe async state + - No need to understand Temporal plugin internals + +### Minimal Limitations + +1. **Streaming Semantics** (unchanged): + - On failure, streaming restarts from beginning (may show duplicate partial content) + - This is acceptable for user experience + +2. **Worker Configuration**: + - Must register interceptor with worker + - Workflow must store task_id in instance variable + +### Future Improvements + +1. **Contribute Back**: + - This pattern could be contributed to Temporal as an example + - Shows how to extend plugins without forking + +2. **Enhanced Features**: + - Could add request/response interceptors for other use cases + - Pattern works for any runtime context threading need + +### Alternative Approaches Considered + +1. **Workflow-level streaming**: Stream directly from workflow (violates determinism) +2. **Separate streaming service**: Additional infrastructure complexity +3. **Polling pattern**: Poor latency characteristics +4. **WebSockets**: Doesn't integrate with existing AgentEx infrastructure + +--- + +## Key Innovation + +The most important innovation is **using interceptors for runtime context threading**. Instead of forking the plugin to pass task_id through custom components, we use Temporal's interceptor system with Python's ContextVar. This allows: + +- One plugin instance for all workflows (standard plugin!) +- Dynamic streaming channels per execution +- Clean separation of concerns +- No forked components to maintain +- Thread-safe async context propagation +- Compatible with all Temporal updates + +--- + +## Troubleshooting + +**No streaming visible in UI:** +- Ensure task_id is passed in the context: `context = {"task_id": params.task.id}` +- Verify Redis is running and accessible +- Check that the UI is subscribed to the correct task channel + +**Import errors:** +- Make sure agentex-python/src is in your Python path +- Install required dependencies: `uv add agentex-sdk openai-agents temporalio` + +**Activity not found:** +- Ensure the plugin is registered with both client and worker +- Check that `invoke_model_activity_streaming` is registered + +--- + +## Testing + +### Running Tests + +The streaming model implementation has comprehensive tests in `tests/test_streaming_model.py` that verify all configurations, tool types, and edge cases. + +#### From Repository Root + +```bash +# Run all tests +rye run pytest src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_streaming_model.py -v + +# Run without parallel execution (more stable) +rye run pytest src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_streaming_model.py -v -n0 + +# Run specific test +rye run pytest src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_streaming_model.py::TestStreamingModelSettings::test_temperature_setting -v +``` + +#### From Test Directory + +```bash +cd src/agentex/lib/core/temporal/plugins/openai_agents/tests + +# Run all tests +rye run pytest test_streaming_model.py -v + +# Run without parallel execution (recommended) +rye run pytest test_streaming_model.py -v -n0 + +# Run specific test class +rye run pytest test_streaming_model.py::TestStreamingModelSettings -v +``` + +#### Test Coverage + +The test suite covers: +- **ModelSettings**: All configuration parameters (temperature, reasoning, truncation, etc.) +- **Tool Types**: Function tools, web search, file search, computer tools, MCP tools, etc. +- **Streaming**: Redis context creation, task ID threading, error handling +- **Edge Cases**: Missing task IDs, multiple computer tools, handoffs + +**Note**: Tests run faster without parallel execution (`-n0` flag) and avoid potential state pollution between test workers. All 29 tests pass individually; parallel execution may show 4-6 intermittent failures due to shared mock state. + +--- + +## Conclusion + +This implementation uses Temporal interceptors to thread task_id through the standard OpenAI plugin to enable real-time streaming while maintaining workflow determinism. The key innovation is using interceptors with Python's ContextVar to propagate runtime context without forking any Temporal components. + +This approach provides the optimal user experience with: +- **Zero code duplication** - uses standard Temporal plugin +- **Minimal maintenance** - only interceptor and streaming model to maintain +- **Clean architecture** - leverages Temporal's official extension mechanism +- **Full compatibility** - works with all Temporal and OpenAI SDK updates + +The interceptor pattern demonstrates how to extend Temporal plugins without forking, setting a precedent for future enhancements. \ No newline at end of file diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/__init__.py b/src/agentex/lib/core/temporal/plugins/openai_agents/__init__.py new file mode 100644 index 000000000..453e27074 --- /dev/null +++ b/src/agentex/lib/core/temporal/plugins/openai_agents/__init__.py @@ -0,0 +1,86 @@ +"""OpenAI Agents SDK Temporal Plugin with Streaming Support. + +This module provides streaming capabilities for the OpenAI Agents SDK in Temporal +using interceptors to thread task_id through workflows to activities. + +The streaming implementation works by: +1. Using Temporal interceptors to thread task_id through the execution +2. Streaming LLM responses to Redis in real-time from activities +3. Streaming lifecycle events (tool calls, handoffs) via hooks and activities +4. Returning complete responses to maintain Temporal determinism + +Example - Complete Setup: + >>> from agentex.lib.core.temporal.plugins.openai_agents import ( + ... StreamingModelProvider, + ... TemporalStreamingHooks, + ... ContextInterceptor, + ... ) + >>> from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters + >>> from datetime import timedelta + >>> from agents import Agent, Runner + >>> + >>> # 1. Create streaming model provider + >>> model_provider = StreamingModelProvider() + >>> + >>> # 2. Create STANDARD plugin with streaming model provider + >>> plugin = OpenAIAgentsPlugin( + ... model_params=ModelActivityParameters( + ... start_to_close_timeout=timedelta(seconds=120), + ... ), + ... model_provider=model_provider, + ... ) + >>> + >>> # 3. Register interceptor with worker + >>> interceptor = ContextInterceptor() + >>> # Add interceptor to worker configuration + >>> + >>> # 4. In workflow, store task_id in instance variable + >>> self._task_id = params.task.id + >>> + >>> # 5. Create hooks for streaming lifecycle events + >>> hooks = TemporalStreamingHooks(task_id="your-task-id") + >>> + >>> # 6. Run agent - interceptor handles task_id threading automatically + >>> result = await Runner.run(agent, input, hooks=hooks) + +This gives you: +- Real-time streaming of LLM responses (via StreamingModel + interceptors) +- Real-time streaming of tool calls (via TemporalStreamingHooks) +- Real-time streaming of agent handoffs (via TemporalStreamingHooks) +- Full Temporal durability and observability +- No forked plugin required - uses standard OpenAIAgentsPlugin +""" + +from agentex.lib.core.temporal.plugins.openai_agents.run import ( + OpenAIAgentsTurnResult, + run_turn, +) +from agentex.lib.core.temporal.plugins.openai_agents.hooks.hooks import ( + TemporalStreamingHooks, +) +from agentex.lib.core.temporal.plugins.openai_agents.hooks.activities import ( + stream_lifecycle_content, +) +from agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model import ( + TemporalStreamingModel, + TemporalStreamingModelProvider, +) +from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ( + ContextInterceptor, + streaming_task_id, + streaming_trace_id, + streaming_parent_span_id, +) + +__all__ = [ + "TemporalStreamingModel", + "TemporalStreamingModelProvider", + "ContextInterceptor", + "streaming_task_id", + "streaming_trace_id", + "streaming_parent_span_id", + "TemporalStreamingHooks", + "stream_lifecycle_content", + "run_turn", + "OpenAIAgentsTurnResult", +] diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/__init__.py b/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/__init__.py new file mode 100644 index 000000000..7a01e3f50 --- /dev/null +++ b/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/__init__.py @@ -0,0 +1,17 @@ +"""Temporal streaming hooks and activities for OpenAI Agents SDK. + +This module provides hooks for streaming agent lifecycle events and +activities for streaming content to the AgentEx UI. +""" + +from agentex.lib.core.temporal.plugins.openai_agents.hooks.hooks import ( + TemporalStreamingHooks, +) +from agentex.lib.core.temporal.plugins.openai_agents.hooks.activities import ( + stream_lifecycle_content, +) + +__all__ = [ + "TemporalStreamingHooks", + "stream_lifecycle_content", +] \ No newline at end of file diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/activities.py b/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/activities.py new file mode 100644 index 000000000..c65ae3c8b --- /dev/null +++ b/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/activities.py @@ -0,0 +1,81 @@ +"""Temporal activities for streaming agent lifecycle events. + +This module provides reusable Temporal activities for streaming content +to the AgentEx UI, designed to work with TemporalStreamingHooks. +""" + +from typing import Any, Dict + +from temporalio import activity + +from agentex.lib import adk +from agentex.types.text_content import TextContent +from agentex.types.task_message_update import StreamTaskMessageFull +from agentex.types.task_message_content import ( + ToolRequestContent, + ToolResponseContent, +) + + +def _deserialize_content(data: Dict[str, Any]): + """Reconstruct the correct content type from a dict using the 'type' discriminator. + + Temporal's payload converter deserializes Union types by trying each variant + in order, which causes ToolResponseContent to be misdeserialized as TextContent + (both have 'author' and 'content' fields). This function uses the 'type' field + to pick the correct Pydantic model. + """ + content_type = data.get("type") + if content_type == "tool_request": + return ToolRequestContent.model_validate(data) + elif content_type == "tool_response": + return ToolResponseContent.model_validate(data) + else: + return TextContent.model_validate(data) + + +@activity.defn(name="stream_lifecycle_content") +async def stream_lifecycle_content( + task_id: str, + content: Dict[str, Any], +) -> None: + """Stream agent lifecycle content to the AgentEx UI. + + This is a universal streaming activity that can handle any type of agent + lifecycle content (text messages, tool requests, tool responses, etc.). + It uses the AgentEx streaming context to send updates to the UI in real-time. + + Designed to work seamlessly with TemporalStreamingHooks. The hooks class + will call this activity automatically when lifecycle events occur. + + Note: The content parameter is a dict (not a typed Union) because Temporal's + payload converter misdeserializes Union types with overlapping fields. + The correct Pydantic model is reconstructed using the 'type' discriminator. + + Args: + task_id: The AgentEx task ID for routing the content to the correct UI session + content: Dict with a 'type' field that determines the content model: + - type="text": TextContent (plain text messages, handoff notifications) + - type="tool_request": ToolRequestContent (tool invocation with call_id) + - type="tool_response": ToolResponseContent (tool result with call_id) + + Note: + This activity is non-blocking and will not throw exceptions to the workflow. + Any streaming errors are logged but do not fail the activity. This ensures + that streaming failures don't break the agent execution. + """ + try: + typed_content = _deserialize_content(content) + async with adk.streaming.streaming_task_message_context( + task_id=task_id, + initial_content=typed_content, + ) as streaming_context: + await streaming_context.stream_update( + StreamTaskMessageFull( + parent_task_message=streaming_context.task_message, + content=typed_content, + type="full", + ) + ) + except Exception as e: + activity.logger.warning(f"Failed to stream content to task {task_id}: {e}") diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/hooks.py b/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/hooks.py new file mode 100644 index 000000000..30d358cc9 --- /dev/null +++ b/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/hooks.py @@ -0,0 +1,395 @@ +"""Temporal streaming hooks for OpenAI Agents SDK lifecycle events. + +This module provides a convenience class for streaming agent lifecycle events +to the AgentEx UI via Temporal activities, and (optionally) tracing tool calls +to SGP with both inputs and outputs. + +Two responsibilities, independently switchable: + +1. UI message emission, split into tool requests / tool responses / handoffs + (each default True). Leave all on for the non-streaming model provider, which + does not emit them itself. When pairing with ``TemporalStreamingModelProvider`` + set ``emit_tool_requests=False`` — that model already streams the tool REQUEST + from the model output, so emitting it here double-posts. But keep + ``emit_tool_responses=True``: the streaming model does NOT emit a function + tool's response, so ``on_tool_end`` is its only source (disabling it makes the + tool-call "done" events vanish). ``run_turn`` wires this correctly for you. + +2. SGP tracing (enabled when ``trace_id`` is provided): opens a span named after + the tool on tool start with the tool ARGUMENTS as its input and closes it on + tool end with the result as its output, parented to ``parent_span_id``. Token + usage metrics are always emitted via ``LLMMetricsHooks`` regardless of these + flags. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any, override +from datetime import timedelta + +from agents import Tool, Agent, RunContextWrapper +from temporalio import workflow +from agents.tool_context import ToolContext + +from agentex.types.text_content import TextContent +from agentex.types.task_message_content import ToolRequestContent, ToolResponseContent +from agentex.lib.core.observability.llm_metrics_hooks import LLMMetricsHooks +from agentex.lib.core.temporal.plugins.openai_agents.hooks.activities import stream_lifecycle_content + +logger = logging.getLogger(__name__) + +# Best-effort tracing budget — a tracing outage must never break a tool call. +_TRACE_TIMEOUT = timedelta(seconds=5) +# Cap tool-result span output so a large payload can't bloat the trace. +_MAX_SPAN_OUTPUT_CHARS = 2000 + + +def _get_adk() -> Any: + """Lazily import the adk facade for workflow-safe tracing. + + Kept lazy (not a module-level import) so this core hooks module does not pull + the full adk surface — and its optional deps — at import time. Only invoked + when a tool span is actually created (i.e. when ``trace_id`` is set). + """ + from agentex.lib import adk + + return adk + + +class TemporalStreamingHooks(LLMMetricsHooks): + """Convenience hooks class for streaming OpenAI Agent lifecycle events to the AgentEx UI. + + This class automatically streams agent lifecycle events (tool calls, handoffs) to the + AgentEx UI via Temporal activities. It subclasses the OpenAI Agents SDK's RunHooks + to intercept lifecycle events and forward them for real-time UI updates. + + Lifecycle events streamed (each gated by its own flag, all default True): + - Tool requests (on_tool_start, ``emit_tool_requests``): when a tool is invoked + - Tool responses (on_tool_end, ``emit_tool_responses``): the tool's result + - Agent handoffs (on_handoff, ``emit_handoffs``): when control transfers + + Tracing (when ``trace_id`` is provided): + - One SGP span per tool call, named after the tool, with the tool + arguments as the span input and the tool result as the span output. + + Usage: + Basic usage - streams all lifecycle events:: + + from agentex.lib.core.temporal.plugins.openai_agents import TemporalStreamingHooks + + hooks = TemporalStreamingHooks(task_id="abc123") + result = await Runner.run(agent, input, hooks=hooks) + + Paired with the streaming model provider (it already streams the tool + REQUEST, so suppress that here — but keep responses, which the model does + not emit). Prefer ``run_turn`` which wires this for you:: + + hooks = TemporalStreamingHooks( + task_id="abc123", + emit_tool_requests=False, + emit_tool_responses=True, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + + Advanced - subclass for custom behavior:: + + class MyCustomHooks(TemporalStreamingHooks): + async def on_tool_start(self, context, agent, tool): + # Add custom logic before streaming + await self.my_custom_logging(tool) + # Call parent to stream to UI + await super().on_tool_start(context, agent, tool) + + async def on_agent_start(self, context, agent): + # Override empty methods for additional tracking + print(f"Agent {agent.name} started") + + Power users can ignore this class and subclass agents.RunHooks directly for full control. + + Note: + Tool arguments are extracted from the ToolContext's tool_arguments field, + which contains a JSON string of the arguments passed to the tool. + + Attributes: + task_id: The AgentEx task ID for routing streamed events + timeout: Timeout for streaming activity calls (default: 10 seconds) + emit_tool_requests: Whether to stream the ToolRequestContent on tool start + emit_tool_responses: Whether to stream the ToolResponseContent on tool end + emit_handoffs: Whether to stream the handoff text message + trace_id: When set, tool calls are traced to SGP (input + output) + parent_span_id: Parent span for the per-tool spans + """ + + def __init__( + self, + task_id: str, + timeout: timedelta = timedelta(seconds=10), + *, + emit_tool_requests: bool = True, + emit_tool_responses: bool = True, + emit_handoffs: bool = True, + trace_id: str | None = None, + parent_span_id: str | None = None, + ): + """Initialize the streaming hooks. + + Request and response emission are independently switchable because the + ``TemporalStreamingModelProvider`` emits a function tool's REQUEST from + the model output but NOT its response — the function result only ever + surfaces here via ``on_tool_end``. So when pairing with that provider, + set ``emit_tool_requests=False`` (the model already posted the request) + but keep ``emit_tool_responses=True`` (otherwise the tool-call "done" + events disappear). ``run_turn`` wires this correctly for you. + + Args: + task_id: AgentEx task ID for routing streamed events to the correct UI session + timeout: Timeout for streaming activity invocations (default: 10 seconds) + emit_tool_requests: When True (default) stream a ToolRequestContent on + tool start. Set False when a streaming model provider already + emits the request, to avoid double-posting it. + emit_tool_responses: When True (default) stream a ToolResponseContent + on tool end. Keep True with the streaming model provider — it does + NOT emit function-tool responses, so this is their only source. + emit_handoffs: When True (default) stream a handoff text message. + trace_id: When provided, open an SGP span per tool call (named after + the tool) with the arguments as input and the result as output. When None, + no tool spans are created (token-usage metrics still emit). + parent_span_id: Parent span id the per-tool spans attach to. + """ + super().__init__() + self.task_id = task_id + self.timeout = timeout + self.emit_tool_requests = emit_tool_requests + self.emit_tool_responses = emit_tool_responses + self.emit_handoffs = emit_handoffs + self.trace_id = trace_id + self.parent_span_id = parent_span_id + # tool_call_id -> open SGP span, so on_tool_end closes the right one. + self._tool_spans: dict[str, Any] = {} + + @staticmethod + def _tool_call_id(context: RunContextWrapper, tool: Tool) -> str: + tool_context = context if isinstance(context, ToolContext) else None + return getattr(tool_context, "tool_call_id", None) or f"call_{id(tool)}" + + @staticmethod + def _parse_tool_arguments(context: RunContextWrapper) -> dict[str, Any]: + """Parse the JSON ``tool_arguments`` off a ToolContext into a dict. + + Returns an empty dict for a non-ToolContext or unparseable arguments — + a tool call must never fail because its args could not be displayed. + """ + tool_context = context if isinstance(context, ToolContext) else None + raw = getattr(tool_context, "tool_arguments", None) + if not raw: + return {} + try: + parsed = json.loads(raw) + except (json.JSONDecodeError, TypeError): + logger.warning(f"Failed to parse tool arguments: {raw!r}") + return {} + return parsed if isinstance(parsed, dict) else {"value": parsed} + + @override + async def on_agent_start(self, context: RunContextWrapper, agent: Agent) -> None: # noqa: ARG002 + """Called when an agent starts execution. + + Default implementation logs the event. Override to add custom behavior. + + Args: + context: The run context wrapper + agent: The agent that is starting + """ + logger.debug(f"[TemporalStreamingHooks] Agent '{agent.name}' started execution") + + @override + async def on_agent_end(self, context: RunContextWrapper, agent: Agent, output: Any) -> None: # noqa: ARG002 + """Called when an agent completes execution. + + Default implementation logs the event. Override to add custom behavior. + + Args: + context: The run context wrapper + agent: The agent that completed + output: The agent's output + """ + logger.debug( + f"[TemporalStreamingHooks] Agent '{agent.name}' completed execution with output type: {type(output).__name__}" + ) + + @override + async def on_tool_start(self, context: RunContextWrapper, agent: Agent, tool: Tool) -> None: # noqa: ARG002 + """Stream the tool request (optional) and open a traced span (optional). + + Streams a ToolRequestContent message when ``emit_tool_requests`` is True, + and opens an SGP span named after the tool (input = arguments) when + ``trace_id`` is set. Both read the same parsed arguments. + + Args: + context: The run context wrapper (a ToolContext with tool_call_id and tool_arguments) + agent: The agent executing the tool + tool: The tool being executed + """ + tool_call_id = self._tool_call_id(context, tool) + tool_arguments = self._parse_tool_arguments(context) + + if self.emit_tool_requests: + await workflow.execute_activity( + stream_lifecycle_content, + args=[ + self.task_id, + ToolRequestContent( + author="agent", + tool_call_id=tool_call_id, + name=tool.name, + arguments=tool_arguments, + ).model_dump(), + ], + start_to_close_timeout=self.timeout, + ) + + await self._maybe_start_tool_span(tool_call_id, tool.name, tool_arguments) + + @override + async def on_tool_end( + self, + context: RunContextWrapper, + agent: Agent, # noqa: ARG002 + tool: Tool, + result: str, + ) -> None: + """Stream the tool response (optional) and close the traced span (optional). + + Streams a ToolResponseContent message when ``emit_tool_responses`` is True, + and closes the matching tool span (output = result) when one was + opened in on_tool_start. + + Args: + context: The run context wrapper (a ToolContext with tool_call_id) + agent: The agent that executed the tool + tool: The tool that was executed + result: The tool's execution result + """ + tool_call_id = self._tool_call_id(context, tool) + + if self.emit_tool_responses: + await workflow.execute_activity( + stream_lifecycle_content, + args=[ + self.task_id, + ToolResponseContent( + author="agent", + tool_call_id=tool_call_id, + name=tool.name, + content=result, + ).model_dump(), + ], + start_to_close_timeout=self.timeout, + ) + + await self._maybe_end_tool_span(tool_call_id, result) + + @override + async def on_handoff( + self, + context: RunContextWrapper, + from_agent: Agent, + to_agent: Agent, # noqa: ARG002 + ) -> None: + """Stream handoff message when control transfers between agents. + + Sends a text message to the UI indicating that one agent is handing off + to another agent. No-op when ``emit_handoffs`` is False. + + Args: + context: The run context wrapper + from_agent: The agent transferring control + to_agent: The agent receiving control + """ + if not self.emit_handoffs: + return + await workflow.execute_activity( + stream_lifecycle_content, + args=[ + self.task_id, + TextContent( + author="agent", + content=f"Handoff from {from_agent.name} to {to_agent.name}", + type="text", + ).model_dump(), + ], + start_to_close_timeout=self.timeout, + ) + + async def _maybe_start_tool_span(self, tool_call_id: str, tool_name: str, arguments: dict[str, Any]) -> None: + """Open a span named after the tool with the arguments as input. + + The span name is the bare ``tool_name`` (no prefix) to match the shared + unified-harness span reducer (``core/harness/span_derivation.py``), so + OpenAI Temporal traces look the same as every other harness. + + Best-effort: tracing must never break a tool call, so any failure is + logged and swallowed. No-op when ``trace_id`` is not set. + """ + if not self.trace_id: + return + try: + span = await _get_adk().tracing.start_span( + trace_id=self.trace_id, + parent_id=self.parent_span_id, + name=tool_name, + input={"arguments": arguments}, + start_to_close_timeout=_TRACE_TIMEOUT, + ) + if span is not None: + self._tool_spans[tool_call_id] = span + except Exception as e: # noqa: BLE001 - tracing is best-effort + logger.warning(f"[tracing] tool start_span failed (non-fatal): {e}") + + async def _maybe_end_tool_span(self, tool_call_id: str, result: Any) -> None: + """Close the span opened for ``tool_call_id`` with the result as output.""" + span = self._tool_spans.pop(tool_call_id, None) + if span is None or not self.trace_id: + return + try: + span.output = {"result": str(result)[:_MAX_SPAN_OUTPUT_CHARS]} + await _get_adk().tracing.end_span( + trace_id=self.trace_id, + span=span, + start_to_close_timeout=_TRACE_TIMEOUT, + ) + except Exception as e: # noqa: BLE001 - tracing is best-effort + logger.warning(f"[tracing] tool end_span failed (non-fatal): {e}") + + async def close_open_tool_spans(self) -> None: + """Close any tool spans still open because ``on_tool_end`` never fired. + + ``on_tool_start`` opens a span that ``on_tool_end`` is expected to close. + If the runner terminates mid-tool (max-turns exceeded, cancellation, an + unexpected SDK exception), the matching ``on_tool_end`` never runs and the + span would otherwise stay open forever — orphaned in the tracing backend. + Call this from a ``finally`` around ``Runner.run`` to drain the leftovers. + + Best-effort, like the rest of tracing: each span is closed with an + ``incomplete`` marker and any failure is logged and swallowed. + """ + if not self._tool_spans: + return + orphaned = list(self._tool_spans.items()) + self._tool_spans.clear() + for tool_call_id, span in orphaned: + logger.warning( + f"[tracing] tool span for {tool_call_id} left open (on_tool_end never fired); closing as incomplete" + ) + try: + span.output = {"result": None, "status": "incomplete"} + await _get_adk().tracing.end_span( + trace_id=self.trace_id, + span=span, + start_to_close_timeout=_TRACE_TIMEOUT, + ) + except Exception as e: # noqa: BLE001 - tracing is best-effort + logger.warning(f"[tracing] orphan tool end_span failed (non-fatal): {e}") diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/interceptors/__init__.py b/src/agentex/lib/core/temporal/plugins/openai_agents/interceptors/__init__.py new file mode 100644 index 000000000..47290ea41 --- /dev/null +++ b/src/agentex/lib/core/temporal/plugins/openai_agents/interceptors/__init__.py @@ -0,0 +1,19 @@ +"""Temporal interceptors for OpenAI Agents SDK integration. + +This module provides interceptors for threading context (task_id, trace_id, parent_span_id) +from workflows to activities in Temporal. +""" + +from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ( + ContextInterceptor, + streaming_task_id, + streaming_trace_id, + streaming_parent_span_id, +) + +__all__ = [ + "ContextInterceptor", + "streaming_task_id", + "streaming_trace_id", + "streaming_parent_span_id", +] \ No newline at end of file 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 new file mode 100644 index 000000000..893f75f28 --- /dev/null +++ b/src/agentex/lib/core/temporal/plugins/openai_agents/interceptors/context_interceptor.py @@ -0,0 +1,153 @@ +""" +Temporal context interceptors for threading runtime context through workflows and activities. + +This module provides interceptors that pass task_id, trace_id, and parent_span_id from +workflows to activities via headers, making them available via ContextVars for models +to use for streaming, tracing, or other purposes. +""" + +import logging +from typing import Any, Type, Optional, override +from contextvars import ContextVar + +from temporalio import workflow +from temporalio.worker import ( + Interceptor, + StartActivityInput, + ExecuteActivityInput, + ExecuteWorkflowInput, + ActivityInboundInterceptor, + WorkflowInboundInterceptor, + WorkflowOutboundInterceptor, +) +from temporalio.converter import default + +# Set up logging +logger = logging.getLogger("context.interceptor") + +# Global context variables that models can read +# These are thread-safe and work across async boundaries +streaming_task_id: ContextVar[Optional[str]] = ContextVar('streaming_task_id', default=None) +streaming_trace_id: ContextVar[Optional[str]] = ContextVar('streaming_trace_id', default=None) +streaming_parent_span_id: ContextVar[Optional[str]] = ContextVar('streaming_parent_span_id', default=None) + +# Header keys for passing context +TASK_ID_HEADER = "context-task-id" +TRACE_ID_HEADER = "context-trace-id" +PARENT_SPAN_ID_HEADER = "context-parent-span-id" + +class ContextInterceptor(Interceptor): + """Main interceptor that enables context threading through Temporal.""" + + def __init__(self): + self._payload_converter = default().payload_converter + logger.info("[ContextInterceptor] Initialized") + + @override + def intercept_activity(self, next: ActivityInboundInterceptor) -> ActivityInboundInterceptor: + """Create activity interceptor to read context from headers.""" + return ContextActivityInboundInterceptor(next, self._payload_converter) + + @override + def workflow_interceptor_class(self, _input: Any) -> Optional[Type[WorkflowInboundInterceptor]]: + """Return workflow interceptor class.""" + return ContextWorkflowInboundInterceptor + + +class ContextWorkflowInboundInterceptor(WorkflowInboundInterceptor): + """Workflow interceptor that creates the outbound interceptor.""" + + def __init__(self, next: WorkflowInboundInterceptor): + super().__init__(next) + self._payload_converter = default().payload_converter + + @override + async def execute_workflow(self, input: ExecuteWorkflowInput) -> Any: + """Execute workflow - just pass through.""" + return await self.next.execute_workflow(input) + + @override + def init(self, outbound: WorkflowOutboundInterceptor) -> None: + """Initialize with our custom outbound interceptor.""" + self.next.init(ContextWorkflowOutboundInterceptor( + outbound, self._payload_converter + )) + + +class ContextWorkflowOutboundInterceptor(WorkflowOutboundInterceptor): + """Outbound interceptor that adds task_id to activity headers.""" + + def __init__(self, next, payload_converter): + super().__init__(next) + self._payload_converter = payload_converter + + @override + def start_activity(self, input: StartActivityInput) -> workflow.ActivityHandle: + """Add task_id, trace_id, and parent_span_id to headers when starting model activities.""" + + try: + workflow_instance = workflow.instance() + task_id = getattr(workflow_instance, '_task_id', None) + trace_id = getattr(workflow_instance, '_trace_id', None) + parent_span_id = getattr(workflow_instance, '_parent_span_id', None) + + if task_id and trace_id and parent_span_id: + if not input.headers: + input.headers = {} + + input.headers[TASK_ID_HEADER] = self._payload_converter.to_payload(task_id) # type: ignore[index] + input.headers[TRACE_ID_HEADER] = self._payload_converter.to_payload(trace_id) # type: ignore[index] + input.headers[PARENT_SPAN_ID_HEADER] = self._payload_converter.to_payload(parent_span_id) # type: ignore[index] + logger.debug(f"[OutboundInterceptor] Added task_id, trace_id, and parent_span_id to activity headers: {task_id}, {trace_id}, {parent_span_id}") + else: + logger.warning("[OutboundInterceptor] No _task_id, _trace_id, or _parent_span_id found in workflow instance") + except Exception as e: + logger.error(f"[OutboundInterceptor] Failed to get task_id, trace_id, or parent_span_id from workflow instance: {e}") + + return self.next.start_activity(input) + + +class ContextActivityInboundInterceptor(ActivityInboundInterceptor): + """Activity interceptor that extracts task_id, trace_id, and parent_span_id from headers and sets context variables.""" + + def __init__(self, next, payload_converter): + super().__init__(next) + self._payload_converter = payload_converter + + @override + async def execute_activity(self, input: ExecuteActivityInput) -> Any: + """Extract task_id, trace_id, and parent_span_id from headers and set context variables.""" + + # Extract task_id from headers if present + if input.headers and TASK_ID_HEADER in input.headers: + task_id_value = self._payload_converter.from_payload( + input.headers[TASK_ID_HEADER], str + ) + trace_id_value = self._payload_converter.from_payload( + input.headers[TRACE_ID_HEADER], str + ) + parent_span_id_value = self._payload_converter.from_payload( + input.headers[PARENT_SPAN_ID_HEADER], str + ) + + # P THIS IS THE KEY PART - Set the context variable! + # This makes task_id available to TemporalStreamingModel.get_response() + streaming_task_id.set(task_id_value) + streaming_trace_id.set(trace_id_value) + streaming_parent_span_id.set(parent_span_id_value) + logger.info(f"[ActivityInterceptor] Set task_id, trace_id, and parent_span_id in context: {task_id_value}, {trace_id_value}, {parent_span_id_value}") + else: + logger.debug("[ActivityInterceptor] No task_id, trace_id, or parent_span_id in headers") + + try: + # Execute the activity + # The TemporalStreamingModel can now read streaming_task_id.get() + result = await self.next.execute_activity(input) + return result + finally: + # Clean up context after activity + streaming_task_id.set(None) + streaming_trace_id.set(None) + streaming_parent_span_id.set(None) + logger.debug("[ActivityInterceptor] Cleared task_id, trace_id, and parent_span_id from context") + diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/models/__init__.py b/src/agentex/lib/core/temporal/plugins/openai_agents/models/__init__.py new file mode 100644 index 000000000..19dd967d5 --- /dev/null +++ b/src/agentex/lib/core/temporal/plugins/openai_agents/models/__init__.py @@ -0,0 +1,15 @@ +"""Model providers for Temporal OpenAI Agents SDK integration. + +This module provides model implementations that add streaming and tracing +capabilities to standard OpenAI models when running in Temporal workflows/activities. +""" + +from agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model import ( + TemporalStreamingModel, + TemporalStreamingModelProvider, +) + +__all__ = [ + "TemporalStreamingModel", + "TemporalStreamingModelProvider", +] diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/models/temporal_streaming_model.py b/src/agentex/lib/core/temporal/plugins/openai_agents/models/temporal_streaming_model.py new file mode 100644 index 000000000..c985d5e65 --- /dev/null +++ b/src/agentex/lib/core/temporal/plugins/openai_agents/models/temporal_streaming_model.py @@ -0,0 +1,1356 @@ +"""Custom Temporal Model Provider with streaming support for OpenAI agents.""" +from __future__ import annotations + +import json +import time +import uuid +from typing import Any, List, Union, Optional, override + +from agents import ( + Tool, + Model, + Handoff, + FunctionTool, + ModelTracing, + ModelProvider, + ModelResponse, + ModelSettings, + TResponseInputItem, + AgentOutputSchemaBase, +) +from openai import NOT_GIVEN, AsyncOpenAI +from agents.tool import ( + ComputerTool, + HostedMCPTool, + WebSearchTool, + FileSearchTool, + LocalShellTool, + CodeInterpreterTool, + ImageGenerationTool, +) +from agents.computer import Computer, AsyncComputer + +# Re-export the canonical StreamingMode literal from the streaming service so +# all layers share a single definition. +from agentex.lib.core.services.adk.streaming import StreamingMode as StreamingMode +from agentex.lib.core.observability.llm_metrics import get_llm_metrics +from agentex.lib.core.observability.llm_metrics_hooks import record_llm_failure + +try: + from agents.tool import ShellTool # type: ignore[attr-defined] +except ImportError: + ShellTool = None # type: ignore[assignment,misc] +from agents.usage import Usage, InputTokensDetails, OutputTokensDetails # type: ignore[attr-defined] +from agents.model_settings import MCPToolChoice +from openai.types.responses import ( + ResponseOutputText, + ResponseOutputMessage, + ResponseCompletedEvent, + ResponseTextDeltaEvent, + ResponseFunctionToolCall, + ResponseOutputItemDoneEvent, + # Event types for proper type checking + ResponseOutputItemAddedEvent, + ResponseReasoningTextDeltaEvent, + ResponseReasoningSummaryPartDoneEvent, + ResponseFunctionCallArgumentsDoneEvent, + ResponseReasoningSummaryPartAddedEvent, + ResponseReasoningSummaryTextDeltaEvent, + ResponseFunctionCallArgumentsDeltaEvent, +) +from openai.types.responses.response_prompt_param import ResponsePromptParam + +# AgentEx SDK imports +from agentex.lib import adk +from agentex.lib.utils.logging import make_logger +from agentex.lib.core.tracing.tracer import AsyncTracer +from agentex.lib.core.tracing.lineage import merge_refs_into_data, resolve_refs_from_items +from agentex.types.task_message_delta import TextDelta, ToolRequestDelta, ReasoningContentDelta, ReasoningSummaryDelta +from agentex.types.task_message_update import StreamTaskMessageFull, StreamTaskMessageDelta +from agentex.types.task_message_content import TextContent, ReasoningContent, ToolRequestContent, ToolResponseContent +from agentex.lib.adk.utils._modules.client import create_async_agentex_client +from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ( + streaming_task_id, + streaming_trace_id, + streaming_parent_span_id, +) + +# Use the SDK's make_logger so this module's INFO/DEBUG output is actually +# visible (raw ``logging.getLogger`` returns a logger with no handler/level +# configured, which silently drops anything below WARNING). Keep the explicit +# name "agentex.temporal.streaming" so any external logging config targeting +# that name keeps working. +logger = make_logger("agentex.temporal.streaming") + + +# LLM metrics live in agentex.lib.core.observability.llm_metrics so other +# code paths (sync ACP, Claude SDK plugin, future provider integrations) +# can share the same instrument definitions without redefining names. + + +def _serialize_item(item: Any) -> dict[str, Any]: + """ + Universal serializer for any item type from OpenAI Agents SDK. + + Uses model_dump() for Pydantic models, otherwise extracts attributes manually. + Filters out internal Pydantic fields that can't be serialized. + """ + if hasattr(item, 'model_dump'): + # Pydantic model - use model_dump for proper serialization + try: + return item.model_dump(mode='json', exclude_unset=True) + except Exception: + # Fallback to dict conversion + return dict(item) if hasattr(item, '__iter__') else {} + else: + # Not a Pydantic model - extract attributes manually + item_dict = {} + for attr_name in dir(item): + if not attr_name.startswith('_') and attr_name not in ('model_fields', 'model_config', 'model_computed_fields'): + try: + attr_value = getattr(item, attr_name, None) + # Skip methods and None values + if attr_value is not None and not callable(attr_value): + # Convert to JSON-serializable format + if hasattr(attr_value, 'model_dump'): + item_dict[attr_name] = attr_value.model_dump() + elif isinstance(attr_value, (str, int, float, bool, list, dict)): + item_dict[attr_name] = attr_value + else: + item_dict[attr_name] = str(attr_value) + except Exception: + # Skip attributes that can't be accessed + pass + return item_dict + + +# Responses-API output items for server-side / hosted tools. These execute inside +# the Responses API, so they never become function_call items AND the SDK's +# RunHooks (on_tool_start/on_tool_end) never fire for them. The streaming loop +# must surface them explicitly, as a tool request + response pair, when the item +# completes (by then it carries the full query/result). +_HOSTED_TOOL_TYPES = frozenset( + { + "web_search_call", + "file_search_call", + "code_interpreter_call", + "image_generation_call", + "mcp_call", + "computer_call", + "local_shell_call", + } +) + +# Cap on the rendered hosted-tool result string (UI / trace readability). +_HOSTED_TOOL_RESULT_CAP = 2000 + + +def _coerce_args(raw: Any) -> dict[str, Any]: + """Best-effort coerce a hosted-tool's arguments to a dict for the UI.""" + if raw is None: + return {} + if isinstance(raw, dict): + return raw + if isinstance(raw, str): + try: + parsed = json.loads(raw) + return parsed if isinstance(parsed, dict) else {"value": parsed} + except (json.JSONDecodeError, ValueError): + return {"raw": raw} + serialized = _serialize_item(raw) + return serialized if isinstance(serialized, dict) else {"value": str(raw)} + + +def _hosted_tool_request(item: Any) -> tuple[str, str, dict[str, Any]]: + """Extract (call_id, display_name, arguments) from a hosted-tool item.""" + itype = getattr(item, "type", "") or "" + call_id = ( + getattr(item, "id", "") + or getattr(item, "call_id", "") + or f"hosted_{uuid.uuid4().hex[:8]}" + ) + name = itype[:-5] if itype.endswith("_call") else itype # web_search_call -> web_search + args: dict[str, Any] = {} + if itype == "web_search_call": + action = getattr(item, "action", None) + if action is not None: + args = _coerce_args(action) + elif itype == "file_search_call": + args = {"queries": list(getattr(item, "queries", []) or [])} + elif itype == "code_interpreter_call": + args = {"code": getattr(item, "code", "") or ""} + elif itype in ("computer_call", "local_shell_call"): + # Both carry an `action` object: a ComputerAction (click/scroll/type/...) + # or a LocalShellCallAction (command/env/cwd). Surface it as the args so + # the trace shows what the tool actually did, not just its status. + action = getattr(item, "action", None) + if action is not None: + args = _coerce_args(action) + elif itype == "mcp_call": + mcp_name = getattr(item, "name", None) or "mcp" + server = getattr(item, "server_label", None) + name = f"{server}.{mcp_name}" if server else mcp_name + args = _coerce_args(getattr(item, "arguments", None)) + return call_id, name, args + + +def _hosted_tool_result(item: Any) -> str: + """Extract a short result string from a completed hosted-tool item.""" + itype = getattr(item, "type", "") or "" + if itype == "mcp_call": + err = getattr(item, "error", None) + if err: + return f"error: {err}" + out = getattr(item, "output", None) + if out: + return str(out) + elif itype == "code_interpreter_call": + outputs = getattr(item, "outputs", None) + if outputs: + return json.dumps([_serialize_item(o) for o in outputs])[:_HOSTED_TOOL_RESULT_CAP] + elif itype == "file_search_call": + results = getattr(item, "results", None) + if results: + return json.dumps([_serialize_item(r) for r in results])[:_HOSTED_TOOL_RESULT_CAP] + elif itype == "image_generation_call": + # `result` is base64 image data; surface a compact reference instead of + # dumping the (large) payload into the trace. + result = getattr(item, "result", None) + if result: + return f"" + return str(getattr(item, "status", "completed") or "completed") + + +class TemporalStreamingModel(Model): + """Custom model implementation with streaming support.""" + + def __init__( + self, + model_name: str = "gpt-4o", + _use_responses_api: bool = True, + openai_client: Optional[AsyncOpenAI] = None, + streaming_mode: StreamingMode = "coalesced", + ): + """Initialize the streaming model with OpenAI client and model name. + + Args: + model_name: The name of the OpenAI model to use (default: "gpt-4o") + _use_responses_api: Internal flag for responses API (deprecated, always True) + openai_client: Optional custom AsyncOpenAI client. If not provided, a default + client with max_retries=0 will be created (since Temporal handles retries) + streaming_mode: How per-delta updates flow to consumers. Defaults to + "coalesced" (50ms / 128-char windowed batches with an + immediate first-delta flush) for low latency without + giving up streaming UX. Use "per_token" for legacy + publish-every-delta behavior, or "off" to suppress + per-delta publishes entirely. + """ + # Use provided client or create default (Temporal handles retries) + self.client = openai_client if openai_client is not None else AsyncOpenAI(max_retries=0) + self.model_name = model_name + # Always use Responses API for all models + self.use_responses_api = True + self.streaming_mode: StreamingMode = streaming_mode + + # Initialize tracer as a class variable + agentex_client = create_async_agentex_client() + self.tracer = AsyncTracer(agentex_client) + + logger.info(f"[TemporalStreamingModel] Initialized model={self.model_name}, use_responses_api={self.use_responses_api}, custom_client={openai_client is not None}, streaming_mode={self.streaming_mode}, tracer=initialized") + + def _non_null_or_not_given(self, value: Any) -> Any: + """Convert None to NOT_GIVEN sentinel, matching OpenAI SDK pattern.""" + return value if value is not None else NOT_GIVEN + + def _prepare_response_input(self, input: Union[str, list[TResponseInputItem]]) -> List[dict]: + """Convert input to Responses API format. + + Args: + input: Either a string prompt or list of ResponseInputItem messages + + Returns: + List of input items in Responses API format + """ + response_input = [] + + if isinstance(input, list): + # Process list of ResponseInputItem objects + for _idx, item in enumerate(input): + # Convert to dict if needed + if isinstance(item, dict): + item_dict = item + else: + item_dict = item.model_dump() if hasattr(item, 'model_dump') else item + + item_type = item_dict.get("type") + + if item_type == "message": + # ResponseOutputMessage format + role = item_dict.get("role", "assistant") + content_list = item_dict.get("content", []) + + # Build content array + content_array = [] + for content_item in content_list: + if isinstance(content_item, dict): + if content_item.get("type") == "output_text": + # For assistant messages, keep as output_text + # For user messages, convert to input_text + if role == "user": + content_array.append({ + "type": "input_text", + "text": content_item.get("text", "") + }) + else: + content_array.append({ + "type": "output_text", + "text": content_item.get("text", "") + }) + else: + content_array.append(content_item) + + response_input.append({ + "type": "message", + "role": role, + "content": content_array + }) + + elif item_type == "function_call": + # Function call from previous response + logger.debug(f"[Responses API] function_call item keys: {list(item_dict.keys())}") + call_id = item_dict.get("call_id") or item_dict.get("id") + if not call_id: + logger.debug(f"[Responses API] WARNING: No call_id found in function_call item!") + logger.debug(f"[Responses API] Full item: {item_dict}") + # Generate a fallback ID if missing + call_id = f"call_{uuid.uuid4().hex[:8]}" + logger.debug(f"[Responses API] Generated fallback call_id: {call_id}") + logger.debug(f"[Responses API] Adding function_call with call_id={call_id}, name={item_dict.get('name')}") + response_input.append({ + "type": "function_call", + "call_id": call_id, # API expects 'call_id' not 'id' + "name": item_dict.get("name", ""), + "arguments": item_dict.get("arguments", "{}"), + }) + + elif item_type == "function_call_output": + # Function output/response + call_id = item_dict.get("call_id") + if not call_id: + logger.debug(f"[Responses API] WARNING: No call_id in function_call_output!") + # Try to find it from id field + call_id = item_dict.get("id") + response_input.append({ + "type": "function_call_output", + "call_id": call_id or "", + "output": item_dict.get("output", "") + }) + + elif item_dict.get("role") == "user": + # Simple user message + response_input.append({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": item_dict.get("content", "")}] + }) + + elif item_dict.get("role") == "tool": + # Tool message + response_input.append({ + "type": "function_call_output", + "call_id": item_dict.get("tool_call_id"), + "output": item_dict.get("content") + }) + else: + logger.debug(f"[Responses API] Skipping unhandled item type: {item_type}, role: {item_dict.get('role')}") + + elif isinstance(input, str): + # Simple string input + response_input.append({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": input}] + }) + + return response_input + + def _convert_tools(self, tools: list[Tool], handoffs: list[Handoff]) -> tuple[List[dict], List[str]]: + """Convert tools and handoffs to Responses API format. + + Args: + tools: List of Tool objects + handoffs: List of Handoff objects + + Returns: + Tuple of (converted_tools, include_list) where include_list contains + additional response data to request + """ + response_tools = [] + tool_includes = [] + + # Check for multiple computer tools (only one allowed) + computer_tools = [tool for tool in tools if isinstance(tool, ComputerTool)] + if len(computer_tools) > 1: + raise ValueError(f"You can only provide one computer tool. Got {len(computer_tools)}") + + # Convert each tool based on its type + for tool in tools: + if isinstance(tool, FunctionTool): + response_tools.append({ + "type": "function", + "name": tool.name, + "description": tool.description or "", + "parameters": tool.params_json_schema if tool.params_json_schema else {}, + "strict": tool.strict_json_schema, + }) + + elif isinstance(tool, WebSearchTool): + tool_config = { + "type": "web_search", + } + # filters attribute was removed from WebSearchTool API + if hasattr(tool, 'user_location') and tool.user_location is not None: + tool_config["user_location"] = tool.user_location + if hasattr(tool, 'search_context_size') and tool.search_context_size is not None: + tool_config["search_context_size"] = tool.search_context_size + response_tools.append(tool_config) + + elif isinstance(tool, FileSearchTool): + tool_config = { + "type": "file_search", + "vector_store_ids": tool.vector_store_ids, + } + if tool.max_num_results: + tool_config["max_num_results"] = tool.max_num_results + if tool.ranking_options: + tool_config["ranking_options"] = tool.ranking_options + if tool.filters: + tool_config["filters"] = tool.filters + response_tools.append(tool_config) + + # Add include for file search results if needed + if tool.include_search_results: + tool_includes.append("file_search_call.results") + + elif isinstance(tool, ComputerTool): + # In newer openai-agents, tool.computer may be a factory + # (ComputerCreate/ComputerProvider). Only concrete Computer + # / AsyncComputer instances expose environment/dimensions. + computer = tool.computer + if not isinstance(computer, (Computer, AsyncComputer)): + raise ValueError( + "ComputerTool.computer must be a Computer or AsyncComputer " + "instance for Responses API serialization; got " + f"{type(computer).__name__}" + ) + environment = computer.environment + dimensions = computer.dimensions + if environment is None or dimensions is None: + raise ValueError( + "ComputerTool requires `environment` and `dimensions` on the " + "Computer/AsyncComputer implementation." + ) + response_tools.append({ + "type": "computer_use_preview", + "environment": environment, + "display_width": dimensions[0], + "display_height": dimensions[1], + }) + + elif isinstance(tool, HostedMCPTool): + response_tools.append(tool.tool_config) + + elif isinstance(tool, ImageGenerationTool): + response_tools.append(tool.tool_config) + + elif isinstance(tool, CodeInterpreterTool): + response_tools.append(tool.tool_config) + + elif isinstance(tool, LocalShellTool): + # LocalShellTool API changed - no longer has working_directory + # The executor handles execution details internally + response_tools.append({ + "type": "local_shell", + }) + + elif ShellTool is not None and isinstance(tool, ShellTool): + environment = dict(tool.environment) if tool.environment else {"type": "local"} + response_tools.append({ + "type": "shell", + "environment": environment, + }) + + else: + logger.warning(f"Unknown tool type: {type(tool).__name__}, skipping") + + # Convert handoffs (always function tools) + for handoff in handoffs: + response_tools.append({ + "type": "function", + "name": handoff.tool_name, + "description": handoff.tool_description or f"Transfer to {handoff.agent_name}", + "parameters": handoff.input_json_schema if handoff.input_json_schema else {}, + }) + + return response_tools, tool_includes + + def _build_reasoning_param(self, model_settings: ModelSettings) -> Any: + """Build reasoning parameter from model settings. + + Args: + model_settings: Model configuration settings + + Returns: + Reasoning parameter dict or NOT_GIVEN + """ + if not model_settings.reasoning: + return NOT_GIVEN + + if hasattr(model_settings.reasoning, 'effort') and model_settings.reasoning.effort: + # For Responses API, reasoning is an object + reasoning_param = { + "effort": model_settings.reasoning.effort, + } + # Add summary if specified (check both 'summary' and 'generate_summary' for compatibility) + summary_value = None + if hasattr(model_settings.reasoning, 'summary') and model_settings.reasoning.summary is not None: + summary_value = model_settings.reasoning.summary + elif ( + hasattr(model_settings.reasoning, 'generate_summary') + and model_settings.reasoning.generate_summary is not None + ): + summary_value = model_settings.reasoning.generate_summary + + if summary_value is not None: + reasoning_param["summary"] = summary_value + + logger.debug(f"[TemporalStreamingModel] Using reasoning param: {reasoning_param}") + return reasoning_param + + return NOT_GIVEN + + def _convert_tool_choice(self, tool_choice: Any) -> Any: + """Convert tool_choice to Responses API format. + + Args: + tool_choice: Tool choice from model settings + + Returns: + Converted tool choice or NOT_GIVEN + """ + if tool_choice is None: + return NOT_GIVEN + + if isinstance(tool_choice, MCPToolChoice): + # MCP tool choice with server label + return { + "server_label": tool_choice.server_label, + "type": "mcp", + "name": tool_choice.name, + } + elif tool_choice == "required": + return "required" + elif tool_choice == "auto": + return "auto" + elif tool_choice == "none": + return "none" + elif tool_choice == "file_search": + return {"type": "file_search"} + elif tool_choice == "web_search": + return {"type": "web_search"} + elif tool_choice == "web_search_preview": + return {"type": "web_search_preview"} + elif tool_choice == "computer_use_preview": + return {"type": "computer_use_preview"} + elif tool_choice == "image_generation": + return {"type": "image_generation"} + elif tool_choice == "code_interpreter": + return {"type": "code_interpreter"} + elif tool_choice == "mcp": + # Generic MCP without specific tool + return {"type": "mcp"} + elif isinstance(tool_choice, str): + # Specific function tool by name + return { + "type": "function", + "name": tool_choice, + } + else: + # Pass through as-is for other types + return tool_choice + + async def _post_tool_message(self, task_id: str, content: Any) -> None: + """Post a one-shot tool request/response message (no deltas). + + Used for hosted/server-side tool calls (web_search, file_search, + code_interpreter, image generation, server-side mcp, ...) that execute + inside the Responses API and so never produce function_call items or fire + RunHooks. Each completed hosted tool is surfaced as a ToolRequestContent + + ToolResponseContent pair. Posting full (no deltas) means the coalescing + path that the streamed reasoning/text contexts use does not apply here. + """ + try: + async with adk.streaming.streaming_task_message_context( + task_id=task_id, + initial_content=content, + ) as ctx: + await ctx.stream_update( + StreamTaskMessageFull( + parent_task_message=ctx.task_message, + content=content, + type="full", + ) + ) + except Exception as e: # noqa: BLE001 - UI surfacing must never break a turn + logger.warning(f"[TemporalStreamingModel] failed to post hosted-tool message: {e}") + + @override + async def get_response( + self, + system_instructions: Optional[str], + input: Union[str, list[TResponseInputItem]], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: Optional[AgentOutputSchemaBase], + handoffs: list[Handoff], + tracing: ModelTracing, # noqa: ARG002 + *, + previous_response_id: Optional[str] = None, + conversation_id: Optional[str] = None, + prompt: Optional[ResponsePromptParam] = None, + ) -> ModelResponse: + """Get a non-streaming response from the model with streaming to Redis. + + This method is used by Temporal activities and needs to return a complete + response, but we stream the response to Redis while generating it. + + ``previous_response_id``, ``conversation_id``, and ``prompt`` are all + Responses API server-state parameters threaded through by the OpenAI + Agents SDK. Each is forwarded to ``responses.create`` only when + explicitly set — defaults resolve to ``NOT_GIVEN`` and are omitted from + the request body. Not all OpenAI-compatible backends recognize these + fields, so callers on alternative providers see no wire-level change + unless they opt in. + """ + + task_id = streaming_task_id.get() + trace_id = streaming_trace_id.get() + parent_span_id = streaming_parent_span_id.get() + + if not task_id or not trace_id or not parent_span_id: + raise ValueError("task_id, trace_id, and parent_span_id are required for streaming with Responses API") + + trace = self.tracer.trace(trace_id) + + async with trace.span( + parent_id=parent_span_id, + name="streaming_model_get_response", + input={ + "model": self.model_name, + "has_system_instructions": system_instructions is not None, + "input_type": type(input).__name__, + "tools_count": len(tools) if tools else 0, + "handoffs_count": len(handoffs) if handoffs else 0, + }, + ) as span: + # Always use Responses API for streaming + if not task_id: + # If no task_id, we can't use streaming - this shouldn't happen normally + raise ValueError("task_id is required for streaming with Responses API") + + logger.info(f"[TemporalStreamingModel] Using Responses API for {self.model_name}") + + try: + # Prepare input using helper method + response_input = self._prepare_response_input(input) + + # Convert tools and handoffs using helper method + response_tools, tool_includes = self._convert_tools(tools, handoffs) + openai_tools = response_tools if response_tools else None + + # Build reasoning parameter using helper method + reasoning_param = self._build_reasoning_param(model_settings) + + # Convert tool_choice using helper method + tool_choice = self._convert_tool_choice(model_settings.tool_choice) + + # Build include list for response data + include_list = [] + # Add tool-specific includes + if tool_includes: + include_list.extend(tool_includes) + # Add user-specified includes + if model_settings.response_include: + include_list.extend(model_settings.response_include) + # Add logprobs include if top_logprobs is set + if model_settings.top_logprobs is not None: + include_list.append("message.output_text.logprobs") + # Build response format for verbosity and structured output + response_format = NOT_GIVEN + + if output_schema is not None: + # Handle structured output schema for Responses API + # The Responses API expects the schema in the 'text' parameter with a 'format' key + logger.debug(f"[TemporalStreamingModel] Converting output_schema to Responses API format") + try: + # Get the JSON schema from the output schema + schema_dict = output_schema.json_schema() + response_format = { + "format": { + "type": "json_schema", + "name": "final_output", + "schema": schema_dict, + "strict": output_schema.is_strict_json_schema() if hasattr(output_schema, 'is_strict_json_schema') else True, + } + } + logger.debug(f"[TemporalStreamingModel] Built response_format with json_schema: {response_format}") + except Exception as e: + logger.warning(f"Failed to convert output_schema: {e}") + response_format = NOT_GIVEN + + if model_settings.verbosity is not None: + if response_format is not NOT_GIVEN and isinstance(response_format, dict): + response_format["verbosity"] = model_settings.verbosity + else: + response_format = {"verbosity": model_settings.verbosity} + + # Build extra_args dict for additional parameters + extra_args = dict(model_settings.extra_args or {}) + if model_settings.top_logprobs is not None: + extra_args["top_logprobs"] = model_settings.top_logprobs + + # Opt-in prompt_cache_key: forwarded only when the caller supplies it via + # model_settings.extra_args["prompt_cache_key"]. Not all OpenAI-compatible + # endpoints recognize this parameter, so we don't auto-inject a default. + prompt_cache_key = extra_args.pop("prompt_cache_key", NOT_GIVEN) + + # Create the response stream using Responses API. + # Bookmark request start *before* the await so ttft captures the full + # user-perceived latency (HTTP round-trip + model TTFB), not just the + # post-connect event-loop delay. + stream_start_perf = time.perf_counter() + logger.debug(f"[TemporalStreamingModel] Creating response stream with Responses API") + stream = await self.client.responses.create( # type: ignore[call-overload] + + model=self.model_name, + input=response_input, + instructions=system_instructions, + tools=openai_tools or NOT_GIVEN, + stream=True, + # Temperature and sampling parameters + temperature=self._non_null_or_not_given(model_settings.temperature), + max_output_tokens=self._non_null_or_not_given(model_settings.max_tokens), + top_p=self._non_null_or_not_given(model_settings.top_p), + # Note: frequency_penalty and presence_penalty are not supported by Responses API + # Tool and reasoning parameters + reasoning=reasoning_param, + tool_choice=tool_choice, + parallel_tool_calls=self._non_null_or_not_given(model_settings.parallel_tool_calls), + # Context and truncation + truncation=self._non_null_or_not_given(model_settings.truncation), + # Response configuration (includes structured output schema) + text=response_format, + include=include_list if include_list else NOT_GIVEN, + # Metadata and storage + metadata=self._non_null_or_not_given(model_settings.metadata), + store=self._non_null_or_not_given(model_settings.store), + # Extra customization + extra_headers=model_settings.extra_headers, + extra_query=model_settings.extra_query, + extra_body=model_settings.extra_body, + prompt_cache_key=prompt_cache_key, + previous_response_id=self._non_null_or_not_given(previous_response_id), + # SDK abstract names this conversation_id; the Responses API + # endpoint kwarg is `conversation` (accepts a str id directly). + conversation=self._non_null_or_not_given(conversation_id), + prompt=self._non_null_or_not_given(prompt), + # Any additional parameters from extra_args + **extra_args, + ) + + # Process the stream of events from Responses API + output_items = [] + captured_usage = None + captured_response_id = None + current_text = "" + streaming_context = None + reasoning_context = None + reasoning_summaries = [] + reasoning_contents = [] + event_count = 0 + # ttft / ttat / tps instrumentation. ``stream_start_perf`` is set + # above, before the responses.create() await, so it captures the full + # request-to-first-token latency. ``first_token_at`` and + # ``last_token_at`` bracket the model-generation window for tps. + # ``first_answer_at`` is set on the first user-visible answer token + # (text or tool-call delta) and excludes reasoning chunks, so ttat + # measures the latency users actually perceive on reasoning models. + first_token_at: Optional[float] = None + last_token_at: Optional[float] = None + first_answer_at: Optional[float] = None + + # We expect task_id to always be provided for streaming + if not task_id: + raise ValueError("[TemporalStreamingModel] task_id is required for streaming model") + + # Process events from the Responses API stream + function_calls_in_progress = {} # Track function calls being streamed + + async for event in stream: + event_count += 1 + + # Log event type + logger.debug(f"[TemporalStreamingModel] Event {event_count}: {type(event).__name__}") + + # Bookmark first/last token-producing events for ttft and tps. + # Includes function-call argument deltas so the generation window + # covers every event type whose tokens land in usage.output_tokens. + if isinstance(event, ( + ResponseTextDeltaEvent, + ResponseReasoningTextDeltaEvent, + ResponseReasoningSummaryTextDeltaEvent, + ResponseFunctionCallArgumentsDeltaEvent, + )): + now_perf = time.perf_counter() + if first_token_at is None: + first_token_at = now_perf + last_token_at = now_perf + # ttat: first user-visible answer token (text or tool call), + # excluding reasoning chunks. Equal to ttft for non-reasoning + # models; differs by reasoning duration for reasoning models. + if first_answer_at is None and isinstance(event, ( + ResponseTextDeltaEvent, + ResponseFunctionCallArgumentsDeltaEvent, + )): + first_answer_at = now_perf + + # Handle different event types using isinstance for type safety + if isinstance(event, ResponseOutputItemAddedEvent): + # New output item (reasoning, function call, or message) + item = getattr(event, 'item', None) + output_index = getattr(event, 'output_index', 0) + + if item and getattr(item, 'type', None) == 'reasoning': + logger.debug(f"[TemporalStreamingModel] Starting reasoning item") + if not reasoning_context: + # Start a reasoning context for streaming reasoning to UI + reasoning_context = await adk.streaming.streaming_task_message_context( + task_id=task_id, + initial_content=ReasoningContent( + author="agent", + summary=[], + content=[], + type="reasoning", + style="active", + ), + streaming_mode=self.streaming_mode, + ).__aenter__() + elif item and getattr(item, 'type', None) == 'function_call': + # Open a streaming context per function call so argument + # deltas can be published incrementally. Coalescing and + # mode dispatch are handled by the streaming layer. + call_id = getattr(item, 'call_id', '') + tool_name = getattr(item, 'name', '') + call_context = await adk.streaming.streaming_task_message_context( + task_id=task_id, + initial_content=ToolRequestContent( + author="agent", + tool_call_id=call_id, + name=tool_name, + arguments={}, + ), + streaming_mode=self.streaming_mode, + ).__aenter__() + function_calls_in_progress[output_index] = { + 'id': getattr(item, 'id', ''), + 'call_id': call_id, + 'name': tool_name, + 'arguments': getattr(item, 'arguments', ''), + 'context': call_context, + } + logger.debug(f"[TemporalStreamingModel] Starting function call: {item.name}") + + elif item and getattr(item, 'type', None) == 'message': + # Track the message being streamed + streaming_context = await adk.streaming.streaming_task_message_context( + task_id=task_id, + initial_content=TextContent( + author="agent", + content="", + format="markdown", + ), + streaming_mode=self.streaming_mode, + ).__aenter__() + + elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent): + # Stream function call arguments + output_index = getattr(event, 'output_index', 0) + delta = getattr(event, 'delta', '') + + call_data = function_calls_in_progress.get(output_index) + if call_data is not None: + call_data['arguments'] += delta + call_context = call_data.get('context') + if call_context is not None: + try: + await call_context.stream_update(StreamTaskMessageDelta( + parent_task_message=call_context.task_message, + delta=ToolRequestDelta( + tool_call_id=call_data['call_id'], + name=call_data['name'], + arguments_delta=delta, + type="tool_request", + ), + type="delta", + )) + except Exception as e: + logger.warning(f"Failed to send tool request delta: {e}") + logger.debug(f"[TemporalStreamingModel] Function call args delta: {delta[:50]}...") + + elif isinstance(event, ResponseFunctionCallArgumentsDoneEvent): + # Function call arguments complete + output_index = getattr(event, 'output_index', 0) + arguments = getattr(event, 'arguments', '') + + if output_index in function_calls_in_progress: + function_calls_in_progress[output_index]['arguments'] = arguments + logger.debug(f"[TemporalStreamingModel] Function call args done") + + elif isinstance(event, (ResponseReasoningTextDeltaEvent, ResponseReasoningSummaryTextDeltaEvent, ResponseTextDeltaEvent)): + # Handle text streaming + delta = getattr(event, 'delta', '') + + if isinstance(event, ResponseReasoningSummaryTextDeltaEvent) and reasoning_context: + # Stream reasoning summary deltas - these are the actual reasoning tokens! + try: + # Use ReasoningSummaryDelta for reasoning summaries + summary_index = getattr(event, 'summary_index', 0) + delta_obj = ReasoningSummaryDelta( + summary_index=summary_index, + summary_delta=delta, + type="reasoning_summary", + ) + update = StreamTaskMessageDelta( + parent_task_message=reasoning_context.task_message, + delta=delta_obj, + type="delta", + ) + await reasoning_context.stream_update(update) + # Accumulate the reasoning summary + if len(reasoning_summaries) <= summary_index: + logger.debug(f"[TemporalStreamingModel] Extending reasoning summaries: {summary_index}") + reasoning_summaries.extend([""] * (summary_index + 1 - len(reasoning_summaries))) + reasoning_summaries[summary_index] += delta + logger.debug(f"[TemporalStreamingModel] Streamed reasoning summary: {delta[:30]}..." if len(delta) > 30 else f"[TemporalStreamingModel] Streamed reasoning summary: {delta}") + except Exception as e: + logger.warning(f"Failed to send reasoning delta: {e}") + elif isinstance(event, ResponseReasoningTextDeltaEvent) and reasoning_context: + # Regular reasoning delta (if these ever appear) + try: + delta_obj = ReasoningContentDelta( + content_index=0, + content_delta=delta, + type="reasoning_content", + ) + update = StreamTaskMessageDelta( + parent_task_message=reasoning_context.task_message, + delta=delta_obj, + type="delta", + ) + await reasoning_context.stream_update(update) + reasoning_contents.append(delta) + except Exception as e: + logger.warning(f"Failed to send reasoning delta: {e}") + elif isinstance(event, ResponseTextDeltaEvent): + # Stream regular text output + current_text += delta + try: + delta_obj = TextDelta( + type="text", + text_delta=delta, + ) + update = StreamTaskMessageDelta( + parent_task_message=streaming_context.task_message if streaming_context else None, + delta=delta_obj, + type="delta", + ) + if streaming_context: + await streaming_context.stream_update(update) + except Exception as e: + logger.warning(f"Failed to send text delta: {e}") + + elif isinstance(event, ResponseOutputItemDoneEvent): + # Output item completed + item = getattr(event, 'item', None) + output_index = getattr(event, 'output_index', 0) + + if item and getattr(item, 'type', None) == 'reasoning': + if reasoning_context and reasoning_summaries: + logger.debug(f"[TemporalStreamingModel] Reasoning itme completed, sending final update") + try: + # Send a full message update with the complete reasoning content + complete_reasoning_content = ReasoningContent( + author="agent", + summary=reasoning_summaries, # Use accumulated summaries + content=reasoning_contents if reasoning_contents else [], + type="reasoning", + style="static", + ) + + await reasoning_context.stream_update( + update=StreamTaskMessageFull( + parent_task_message=reasoning_context.task_message, + content=complete_reasoning_content, + type="full", + ), + ) + + # Close the reasoning context after sending the final update + # This matches the reference implementation pattern + await reasoning_context.close() + reasoning_context = None + logger.debug(f"[TemporalStreamingModel] Closed reasoning context after final update") + except Exception as e: + logger.warning(f"Failed to send reasoning part done update: {e}") + + elif item and getattr(item, 'type', None) == 'function_call': + # Function call completed - add to output + if output_index in function_calls_in_progress: + call_data = function_calls_in_progress[output_index] + logger.debug(f"[TemporalStreamingModel] Function call completed: {call_data['name']}") + + # Create proper function call object + tool_call = ResponseFunctionToolCall( + id=call_data['id'], + call_id=call_data['call_id'], + type="function_call", + name=call_data['name'], + arguments=call_data['arguments'], + ) + output_items.append(tool_call) + + # Emit the final ToolRequestContent and close the + # per-call streaming context. If the model produced + # invalid JSON args (truncation, hallucination), fall + # back to an empty dict so the streaming layer can + # still persist a message. + call_context = call_data.get('context') + if call_context is not None: + raw_args = call_data['arguments'] or '' + try: + parsed_args = json.loads(raw_args) if raw_args else {} + except json.JSONDecodeError: + logger.warning( + f"Failed to parse tool call arguments for {call_data['name']} " + f"(raw_args_bytes={len(raw_args)})" + ) + parsed_args = {} + try: + await call_context.stream_update(StreamTaskMessageFull( + parent_task_message=call_context.task_message, + content=ToolRequestContent( + author="agent", + tool_call_id=call_data['call_id'], + name=call_data['name'], + arguments=parsed_args, + ), + type="full", + )) + except Exception as e: + logger.warning(f"Failed to send tool request full update: {e}") + try: + await call_context.close() + except Exception as e: + logger.warning(f"Failed to close tool request context: {e}") + finally: + call_data['context'] = None + + elif item and getattr(item, 'type', None) in _HOSTED_TOOL_TYPES: + # Hosted / server-side tool call (web_search, file_search, + # code_interpreter, image generation, server-side mcp, ...). + # These run inside the Responses API: no function_call item + # and no RunHooks fire, so surface the completed call as a + # tool request + response pair (it carries the full + # query/result by the time it's done). + call_id, name, args = _hosted_tool_request(item) + await self._post_tool_message( + task_id, + ToolRequestContent( + author="agent", + tool_call_id=call_id, + name=name, + arguments=args, + ), + ) + await self._post_tool_message( + task_id, + ToolResponseContent( + author="agent", + tool_call_id=call_id, + name=name, + # Plain string, matching the function-tool response + # path (hooks.on_tool_end) so hosted and function + # tools render identically in the same flow. + content=_hosted_tool_result(item)[:_HOSTED_TOOL_RESULT_CAP], + ), + ) + + elif isinstance(event, ResponseReasoningSummaryPartAddedEvent): + # New reasoning part/summary started - reset accumulator + part = getattr(event, 'part', None) + if part: + part_type = getattr(part, 'type', 'unknown') + logger.debug(f"[TemporalStreamingModel] New reasoning part: type={part_type}") + # Reset the current reasoning summary for this new part + + elif isinstance(event, ResponseReasoningSummaryPartDoneEvent): + # Reasoning part completed - ResponseOutputItemDoneEvent will handle the final update + logger.debug(f"[TemporalStreamingModel] Reasoning part completed") + + elif isinstance(event, ResponseCompletedEvent): + # Response completed + logger.debug(f"[TemporalStreamingModel] Response completed") + response = getattr(event, 'response', None) + if response is not None: + if hasattr(response, 'output'): + # Use the final output from the response + output_items = response.output + logger.debug(f"[TemporalStreamingModel] Found {len(output_items)} output items in final response") + captured_usage = getattr(response, 'usage', None) + captured_response_id = getattr(response, 'id', None) + + # End of event processing loop - close any open contexts + if reasoning_context: + await reasoning_context.close() + reasoning_context = None + + if streaming_context: + await streaming_context.close() + streaming_context = None + + # Defensive: close any function call contexts that didn't see a + # ResponseOutputItemDoneEvent (truncated stream, error mid-call). + for call_data in function_calls_in_progress.values(): + call_context = call_data.get('context') + if call_context is not None: + try: + await call_context.close() + except Exception as e: + logger.warning(f"Failed to close orphaned tool request context: {e}") + call_data['context'] = None + + # Build the response from output items collected during streaming + # Create output from the items we collected + response_output = [] + + # Process output items from the response + if output_items: + for item in output_items: + if isinstance(item, ResponseFunctionToolCall): + response_output.append(item) + elif isinstance(item, ResponseOutputMessage): + response_output.append(item) + else: + response_output.append(item) + else: + # No output items - create empty message + message = ResponseOutputMessage( + id=f"msg_{uuid.uuid4().hex[:8]}", + type="message", + status="completed", + role="assistant", + content=[ResponseOutputText( + type="output_text", + text=current_text if current_text else "", + annotations=[] + )] + ) + response_output.append(message) + + # Use the real usage from the streaming Response if available; + # fall back to zeros only when the stream ended without a + # ResponseCompletedEvent (error paths). + if captured_usage is not None: + usage = Usage( + input_tokens=captured_usage.input_tokens, + output_tokens=captured_usage.output_tokens, + total_tokens=captured_usage.total_tokens, + input_tokens_details=InputTokensDetails( + cached_tokens=getattr( + captured_usage.input_tokens_details, "cached_tokens", 0 + ), + ), + output_tokens_details=OutputTokensDetails( + reasoning_tokens=getattr( + captured_usage.output_tokens_details, "reasoning_tokens", 0 + ), + ), + ) + else: + usage = Usage( + input_tokens=0, + output_tokens=0, + total_tokens=0, + input_tokens_details=InputTokensDetails(cached_tokens=0), + output_tokens_details=OutputTokensDetails(reasoning_tokens=0), + ) + + # Serialize response output items for span tracing + new_items = [] + final_output = None + tool_calls = [] + tool_outputs = [] + + for item in response_output: + try: + item_dict = _serialize_item(item) + if item_dict: + new_items.append(item_dict) + + # Extract final_output from message type if available + if item_dict.get('type') == 'message' and not final_output: + content = item_dict.get('content', []) + if content and isinstance(content, list): + for content_part in content: + if isinstance(content_part, dict) and 'text' in content_part: + final_output = content_part['text'] + break + except Exception as e: + logger.warning(f"Failed to serialize item in temporal_streaming_model: {e}") + continue + + # Extract tool calls and outputs from input + try: + if isinstance(input, list): + for item in input: + try: + item_dict = _serialize_item(item) if not isinstance(item, dict) else item + if item_dict: + # Capture function calls + if item_dict.get('type') == 'function_call': + tool_calls.append(item_dict) + # Capture function outputs + elif item_dict.get('type') == 'function_call_output': + tool_outputs.append(item_dict) + except Exception: + pass + except Exception as e: + logger.warning(f"Failed to extract tool calls and outputs: {e}") + + # Set span output with structured data + if span: + output_data = { + "new_items": new_items, + "final_output": final_output, + "usage": { + "input_tokens": usage.input_tokens, + "output_tokens": usage.output_tokens, + "total_tokens": usage.total_tokens, + "cached_input_tokens": usage.input_tokens_details.cached_tokens, + "reasoning_tokens": usage.output_tokens_details.reasoning_tokens, + }, + } + # Include tool calls if any were in the input + if tool_calls: + output_data["tool_calls"] = tool_calls + # Include tool outputs if any were processed + if tool_outputs: + output_data["tool_outputs"] = tool_outputs + + span.output = output_data + lineage_refs = resolve_refs_from_items(new_items) + if lineage_refs: + span.data = merge_refs_into_data(span.data, lineage_refs) + + # Streaming-only metrics. Token counters and the success request + # counter are emitted by LLMMetricsHooks.on_llm_end so they fire + # consistently across streaming and non-streaming paths. + m = get_llm_metrics() + metric_attrs = {"model": self.model_name} + if first_token_at is not None: + m.ttft_ms.record((first_token_at - stream_start_perf) * 1000, metric_attrs) + if first_answer_at is not None: + m.ttat_ms.record((first_answer_at - stream_start_perf) * 1000, metric_attrs) + # Single-token responses collapse the generation window to 0; tps + # is undefined and skipped. + if ( + first_token_at is not None + and last_token_at is not None + and last_token_at > first_token_at + and (usage.output_tokens or 0) > 0 + ): + m.tps.record(usage.output_tokens / (last_token_at - first_token_at), metric_attrs) + + # Return the response. response_id is the server-issued id from + # ResponseCompletedEvent.response.id, or None when the stream ended + # without a completed event (error path) — matching the documented + # `str | None` contract on `ModelResponse.response_id`. Returning + # None lets callers use it safely as `previous_response_id` for + # multi-turn chaining; a fabricated UUID would 400 against any real + # server. + return ModelResponse( + output=response_output, + usage=usage, + response_id=captured_response_id, + ) + + except Exception as e: + logger.error(f"Error using Responses API: {e}") + # LLMMetricsHooks.on_llm_end doesn't fire on error, so emit the + # failure counter here. Best-effort so the typed LLM exception + # always propagates intact for retry / circuit-breaker logic. + record_llm_failure(self.model_name, e) + raise + + # The _get_response_with_responses_api method has been merged into get_response above + # All Responses API logic is now integrated directly in get_response() method + + @override + def stream_response(self, *args, **kwargs): + """Streaming is not implemented as we use the async get_response method. + This method is included for compatibility with the Model interface but should not be used. + All streaming is handled through the async get_response method with the Responses API.""" + raise NotImplementedError("stream_response is not used in Temporal activities - use get_response instead") + + +class TemporalStreamingModelProvider(ModelProvider): + """Custom model provider that returns a streaming-capable model.""" + + def __init__( + self, + openai_client: Optional[AsyncOpenAI] = None, + streaming_mode: StreamingMode = "coalesced", + ): + """Initialize the provider. + + Args: + openai_client: Optional custom AsyncOpenAI client to use for all models. + If not provided, each model will create its own default client. + streaming_mode: Default streaming mode applied to every model returned by + this provider. See ``StreamingMode`` for the meaning of + each value. Defaults to "coalesced" — fast but still streamy. + """ + super().__init__() + self.openai_client = openai_client + self.streaming_mode: StreamingMode = streaming_mode + logger.info(f"[TemporalStreamingModelProvider] Initialized, custom_client={openai_client is not None}, streaming_mode={self.streaming_mode}") + + @override + def get_model(self, model_name: Union[str, None]) -> Model: + """Get a model instance with streaming capabilities. + + Args: + model_name: The name of the model to retrieve + + Returns: + A Model instance with streaming support. + """ + # Use the provided model_name or default to gpt-4o + actual_model = model_name if model_name else "gpt-4o" + logger.info(f"[TemporalStreamingModelProvider] Creating TemporalStreamingModel for model_name: {actual_model}") + model = TemporalStreamingModel( + model_name=actual_model, + openai_client=self.openai_client, + streaming_mode=self.streaming_mode, + ) + return model diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/run.py b/src/agentex/lib/core/temporal/plugins/openai_agents/run.py new file mode 100644 index 000000000..0fb21bfe4 --- /dev/null +++ b/src/agentex/lib/core/temporal/plugins/openai_agents/run.py @@ -0,0 +1,161 @@ +"""``run_turn`` — the unified entry point for the OpenAI Agents Temporal harness. + +This is the ``Runner.run`` analogue of the CLI harness's +``UnifiedEmitter.auto_send_turn``: it owns the repeatable per-turn concerns so +agents don't hand-roll them. + +What it does: + +1. Runs the agent via ``Runner.run`` with hooks that emit each tool call exactly + ONCE. The ``TemporalStreamingModelProvider`` already streams the tool-call + message from the model output, so the hooks are wired with + ``emit_messages=False`` to avoid the double-post; they still trace tool calls + (input + output) and emit token-usage metrics. +2. Normalizes token usage off the run result into a harness-independent + ``TurnUsage`` so callers can attach it to the turn span / task metadata, + matching what the CLI harness reports. + +What it deliberately does NOT do: sandboxing. Sandbox provisioning is a +composable concern carried on ``RunConfig`` (the SDK's ``SandboxRunConfig``) and +is passed straight through. Agent-specific lifecycle UI (e.g. surfacing sandbox +provisioning as a tool card) belongs in a caller-supplied ``hooks`` subclass, +not here. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from dataclasses import dataclass + +from agents import Runner + +from agentex.lib.utils.logging import make_logger +from agentex.lib.core.harness.types import TurnUsage +from agentex.lib.core.temporal.plugins.openai_agents.hooks.hooks import TemporalStreamingHooks + +if TYPE_CHECKING: + from agents import RunHooks, RunConfig + from agents.result import RunResult + +logger = make_logger(__name__) + +# Mirror the OpenAI Agents SDK default; callers can override per turn. +_DEFAULT_MAX_TURNS = 10 + + +@dataclass +class OpenAIAgentsTurnResult: + """The raw SDK run result plus normalized agentex usage. + + The raw ``result`` is kept so callers retain ``final_output``, + ``to_input_list()`` and any provider extras (e.g. sandbox resume state); + ``usage`` is the harness-independent token/cost summary for the turn span. + """ + + result: "RunResult" + usage: TurnUsage + + @property + def final_output(self) -> Any: + return self.result.final_output + + +def _extract_turn_usage(result: "RunResult", *, model: str | None = None) -> TurnUsage: + """Map the SDK's aggregated ``context_wrapper.usage`` onto ``TurnUsage``. + + Tolerant of a missing/partial Usage shape (non-OpenAI providers routed via + litellm may omit the nested token details) — absent fields stay None. + """ + usage = getattr(getattr(result, "context_wrapper", None), "usage", None) + if usage is None: + return TurnUsage(model=model) + + input_details = getattr(usage, "input_tokens_details", None) + output_details = getattr(usage, "output_tokens_details", None) + return TurnUsage( + model=model, + input_tokens=getattr(usage, "input_tokens", None), + output_tokens=getattr(usage, "output_tokens", None), + total_tokens=getattr(usage, "total_tokens", None), + cached_input_tokens=getattr(input_details, "cached_tokens", None), + reasoning_tokens=getattr(output_details, "reasoning_tokens", None), + num_llm_calls=getattr(usage, "requests", None), + ) + + +async def run_turn( + starting_agent: Any, + input: Any, + *, + task_id: str, + trace_id: str | None = None, + parent_span_id: str | None = None, + run_config: "RunConfig | None" = None, + hooks: "RunHooks | None" = None, + model: str | None = None, + max_turns: int = _DEFAULT_MAX_TURNS, +) -> OpenAIAgentsTurnResult: + """Run one agent turn and return the result plus normalized usage. + + Args: + starting_agent: The agent to run. + input: The input list / string passed to ``Runner.run``. + task_id: AgentEx task id for streaming. + trace_id: When set, tool calls are traced to SGP (input + output). Only + applied when ``hooks`` is omitted (it flows into the default + ``TemporalStreamingHooks``). Ignored when you pass your own ``hooks`` + — see ``hooks`` below. + parent_span_id: Parent span for the per-tool spans (typically the turn + span). Same caveat as ``trace_id``: only applied to the default hooks. + run_config: Forwarded to ``Runner.run`` verbatim (carries the model + provider and any ``SandboxRunConfig``). Left untouched here. + hooks: Optional hooks override. When omitted, a default + ``TemporalStreamingHooks(emit_tool_requests=False, ...)`` is used so + the streaming model is the sole tool-REQUEST emitter while the hooks + still emit tool RESPONSES (the model does not), and ``trace_id`` / + ``parent_span_id`` are forwarded into it. When you pass your own + subclass (also with ``emit_tool_requests=False``) to add agent-specific + lifecycle behavior such as a sandbox-ready card, ``trace_id`` and + ``parent_span_id`` are NOT applied for you — pass them to your + subclass's constructor yourself if you want tool spans traced. + model: Model name recorded on the returned usage; derived from the agent + when not supplied. + max_turns: Forwarded to ``Runner.run``. + + Returns: + OpenAIAgentsTurnResult with the raw run result and normalized usage. + """ + if hooks is None: + hooks = TemporalStreamingHooks( + task_id=task_id, + # The streaming model already posts the tool REQUEST, so suppress it + # here (no double-post) — but keep responses, which the model does not + # emit for function tools (on_tool_end is their only source). + emit_tool_requests=False, + emit_tool_responses=True, + trace_id=trace_id, + parent_span_id=parent_span_id, + ) + + run_kwargs: dict[str, Any] = {"hooks": hooks, "max_turns": max_turns} + if run_config is not None: + run_kwargs["run_config"] = run_config + + try: + result = await Runner.run(starting_agent, input, **run_kwargs) + finally: + # If the runner terminated mid-tool (max-turns, cancellation, SDK error), + # on_tool_end never fired for the in-flight call, leaving its span open. + # Drain any leftovers so they don't orphan in the tracing backend. + if isinstance(hooks, TemporalStreamingHooks): + await hooks.close_open_tool_spans() + + resolved_model = model + if resolved_model is None: + agent_model = getattr(starting_agent, "model", None) + resolved_model = str(agent_model) if agent_model else None + + return OpenAIAgentsTurnResult( + result=result, + usage=_extract_turn_usage(result, model=resolved_model), + ) diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/tests/__init__.py b/src/agentex/lib/core/temporal/plugins/openai_agents/tests/__init__.py new file mode 100644 index 000000000..0c635833b --- /dev/null +++ b/src/agentex/lib/core/temporal/plugins/openai_agents/tests/__init__.py @@ -0,0 +1,3 @@ +""" +Tests for the StreamingModel implementation in the OpenAI Agents plugin. +""" \ No newline at end of file diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/tests/conftest.py b/src/agentex/lib/core/temporal/plugins/openai_agents/tests/conftest.py new file mode 100644 index 000000000..aa7ad7b04 --- /dev/null +++ b/src/agentex/lib/core/temporal/plugins/openai_agents/tests/conftest.py @@ -0,0 +1,331 @@ +""" +Pytest configuration and fixtures for StreamingModel tests. +""" + +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +import pytest_asyncio +from agents import ( + Handoff, + FunctionTool, + ModelSettings, +) +from agents.tool import ( + ComputerTool, + HostedMCPTool, + WebSearchTool, + FileSearchTool, + LocalShellTool, + CodeInterpreterTool, + ImageGenerationTool, +) +from agents.computer import Computer +from agents.model_settings import Reasoning # type: ignore[attr-defined] +from openai.types.responses import ( + ResponseCompletedEvent, + ResponseTextDeltaEvent, + ResponseOutputItemAddedEvent, + ResponseReasoningSummaryTextDeltaEvent, +) + +# Configure pytest-asyncio +pytest_plugins = ("pytest_asyncio",) + + +@pytest.fixture +def mock_openai_client(): + """Mock AsyncOpenAI client""" + client = MagicMock() + client.responses = MagicMock() + return client + + +@pytest.fixture +def sample_task_id(): + """Generate a sample task ID""" + return f"task_{uuid.uuid4().hex[:8]}" + + +@pytest.fixture +def _streaming_context_vars(sample_task_id): + """Populate the streaming ContextVars that ContextInterceptor sets from + request headers in real Temporal flows. TemporalStreamingModel.get_response() + validates that all three are set before doing any work, so any test that + calls get_response() must request this fixture. + + Named with a leading underscore so tests can request it purely for its + setup/teardown side effects without ruff flagging it as an unused argument + (ARG002). The yielded value is the task_id set on the ContextVar, available + for tests that need to assert against it. + """ + from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ( + streaming_task_id, + streaming_trace_id, + streaming_parent_span_id, + ) + task_token = streaming_task_id.set(sample_task_id) + trace_token = streaming_trace_id.set("test-trace-id") + span_token = streaming_parent_span_id.set("test-parent-span-id") + try: + yield sample_task_id + finally: + streaming_task_id.reset(task_token) + streaming_trace_id.reset(trace_token) + streaming_parent_span_id.reset(span_token) + + +@pytest.fixture +def mock_streaming_context(): + """Mock streaming context for testing""" + context = AsyncMock() + context.task_message = MagicMock() + context.stream_update = AsyncMock() + context.close = AsyncMock() + context.__aenter__ = AsyncMock(return_value=context) + context.__aexit__ = AsyncMock() + return context + + +@pytest.fixture(autouse=True) +def mock_adk_streaming(): + """Mock the ADK streaming module""" + with patch('agentex.lib.adk.streaming') as mock_streaming: + mock_context = AsyncMock() + mock_context.task_message = MagicMock() + mock_context.stream_update = AsyncMock() + mock_context.close = AsyncMock() + mock_context.__aenter__ = AsyncMock(return_value=mock_context) + mock_context.__aexit__ = AsyncMock() + + mock_streaming.streaming_task_message_context.return_value = mock_context + yield mock_streaming + + +@pytest.fixture +def sample_function_tool(): + """Sample FunctionTool for testing""" + async def mock_tool_handler(_context, _args): + return {"temperature": "72F", "condition": "sunny"} + + return FunctionTool( + name="get_weather", + description="Get the current weather", + params_json_schema={ + "type": "object", + "properties": { + "location": {"type": "string"} + } + }, + on_invoke_tool=mock_tool_handler, + strict_json_schema=False + ) + + +@pytest.fixture +def sample_web_search_tool(): + """Sample WebSearchTool for testing""" + return WebSearchTool( + user_location=None, + search_context_size="medium" + ) + + +@pytest.fixture +def sample_file_search_tool(): + """Sample FileSearchTool for testing""" + return FileSearchTool( + vector_store_ids=["vs_123"], + max_num_results=10, + include_search_results=True + ) + + +@pytest.fixture +def sample_computer_tool(): + """Sample ComputerTool for testing. + + Production validates ``isinstance(computer, (Computer, AsyncComputer))`` for + Responses API serialization, so the mock must be ``spec``-bound to + ``Computer`` for the isinstance check to pass. + """ + computer = MagicMock(spec=Computer) + computer.environment = "desktop" + computer.dimensions = [1920, 1080] + return ComputerTool(computer=computer) + + +@pytest.fixture +def sample_hosted_mcp_tool(): + """Sample HostedMCPTool for testing""" + tool = MagicMock(spec=HostedMCPTool) + tool.tool_config = { + "type": "mcp", + "server_label": "test_server", + "name": "test_tool" + } + return tool + + +@pytest.fixture +def sample_image_generation_tool(): + """Sample ImageGenerationTool for testing""" + tool = MagicMock(spec=ImageGenerationTool) + tool.tool_config = { + "type": "image_generation", + "model": "dall-e-3" + } + return tool + + +@pytest.fixture +def sample_code_interpreter_tool(): + """Sample CodeInterpreterTool for testing""" + tool = MagicMock(spec=CodeInterpreterTool) + tool.tool_config = { + "type": "code_interpreter" + } + return tool + + +@pytest.fixture +def sample_local_shell_tool(): + """Sample LocalShellTool for testing""" + from agents import LocalShellExecutor + executor = MagicMock(spec=LocalShellExecutor) + return LocalShellTool(executor=executor) + + +@pytest.fixture +def sample_handoff(): + """Sample Handoff for testing""" + from agents import Agent + + async def mock_handoff_handler(_context, _args): + # Return a mock agent + return MagicMock(spec=Agent) + + return Handoff( + agent_name="support_agent", + tool_name="transfer_to_support", + tool_description="Transfer to support agent", + input_json_schema={"type": "object"}, + on_invoke_handoff=mock_handoff_handler + ) + + +@pytest.fixture +def basic_model_settings(): + """Basic ModelSettings for testing""" + return ModelSettings( + temperature=0.7, + max_tokens=1000, + top_p=0.9 + ) + + +@pytest.fixture +def reasoning_model_settings(): + """ModelSettings with reasoning enabled""" + return ModelSettings( + reasoning=Reasoning( + effort="medium", + generate_summary="auto" + ) + ) + + +@pytest.fixture +def mock_response_stream(): + """Mock a response stream with basic events""" + async def stream_generator(): + # Yield some basic events + yield ResponseOutputItemAddedEvent( # type: ignore[call-arg] + type="response.output_item.added", + output_index=0, + item=MagicMock(type="message") + ) + + yield ResponseTextDeltaEvent( # type: ignore[call-arg] + type="response.text.delta", + delta="Hello ", + output_index=0 + ) + + yield ResponseTextDeltaEvent( # type: ignore[call-arg] + type="response.text.delta", + delta="world!", + output_index=0 + ) + + yield ResponseCompletedEvent( # type: ignore[call-arg] + type="response.completed", + response=MagicMock( + output=[], + usage=MagicMock() + ) + ) + + return stream_generator() + + +@pytest.fixture +def mock_reasoning_stream(): + """Mock a response stream with reasoning events""" + async def stream_generator(): + # Start reasoning + yield ResponseOutputItemAddedEvent( # type: ignore[call-arg] + type="response.output_item.added", + output_index=0, + item=MagicMock(type="reasoning") + ) + + # Reasoning deltas + yield ResponseReasoningSummaryTextDeltaEvent( # type: ignore[call-arg] + type="response.reasoning_summary_text.delta", + delta="Let me think about this...", + summary_index=0 + ) + + # Complete + yield ResponseCompletedEvent( # type: ignore[call-arg] + type="response.completed", + response=MagicMock( + output=[], + usage=MagicMock() + ) + ) + + return stream_generator() + + +@pytest_asyncio.fixture(scope="function") +async def streaming_model(): + """Create a TemporalStreamingModel instance for testing""" + from ..models.temporal_streaming_model import TemporalStreamingModel + + model = TemporalStreamingModel(model_name="gpt-4o") + # Mock the OpenAI client with fresh mocks for each test + model.client = AsyncMock() + model.client.responses = AsyncMock() + + yield model + + # Cleanup after each test + if hasattr(model.client, 'close'): + await model.client.close() + + +# Mock environment variables for testing +@pytest.fixture(autouse=True) +def mock_env_vars(): + """Mock environment variables""" + env_vars = { + "OPENAI_API_KEY": "test-key-123", + "AGENT_NAME": "test-agent", + "ACP_URL": "http://localhost:8000", + } + + with patch.dict("os.environ", env_vars): + yield env_vars \ No newline at end of file diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_convert_tools.py b/src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_convert_tools.py new file mode 100644 index 000000000..56a77c5cb --- /dev/null +++ b/src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_convert_tools.py @@ -0,0 +1,61 @@ +"""Unit tests for TemporalStreamingModel._convert_tools tool serialization.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from agentex.lib.core.temporal.plugins.openai_agents.models import ( + temporal_streaming_model as tsm_module, +) +from agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model import ( + TemporalStreamingModel, +) + + +@pytest.fixture +def model(): + with patch( + "agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model.create_async_agentex_client" + ): + return TemporalStreamingModel(model_name="gpt-4o", openai_client=MagicMock()) + + +class _FakeShellTool: + """Stand-in for agents.tool.ShellTool for environments where it isn't installed.""" + + def __init__(self, environment): + self.environment = environment + + +def test_shell_tool_local_environment(model, monkeypatch): + """ShellTool with a local environment should serialize to a 'shell' payload.""" + monkeypatch.setattr(tsm_module, "ShellTool", _FakeShellTool) + + tool = _FakeShellTool(environment={"type": "local", "skills": ["git"]}) + response_tools, _ = model._convert_tools([tool], handoffs=[]) + + assert response_tools == [{"type": "shell", "environment": {"type": "local", "skills": ["git"]}}] + + +def test_shell_tool_defaults_environment_when_missing(model, monkeypatch): + """ShellTool with environment=None should fall back to {'type': 'local'}.""" + monkeypatch.setattr(tsm_module, "ShellTool", _FakeShellTool) + + tool = _FakeShellTool(environment=None) + response_tools, _ = model._convert_tools([tool], handoffs=[]) + + assert response_tools == [{"type": "shell", "environment": {"type": "local"}}] + + +def test_shell_tool_unavailable_falls_through(model, monkeypatch, caplog): + """If ShellTool isn't installed, an unknown tool should log a warning and be skipped.""" + monkeypatch.setattr(tsm_module, "ShellTool", None) + + class _NotAShellTool: + pass + + with caplog.at_level("WARNING"): + response_tools, _ = model._convert_tools([_NotAShellTool()], handoffs=[]) + + assert response_tools == [] + assert any("Unknown tool type" in rec.message for rec in caplog.records) diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_hosted_tools.py b/src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_hosted_tools.py new file mode 100644 index 000000000..066d6f2ed --- /dev/null +++ b/src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_hosted_tools.py @@ -0,0 +1,135 @@ +"""Unit tests for hosted/server-side tool rendering helpers. + +These cover the pure extraction helpers used by TemporalStreamingModel to surface +Responses-API hosted tools (web_search, file_search, code_interpreter, mcp, ...) +as ToolRequest/ToolResponse pairs. They never become function_call items, so the +streaming loop must render them explicitly. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +from openai.types.responses.response_output_item import ( + LocalShellCall, + ImageGenerationCall, + LocalShellCallAction, +) +from openai.types.responses.response_computer_tool_call import ActionClick, ResponseComputerToolCall +from openai.types.responses.response_function_web_search import ActionSearch, ResponseFunctionWebSearch + +from agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model import ( + _HOSTED_TOOL_TYPES, + _coerce_args, + _hosted_tool_result, + _hosted_tool_request, +) + + +def test_hosted_tool_types_membership(): + for t in ("web_search_call", "file_search_call", "code_interpreter_call", + "image_generation_call", "mcp_call", "computer_call", "local_shell_call"): + assert t in _HOSTED_TOOL_TYPES + assert "function_call" not in _HOSTED_TOOL_TYPES + + +def test_coerce_args_variants(): + assert _coerce_args(None) == {} + assert _coerce_args({"a": 1}) == {"a": 1} + assert _coerce_args('{"a": 1}') == {"a": 1} + assert _coerce_args("[1, 2]") == {"value": [1, 2]} + assert _coerce_args("not json") == {"raw": "not json"} + + +def test_hosted_tool_request_web_search(): + # Use the real Responses-API type to prove `action` is a genuine SDK field + # (it is on ResponseFunctionWebSearch), not a hand-crafted stand-in. + item = ResponseFunctionWebSearch( + id="ws_1", + status="completed", + type="web_search_call", + action=ActionSearch(type="search", query="agentex"), + ) + call_id, name, args = _hosted_tool_request(item) + assert call_id == "ws_1" + assert name == "web_search" # "_call" stripped + assert args["query"] == "agentex" + assert args["type"] == "search" + + +def test_hosted_tool_request_computer_call(): + item = ResponseComputerToolCall( + id="cc_1", + call_id="ccall_1", + type="computer_call", + status="completed", + pending_safety_checks=[], + action=ActionClick(type="click", button="left", x=10, y=20), + ) + call_id, name, args = _hosted_tool_request(item) + assert call_id == "cc_1" + assert name == "computer" + assert args["type"] == "click" + assert args["button"] == "left" + assert args["x"] == 10 and args["y"] == 20 + + +def test_hosted_tool_request_local_shell_call(): + item = LocalShellCall( + id="ls_1", + call_id="lscall_1", + type="local_shell_call", + status="completed", + action=LocalShellCallAction(type="exec", command=["ls", "-la"], env={}), + ) + call_id, name, args = _hosted_tool_request(item) + assert call_id == "ls_1" + assert name == "local_shell" + assert args["command"] == ["ls", "-la"] + + +def test_hosted_tool_request_mcp_uses_server_label(): + item = SimpleNamespace(type="mcp_call", id="m_1", name="search", + server_label="linear", arguments='{"q": "x"}') + call_id, name, args = _hosted_tool_request(item) + assert call_id == "m_1" + assert name == "linear.search" + assert args == {"q": "x"} + + +def test_hosted_tool_request_file_search_queries(): + item = SimpleNamespace(type="file_search_call", id="fs_1", + queries=["q1", "q2"]) + _, name, args = _hosted_tool_request(item) + assert name == "file_search" + assert args == {"queries": ["q1", "q2"]} + + +def test_hosted_tool_request_falls_back_to_generated_id(): + item = SimpleNamespace(type="code_interpreter_call", code="print(1)") + call_id, name, args = _hosted_tool_request(item) + assert call_id.startswith("hosted_") + assert name == "code_interpreter" + assert args == {"code": "print(1)"} + + +def test_hosted_tool_result_mcp_error_and_output(): + err_item = SimpleNamespace(type="mcp_call", error="boom") + assert "boom" in _hosted_tool_result(err_item) + ok_item = SimpleNamespace(type="mcp_call", error=None, output="done") + assert _hosted_tool_result(ok_item) == "done" + + +def test_hosted_tool_result_image_generation(): + item = ImageGenerationCall( + id="ig_1", + type="image_generation_call", + status="completed", + result="QUJD", # 4 chars of (fake) base64 + ) + assert _hosted_tool_result(item) == "" + + +def test_hosted_tool_result_falls_back_to_status(): + item = SimpleNamespace(type="web_search_call", status="completed") + assert _hosted_tool_result(item) == "completed" diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_run_turn_and_hooks.py b/src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_run_turn_and_hooks.py new file mode 100644 index 000000000..244182ac5 --- /dev/null +++ b/src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_run_turn_and_hooks.py @@ -0,0 +1,247 @@ +"""Tests for the unified OpenAI-Agents turn surface. + +Covers: +- ``TemporalStreamingHooks`` message-emission gating (``emit_messages``), so the + streaming model can be the sole tool-message emitter (no double-post). +- ``TemporalStreamingHooks`` input-bearing tool spans (input = arguments, + output = result) when a ``trace_id`` is provided. +- ``run_turn`` usage extraction and default-hooks wiring. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from agents.tool_context import ToolContext + +from agentex.lib.core.temporal.plugins.openai_agents import run as run_mod +from agentex.lib.core.temporal.plugins.openai_agents.hooks import hooks as hooks_mod + +TemporalStreamingHooks = hooks_mod.TemporalStreamingHooks + + +def _tool_context(args: str = '{"query": "hi"}') -> ToolContext: + return ToolContext( + context=None, + tool_name="search", + tool_call_id="call_abc", + tool_arguments=args, + ) + + +def _tool() -> MagicMock: + tool = MagicMock() + tool.name = "search" + return tool + + +# --------------------------------------------------------------------------- # +# Argument parsing +# --------------------------------------------------------------------------- # + + +def test_parse_tool_arguments_valid_dict(): + assert TemporalStreamingHooks._parse_tool_arguments(_tool_context('{"a": 1}')) == {"a": 1} + + +def test_parse_tool_arguments_garbage_is_empty(): + assert TemporalStreamingHooks._parse_tool_arguments(_tool_context("not json")) == {} + + +def test_parse_tool_arguments_non_tool_context_is_empty(): + assert TemporalStreamingHooks._parse_tool_arguments(SimpleNamespace()) == {} + + +# --------------------------------------------------------------------------- # +# Message emission gating (the double-post fix + the response-survival guard) +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_defaults_stream_tool_request(monkeypatch): + exec_activity = AsyncMock() + monkeypatch.setattr(hooks_mod.workflow, "execute_activity", exec_activity) + + hooks = TemporalStreamingHooks(task_id="t1") # all emit flags default True + await hooks.on_tool_start(_tool_context(), MagicMock(), _tool()) + + exec_activity.assert_awaited_once() + # args=[task_id, ToolRequestContent.model_dump()] + _, kwargs = exec_activity.call_args + payload = kwargs["args"][1] + assert payload["name"] == "search" + assert payload["arguments"] == {"query": "hi"} + + +@pytest.mark.asyncio +async def test_requests_off_skips_request_but_keeps_response(monkeypatch): + """The streaming-model pairing: suppress the duplicate REQUEST, but the + RESPONSE must still emit (the model never emits function-tool responses).""" + exec_activity = AsyncMock() + monkeypatch.setattr(hooks_mod.workflow, "execute_activity", exec_activity) + + hooks = TemporalStreamingHooks(task_id="t1", emit_tool_requests=False, emit_tool_responses=True) + await hooks.on_tool_start(_tool_context(), MagicMock(), _tool()) + exec_activity.assert_not_awaited() # request suppressed + + await hooks.on_tool_end(_tool_context(), MagicMock(), _tool(), "the result") + exec_activity.assert_awaited_once() # response still emitted + _, kwargs = exec_activity.call_args + payload = kwargs["args"][1] + assert payload["name"] == "search" + assert payload["content"] == "the result" + + +@pytest.mark.asyncio +async def test_responses_off_skips_response(monkeypatch): + exec_activity = AsyncMock() + monkeypatch.setattr(hooks_mod.workflow, "execute_activity", exec_activity) + + hooks = TemporalStreamingHooks(task_id="t1", emit_tool_responses=False) + await hooks.on_tool_end(_tool_context(), MagicMock(), _tool(), "result") + + exec_activity.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_emit_handoffs_false_skips_handoff(monkeypatch): + exec_activity = AsyncMock() + monkeypatch.setattr(hooks_mod.workflow, "execute_activity", exec_activity) + + hooks = TemporalStreamingHooks(task_id="t1", emit_handoffs=False) + await hooks.on_handoff(MagicMock(), MagicMock(name="from"), MagicMock(name="to")) + + exec_activity.assert_not_awaited() + + +# --------------------------------------------------------------------------- # +# Input-bearing tool spans (the "traces have outputs but no inputs" fix) +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_tool_span_carries_input_and_output(monkeypatch): + monkeypatch.setattr(hooks_mod.workflow, "execute_activity", AsyncMock()) + span = SimpleNamespace(output=None) + start_span = AsyncMock(return_value=span) + end_span = AsyncMock() + fake_adk = SimpleNamespace(tracing=SimpleNamespace(start_span=start_span, end_span=end_span)) + monkeypatch.setattr(hooks_mod, "_get_adk", lambda: fake_adk) + + hooks = TemporalStreamingHooks( + task_id="t1", emit_tool_requests=False, trace_id="trace-1", parent_span_id="parent-1" + ) + await hooks.on_tool_start(_tool_context(), MagicMock(), _tool()) + + start_span.assert_awaited_once() + _, kwargs = start_span.call_args + assert kwargs["name"] == "search" + assert kwargs["parent_id"] == "parent-1" + assert kwargs["input"] == {"arguments": {"query": "hi"}} + + await hooks.on_tool_end(_tool_context(), MagicMock(), _tool(), "the answer") + end_span.assert_awaited_once() + assert span.output == {"result": "the answer"} + + +@pytest.mark.asyncio +async def test_no_trace_id_means_no_span(monkeypatch): + monkeypatch.setattr(hooks_mod.workflow, "execute_activity", AsyncMock()) + start_span = AsyncMock() + fake_adk = SimpleNamespace(tracing=SimpleNamespace(start_span=start_span)) + monkeypatch.setattr(hooks_mod, "_get_adk", lambda: fake_adk) + + hooks = TemporalStreamingHooks(task_id="t1", emit_tool_requests=False, trace_id=None) + await hooks.on_tool_start(_tool_context(), MagicMock(), _tool()) + + start_span.assert_not_awaited() + + +# --------------------------------------------------------------------------- # +# Usage extraction +# --------------------------------------------------------------------------- # + + +def _result_with_usage() -> SimpleNamespace: + usage = SimpleNamespace( + requests=3, + input_tokens=100, + output_tokens=40, + total_tokens=140, + input_tokens_details=SimpleNamespace(cached_tokens=20), + output_tokens_details=SimpleNamespace(reasoning_tokens=10), + ) + return SimpleNamespace(context_wrapper=SimpleNamespace(usage=usage), final_output="done") + + +def test_extract_turn_usage_maps_fields(): + usage = run_mod._extract_turn_usage(_result_with_usage(), model="openai/gpt-5.5") + assert usage.model == "openai/gpt-5.5" + assert usage.input_tokens == 100 + assert usage.output_tokens == 40 + assert usage.total_tokens == 140 + assert usage.cached_input_tokens == 20 + assert usage.reasoning_tokens == 10 + assert usage.num_llm_calls == 3 + + +def test_extract_turn_usage_missing_usage_is_tolerant(): + usage = run_mod._extract_turn_usage(SimpleNamespace(), model="m") + assert usage.model == "m" + assert usage.input_tokens is None + assert usage.num_llm_calls is None + + +# --------------------------------------------------------------------------- # +# run_turn +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_run_turn_returns_usage_and_passes_through_result(monkeypatch): + fake_result = _result_with_usage() + runner_run = AsyncMock(return_value=fake_result) + monkeypatch.setattr(run_mod.Runner, "run", runner_run) + + agent = SimpleNamespace(model="openai/gpt-5.5") + out = await run_mod.run_turn( + agent, + [{"role": "user", "content": "hi"}], + task_id="t1", + trace_id="trace-1", + parent_span_id="parent-1", + ) + + assert isinstance(out, run_mod.OpenAIAgentsTurnResult) + assert out.final_output == "done" + assert out.usage.total_tokens == 140 + assert out.usage.model == "openai/gpt-5.5" + + # Default hooks must be wired so the streaming model is the sole tool-REQUEST + # emitter, while the hooks still emit tool RESPONSES (the model does not). + runner_run.assert_awaited_once() + _, kwargs = runner_run.call_args + hooks = kwargs["hooks"] + assert hooks.emit_tool_requests is False + assert hooks.emit_tool_responses is True + assert hooks.trace_id == "trace-1" + assert hooks.parent_span_id == "parent-1" + + +@pytest.mark.asyncio +async def test_run_turn_respects_supplied_hooks(monkeypatch): + runner_run = AsyncMock(return_value=_result_with_usage()) + monkeypatch.setattr(run_mod.Runner, "run", runner_run) + + custom_hooks = TemporalStreamingHooks(task_id="t1", emit_tool_requests=False) + await run_mod.run_turn( + SimpleNamespace(model="m"), + "hi", + task_id="t1", + hooks=custom_hooks, + ) + + _, kwargs = runner_run.call_args + assert kwargs["hooks"] is custom_hooks diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_streaming_model.py b/src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_streaming_model.py new file mode 100644 index 000000000..26c0b7c4b --- /dev/null +++ b/src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_streaming_model.py @@ -0,0 +1,1427 @@ +""" +Comprehensive tests for StreamingModel with all configurations and tool types. +""" + +from typing import Optional +from unittest.mock import AsyncMock, MagicMock + +import pytest +from agents import ModelSettings +from openai import NOT_GIVEN +from agents.model_settings import Reasoning, MCPToolChoice # type: ignore[attr-defined] +from openai.types.responses import ( + ResponseCompletedEvent, + ResponseTextDeltaEvent, + ResponseOutputItemDoneEvent, + ResponseOutputItemAddedEvent, + ResponseFunctionCallArgumentsDoneEvent, + ResponseReasoningSummaryTextDeltaEvent, + ResponseFunctionCallArgumentsDeltaEvent, +) + + +class TestStreamingModelSettings: + """Test that all ModelSettings parameters work with Responses API""" + + @pytest.mark.asyncio + async def test_temperature_setting(self, streaming_model, _streaming_context_vars): + """Test that temperature parameter is properly passed to Responses API""" + streaming_model.client.responses.create = AsyncMock() + + # Mock the response stream + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([ + MagicMock(type="response.completed", response=MagicMock(output=[])) + ]) + streaming_model.client.responses.create.return_value = mock_stream + + # Test with various temperature values + for temp in [0.0, 0.7, 1.5, 2.0]: + settings = ModelSettings(temperature=temp) + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=settings, + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + # Verify temperature was passed correctly + create_call = streaming_model.client.responses.create.call_args + assert create_call.kwargs['temperature'] == temp + + @pytest.mark.asyncio + async def test_top_p_setting(self, streaming_model, _streaming_context_vars): + """Test that top_p parameter is properly passed to Responses API""" + streaming_model.client.responses.create = AsyncMock() + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([ + MagicMock(type="response.completed", response=MagicMock(output=[])) + ]) + streaming_model.client.responses.create.return_value = mock_stream + + # Test with various top_p values + for top_p in [0.1, 0.5, 0.9, None]: + settings = ModelSettings(top_p=top_p) + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=settings, + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + create_call = streaming_model.client.responses.create.call_args + expected = top_p if top_p is not None else NOT_GIVEN + assert create_call.kwargs['top_p'] == expected + + @pytest.mark.asyncio + async def test_max_tokens_setting(self, streaming_model, _streaming_context_vars): + """Test that max_tokens is properly mapped to max_output_tokens""" + streaming_model.client.responses.create = AsyncMock() + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([ + MagicMock(type="response.completed", response=MagicMock(output=[])) + ]) + streaming_model.client.responses.create.return_value = mock_stream + + settings = ModelSettings(max_tokens=2000) + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=settings, + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + create_call = streaming_model.client.responses.create.call_args + assert create_call.kwargs['max_output_tokens'] == 2000 + + @pytest.mark.asyncio + async def test_reasoning_effort_settings(self, streaming_model, _streaming_context_vars): + """Test reasoning effort levels (low/medium/high)""" + streaming_model.client.responses.create = AsyncMock() + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([ + MagicMock(type="response.completed", response=MagicMock(output=[])) + ]) + streaming_model.client.responses.create.return_value = mock_stream + + for effort in ["low", "medium", "high"]: + settings = ModelSettings( + reasoning=Reasoning(effort=effort) + ) + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=settings, + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + create_call = streaming_model.client.responses.create.call_args + assert create_call.kwargs['reasoning'] == {"effort": effort} + + @pytest.mark.asyncio + async def test_reasoning_summary_settings(self, streaming_model, _streaming_context_vars): + """Test reasoning summary settings (auto/none)""" + streaming_model.client.responses.create = AsyncMock() + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([ + MagicMock(type="response.completed", response=MagicMock(output=[])) + ]) + streaming_model.client.responses.create.return_value = mock_stream + + for summary in ["auto", "concise", "detailed"]: + settings = ModelSettings( + reasoning=Reasoning(effort="medium", generate_summary=summary) + ) + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=settings, + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + create_call = streaming_model.client.responses.create.call_args + assert create_call.kwargs['reasoning'] == {"effort": "medium", "summary": summary} + + @pytest.mark.asyncio + async def test_tool_choice_variations(self, streaming_model, _streaming_context_vars, sample_function_tool): + """Test various tool_choice settings""" + streaming_model.client.responses.create = AsyncMock() + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([ + MagicMock(type="response.completed", response=MagicMock(output=[])) + ]) + streaming_model.client.responses.create.return_value = mock_stream + + # Test different tool_choice options + test_cases = [ + ("auto", "auto"), + ("required", "required"), + ("none", "none"), + ("get_weather", {"type": "function", "name": "get_weather"}), + ("web_search", {"type": "web_search"}), + (MCPToolChoice(server_label="test", name="tool"), {"server_label": "test", "type": "mcp", "name": "tool"}) + ] + + for tool_choice, expected in test_cases: + settings = ModelSettings(tool_choice=tool_choice) + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=settings, + tools=[sample_function_tool], + output_schema=None, + handoffs=[], + tracing=None, + ) + + create_call = streaming_model.client.responses.create.call_args + assert create_call.kwargs['tool_choice'] == expected + + @pytest.mark.asyncio + async def test_parallel_tool_calls(self, streaming_model, _streaming_context_vars, sample_function_tool): + """Test parallel tool calls setting""" + streaming_model.client.responses.create = AsyncMock() + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([ + MagicMock(type="response.completed", response=MagicMock(output=[])) + ]) + streaming_model.client.responses.create.return_value = mock_stream + + for parallel in [True, False]: + settings = ModelSettings(parallel_tool_calls=parallel) + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=settings, + tools=[sample_function_tool], + output_schema=None, + handoffs=[], + tracing=None, + ) + + create_call = streaming_model.client.responses.create.call_args + assert create_call.kwargs['parallel_tool_calls'] == parallel + + @pytest.mark.asyncio + async def test_truncation_strategy(self, streaming_model, _streaming_context_vars): + """Test truncation parameter""" + streaming_model.client.responses.create = AsyncMock() + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([ + MagicMock(type="response.completed", response=MagicMock(output=[])) + ]) + streaming_model.client.responses.create.return_value = mock_stream + + # truncation now accepts 'auto' or 'disabled' string literals + settings = ModelSettings(truncation="auto") + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=settings, + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + create_call = streaming_model.client.responses.create.call_args + assert create_call.kwargs['truncation'] == "auto" + + @pytest.mark.asyncio + async def test_response_include(self, streaming_model, _streaming_context_vars, sample_file_search_tool): + """Test response include parameter""" + streaming_model.client.responses.create = AsyncMock() + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([ + MagicMock(type="response.completed", response=MagicMock(output=[])) + ]) + streaming_model.client.responses.create.return_value = mock_stream + + settings = ModelSettings( + response_include=["reasoning.encrypted_content", "message.output_text.logprobs"] + ) + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=settings, + tools=[sample_file_search_tool], # This adds file_search_call.results + output_schema=None, + handoffs=[], + tracing=None, + ) + + create_call = streaming_model.client.responses.create.call_args + include_list = create_call.kwargs['include'] + assert "reasoning.encrypted_content" in include_list + assert "message.output_text.logprobs" in include_list + assert "file_search_call.results" in include_list # Added by file search tool + + @pytest.mark.asyncio + async def test_verbosity(self, streaming_model, _streaming_context_vars): + """Test verbosity settings""" + streaming_model.client.responses.create = AsyncMock() + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([ + MagicMock(type="response.completed", response=MagicMock(output=[])) + ]) + streaming_model.client.responses.create.return_value = mock_stream + + settings = ModelSettings(verbosity="high") + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=settings, + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + create_call = streaming_model.client.responses.create.call_args + assert create_call.kwargs['text'] == {"verbosity": "high"} + + @pytest.mark.asyncio + async def test_metadata_and_store(self, streaming_model, _streaming_context_vars): + """Test metadata and store parameters""" + streaming_model.client.responses.create = AsyncMock() + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([ + MagicMock(type="response.completed", response=MagicMock(output=[])) + ]) + streaming_model.client.responses.create.return_value = mock_stream + + metadata = {"user_id": "123", "session": "abc"} + store = True + + settings = ModelSettings( + metadata=metadata, + store=store + ) + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=settings, + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + create_call = streaming_model.client.responses.create.call_args + assert create_call.kwargs['metadata'] == metadata + assert create_call.kwargs['store'] == store + + @pytest.mark.asyncio + async def test_extra_headers_and_body(self, streaming_model, _streaming_context_vars): + """Test extra customization parameters""" + streaming_model.client.responses.create = AsyncMock() + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([ + MagicMock(type="response.completed", response=MagicMock(output=[])) + ]) + streaming_model.client.responses.create.return_value = mock_stream + + extra_headers = {"X-Custom": "header"} + extra_body = {"custom_field": "value"} + extra_query = {"param": "value"} + + settings = ModelSettings( + extra_headers=extra_headers, + extra_body=extra_body, + extra_query=extra_query + ) + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=settings, + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + create_call = streaming_model.client.responses.create.call_args + assert create_call.kwargs['extra_headers'] == extra_headers + assert create_call.kwargs['extra_body'] == extra_body + assert create_call.kwargs['extra_query'] == extra_query + + @pytest.mark.asyncio + async def test_top_logprobs(self, streaming_model, _streaming_context_vars): + """Test top_logprobs parameter""" + streaming_model.client.responses.create = AsyncMock() + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([ + MagicMock(type="response.completed", response=MagicMock(output=[])) + ]) + streaming_model.client.responses.create.return_value = mock_stream + + settings = ModelSettings(top_logprobs=5) + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=settings, + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + create_call = streaming_model.client.responses.create.call_args + # top_logprobs goes into extra_args + assert "top_logprobs" in create_call.kwargs + assert create_call.kwargs['top_logprobs'] == 5 + # Also should add to include list + assert "message.output_text.logprobs" in create_call.kwargs['include'] + + +class TestStreamingModelTools: + """Test that all tool types work with streaming""" + + @pytest.mark.asyncio + async def test_function_tool(self, streaming_model, _streaming_context_vars, sample_function_tool): + """Test FunctionTool conversion and streaming""" + streaming_model.client.responses.create = AsyncMock() + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([ + MagicMock(type="response.completed", response=MagicMock(output=[])) + ]) + streaming_model.client.responses.create.return_value = mock_stream + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=ModelSettings(), + tools=[sample_function_tool], + output_schema=None, + handoffs=[], + tracing=None, + ) + + create_call = streaming_model.client.responses.create.call_args + tools = create_call.kwargs['tools'] + assert len(tools) == 1 + assert tools[0]['type'] == 'function' + assert tools[0]['name'] == 'get_weather' + assert tools[0]['description'] == 'Get the current weather' + assert 'parameters' in tools[0] + + @pytest.mark.asyncio + async def test_web_search_tool(self, streaming_model, _streaming_context_vars, sample_web_search_tool): + """Test WebSearchTool conversion""" + streaming_model.client.responses.create = AsyncMock() + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([ + MagicMock(type="response.completed", response=MagicMock(output=[])) + ]) + streaming_model.client.responses.create.return_value = mock_stream + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=ModelSettings(), + tools=[sample_web_search_tool], + output_schema=None, + handoffs=[], + tracing=None, + ) + + create_call = streaming_model.client.responses.create.call_args + tools = create_call.kwargs['tools'] + assert len(tools) == 1 + assert tools[0]['type'] == 'web_search' + + @pytest.mark.asyncio + async def test_file_search_tool(self, streaming_model, _streaming_context_vars, sample_file_search_tool): + """Test FileSearchTool conversion""" + streaming_model.client.responses.create = AsyncMock() + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([ + MagicMock(type="response.completed", response=MagicMock(output=[])) + ]) + streaming_model.client.responses.create.return_value = mock_stream + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=ModelSettings(), + tools=[sample_file_search_tool], + output_schema=None, + handoffs=[], + tracing=None, + ) + + create_call = streaming_model.client.responses.create.call_args + tools = create_call.kwargs['tools'] + assert len(tools) == 1 + assert tools[0]['type'] == 'file_search' + assert tools[0]['vector_store_ids'] == ['vs_123'] + assert tools[0]['max_num_results'] == 10 + + @pytest.mark.asyncio + async def test_computer_tool(self, streaming_model, _streaming_context_vars, sample_computer_tool): + """Test ComputerTool conversion""" + streaming_model.client.responses.create = AsyncMock() + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([ + MagicMock(type="response.completed", response=MagicMock(output=[])) + ]) + streaming_model.client.responses.create.return_value = mock_stream + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=ModelSettings(), + tools=[sample_computer_tool], + output_schema=None, + handoffs=[], + tracing=None, + ) + + create_call = streaming_model.client.responses.create.call_args + tools = create_call.kwargs['tools'] + assert len(tools) == 1 + assert tools[0]['type'] == 'computer_use_preview' + assert tools[0]['environment'] == 'desktop' + assert tools[0]['display_width'] == 1920 + assert tools[0]['display_height'] == 1080 + + @pytest.mark.asyncio + async def test_multiple_computer_tools_error(self, streaming_model, _streaming_context_vars, sample_computer_tool): + """Test that multiple computer tools raise an error""" + streaming_model.client.responses.create = AsyncMock() + + # Create two computer tools + computer2 = MagicMock() + computer2.environment = "mobile" + computer2.dimensions = [375, 812] + from agents.tool import ComputerTool + second_computer_tool = ComputerTool(computer=computer2) + + with pytest.raises(ValueError, match="You can only provide one computer tool"): + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=ModelSettings(), + tools=[sample_computer_tool, second_computer_tool], + output_schema=None, + handoffs=[], + tracing=None, + ) + + @pytest.mark.asyncio + async def test_hosted_mcp_tool(self, streaming_model, _streaming_context_vars, sample_hosted_mcp_tool): + """Test HostedMCPTool conversion""" + streaming_model.client.responses.create = AsyncMock() + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([ + MagicMock(type="response.completed", response=MagicMock(output=[])) + ]) + streaming_model.client.responses.create.return_value = mock_stream + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=ModelSettings(), + tools=[sample_hosted_mcp_tool], + output_schema=None, + handoffs=[], + tracing=None, + ) + + create_call = streaming_model.client.responses.create.call_args + tools = create_call.kwargs['tools'] + assert len(tools) == 1 + assert tools[0]['type'] == 'mcp' + assert tools[0]['server_label'] == 'test_server' + + @pytest.mark.asyncio + async def test_image_generation_tool(self, streaming_model, _streaming_context_vars, sample_image_generation_tool): + """Test ImageGenerationTool conversion""" + streaming_model.client.responses.create = AsyncMock() + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([ + MagicMock(type="response.completed", response=MagicMock(output=[])) + ]) + streaming_model.client.responses.create.return_value = mock_stream + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=ModelSettings(), + tools=[sample_image_generation_tool], + output_schema=None, + handoffs=[], + tracing=None, + ) + + create_call = streaming_model.client.responses.create.call_args + tools = create_call.kwargs['tools'] + assert len(tools) == 1 + assert tools[0]['type'] == 'image_generation' + + @pytest.mark.asyncio + async def test_code_interpreter_tool(self, streaming_model, _streaming_context_vars, sample_code_interpreter_tool): + """Test CodeInterpreterTool conversion""" + streaming_model.client.responses.create = AsyncMock() + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([ + MagicMock(type="response.completed", response=MagicMock(output=[])) + ]) + streaming_model.client.responses.create.return_value = mock_stream + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=ModelSettings(), + tools=[sample_code_interpreter_tool], + output_schema=None, + handoffs=[], + tracing=None, + ) + + create_call = streaming_model.client.responses.create.call_args + tools = create_call.kwargs['tools'] + assert len(tools) == 1 + assert tools[0]['type'] == 'code_interpreter' + + @pytest.mark.asyncio + async def test_local_shell_tool(self, streaming_model, _streaming_context_vars, sample_local_shell_tool): + """Test LocalShellTool conversion""" + streaming_model.client.responses.create = AsyncMock() + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([ + MagicMock(type="response.completed", response=MagicMock(output=[])) + ]) + streaming_model.client.responses.create.return_value = mock_stream + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=ModelSettings(), + tools=[sample_local_shell_tool], + output_schema=None, + handoffs=[], + tracing=None, + ) + + create_call = streaming_model.client.responses.create.call_args + tools = create_call.kwargs['tools'] + assert len(tools) == 1 + assert tools[0]['type'] == 'local_shell' + # working_directory no longer in API - LocalShellTool uses executor internally + + @pytest.mark.asyncio + async def test_handoffs(self, streaming_model, _streaming_context_vars, sample_handoff): + """Test Handoff conversion to function tools""" + streaming_model.client.responses.create = AsyncMock() + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([ + MagicMock(type="response.completed", response=MagicMock(output=[])) + ]) + streaming_model.client.responses.create.return_value = mock_stream + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[sample_handoff], + tracing=None, + ) + + create_call = streaming_model.client.responses.create.call_args + tools = create_call.kwargs['tools'] + assert len(tools) == 1 + assert tools[0]['type'] == 'function' + assert tools[0]['name'] == 'transfer_to_support' + assert tools[0]['description'] == 'Transfer to support agent' + + @pytest.mark.asyncio + async def test_mixed_tools(self, streaming_model, _streaming_context_vars, + sample_function_tool, sample_web_search_tool, sample_handoff): + """Test multiple tools together""" + streaming_model.client.responses.create = AsyncMock() + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([ + MagicMock(type="response.completed", response=MagicMock(output=[])) + ]) + streaming_model.client.responses.create.return_value = mock_stream + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=ModelSettings(), + tools=[sample_function_tool, sample_web_search_tool], + output_schema=None, + handoffs=[sample_handoff], + tracing=None, + ) + + create_call = streaming_model.client.responses.create.call_args + tools = create_call.kwargs['tools'] + assert len(tools) == 3 # 2 tools + 1 handoff + + # Check each tool type is present + tool_types = [t['type'] for t in tools] + assert 'function' in tool_types # function tool and handoff + assert 'web_search' in tool_types + + +class TestStreamingModelBasics: + """Test core streaming functionality""" + + @pytest.mark.asyncio + async def test_responses_api_streaming(self, streaming_model, mock_adk_streaming, _streaming_context_vars, sample_task_id): + """Test basic Responses API streaming flow""" + streaming_model.client.responses.create = AsyncMock() + + # Production uses ``isinstance(event, ...)`` against the OpenAI Responses + # event types to dispatch. ``spec=...`` makes isinstance pass without + # triggering pydantic validation on partially-constructed events. + item_added = MagicMock(spec=ResponseOutputItemAddedEvent) + item_added.item = MagicMock(type="message") + item_added.output_index = 0 + text_delta_1 = MagicMock(spec=ResponseTextDeltaEvent) + text_delta_1.delta = "Hello " + text_delta_2 = MagicMock(spec=ResponseTextDeltaEvent) + text_delta_2.delta = "world!" + completed = MagicMock(spec=ResponseCompletedEvent) + completed.response = MagicMock(output=[], usage=MagicMock(), id=None) + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([item_added, text_delta_1, text_delta_2, completed]) + streaming_model.client.responses.create.return_value = mock_stream + + result = await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + # Verify streaming context was created with the right task_id. We + # don't strict-match the full kwargs because production also passes + # ``streaming_mode``, which is an implementation detail this test + # doesn't care about. + mock_adk_streaming.streaming_task_message_context.assert_called() + call_kwargs = mock_adk_streaming.streaming_task_message_context.call_args.kwargs + assert call_kwargs['task_id'] == sample_task_id + + # Verify result is returned as ModelResponse + from agents import ModelResponse + assert isinstance(result, ModelResponse) + + @pytest.mark.asyncio + async def test_task_id_threading(self, streaming_model, mock_adk_streaming, _streaming_context_vars): + """Test that task_id from the streaming ContextVar is threaded through to + the streaming context. ``_streaming_context_vars`` yields the task_id that + was set on the ContextVar, which is what production reads (the kwarg + ``task_id=...`` to ``get_response`` is swallowed by ``**kwargs`` and ignored). + """ + streaming_model.client.responses.create = AsyncMock() + + item_added = MagicMock(spec=ResponseOutputItemAddedEvent) + item_added.item = MagicMock(type="message") + item_added.output_index = 0 + completed = MagicMock(spec=ResponseCompletedEvent) + completed.response = MagicMock(output=[], usage=MagicMock(), id=None) + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([item_added, completed]) + streaming_model.client.responses.create.return_value = mock_stream + + expected_task_id = _streaming_context_vars + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + # Verify the ContextVar's task_id was threaded through to the streaming context + mock_adk_streaming.streaming_task_message_context.assert_called() + call_args = mock_adk_streaming.streaming_task_message_context.call_args + assert call_args.kwargs['task_id'] == expected_task_id + + @pytest.mark.asyncio + async def test_redis_context_creation(self, streaming_model, mock_adk_streaming, _streaming_context_vars): + """Test that Redis streaming contexts are created properly""" + streaming_model.client.responses.create = AsyncMock() + + # Production uses ``isinstance`` against OpenAI Responses event types; + # ``spec=...`` makes isinstance pass without triggering pydantic validation. + item_added = MagicMock(spec=ResponseOutputItemAddedEvent) + item_added.item = MagicMock(type="reasoning") + item_added.output_index = 0 + reasoning_delta = MagicMock(spec=ResponseReasoningSummaryTextDeltaEvent) + reasoning_delta.delta = "Thinking..." + reasoning_delta.summary_index = 0 + completed = MagicMock(spec=ResponseCompletedEvent) + completed.response = MagicMock(output=[], usage=MagicMock(), id=None) + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([item_added, reasoning_delta, completed]) + streaming_model.client.responses.create.return_value = mock_stream + + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=ModelSettings(reasoning=Reasoning(effort="medium")), + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + # Should create at least one context for reasoning + assert mock_adk_streaming.streaming_task_message_context.call_count >= 1 + + @pytest.mark.asyncio + async def test_missing_task_id_error(self, streaming_model): + """Test that missing streaming ContextVars raise an appropriate error. + + Production reads task_id, trace_id, and parent_span_id from ContextVars + populated by ContextInterceptor. Without ``_streaming_context_vars`` + requested, all three are at their defaults — empty strings — and + ``get_response`` raises before doing any work. + """ + streaming_model.client.responses.create = AsyncMock() + + with pytest.raises(ValueError, match=r"task_id.*required"): + await streaming_model.get_response( + system_instructions="Test", + input="Hello", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + +class TestStreamingModelFunctionCallArgsStreaming: + """Verify ``ResponseFunctionCallArgumentsDeltaEvent``s are surfaced as + ``ToolRequestDelta`` updates and that a final ``ToolRequestContent`` Full is + emitted on ``ResponseOutputItemDoneEvent``. + + Without this, write-heavy tools (``write_file``, ``apply_patch``) buffer their + entire argument body inside ``invoke_model_activity`` and the UI sees a + multi-second freeze while the model is actively producing tokens. + """ + + @staticmethod + def _build_function_call_stream(arguments_text: str): + """Construct a streaming event sequence for a single function_call. + + Mirrors the production order: Added → N × ArgumentsDelta → ArgumentsDone + → OutputItemDone → ResponseCompleted. ``spec=...`` makes ``isinstance`` + dispatch in production work without triggering pydantic validation. + """ + call_item = MagicMock() + call_item.type = "function_call" + call_item.id = "fc_abc" + call_item.call_id = "call_abc" + call_item.name = "write_file" + call_item.arguments = "" + + item_added = MagicMock(spec=ResponseOutputItemAddedEvent) + item_added.item = call_item + item_added.output_index = 0 + + # Split the argument text into a few chunks to exercise the per-delta loop + chunk_size = max(1, len(arguments_text) // 3) if arguments_text else 1 + chunks = [arguments_text[i:i + chunk_size] for i in range(0, len(arguments_text), chunk_size)] or [""] + delta_events = [] + for chunk in chunks: + ev = MagicMock(spec=ResponseFunctionCallArgumentsDeltaEvent) + ev.delta = chunk + ev.output_index = 0 + delta_events.append(ev) + + args_done = MagicMock(spec=ResponseFunctionCallArgumentsDoneEvent) + args_done.arguments = arguments_text + args_done.output_index = 0 + + item_done = MagicMock(spec=ResponseOutputItemDoneEvent) + item_done.item = call_item + item_done.output_index = 0 + + completed = MagicMock(spec=ResponseCompletedEvent) + completed.response = MagicMock(output=[], usage=MagicMock(), id=None) + + return [item_added, *delta_events, args_done, item_done, completed], chunks + + @staticmethod + def _install_real_task_message(mock_adk_streaming, task_id: str): + """Replace the autouse fixture's MagicMock ``task_message`` with a real + ``TaskMessage`` so production's ``StreamTaskMessageDelta(parent_task_message=...)`` + construction passes pydantic validation. The default mock works for tests + that only assert on the context's ``__aenter__`` call but breaks tests + that exercise ``stream_update`` end-to-end. + """ + from agentex.types.task_message import TaskMessage + from agentex.types.task_message_content import ToolRequestContent + + ctx = mock_adk_streaming.streaming_task_message_context.return_value + ctx.task_message = TaskMessage( + id="msg_test", + task_id=task_id, + content=ToolRequestContent( + author="agent", + tool_call_id="call_abc", + name="write_file", + arguments={}, + ), + streaming_status="IN_PROGRESS", + ) + return ctx + + @pytest.mark.asyncio + async def test_function_call_emits_argument_deltas_and_final_full( + self, streaming_model, mock_adk_streaming, _streaming_context_vars, sample_task_id + ): + """A function_call with well-formed JSON args should produce: + (1) one streaming context opened with ``ToolRequestContent`` initial_content, + (2) one ``StreamTaskMessageDelta`` per ``ArgumentsDelta`` event carrying a + ``ToolRequestDelta`` with the right ``tool_call_id`` and ``arguments_delta``, + (3) one final ``StreamTaskMessageFull`` with ``ToolRequestContent`` whose + ``arguments`` is the parsed JSON dict. + """ + from agentex.types.task_message_delta import ToolRequestDelta + from agentex.types.task_message_update import StreamTaskMessageFull, StreamTaskMessageDelta + from agentex.types.task_message_content import ToolRequestContent + + ctx = self._install_real_task_message(mock_adk_streaming, sample_task_id) + + args_text = '{"path": "/tmp/foo.txt", "contents": "hello world"}' + events, chunks = self._build_function_call_stream(args_text) + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter(events) + streaming_model.client.responses.create = AsyncMock(return_value=mock_stream) + + await streaming_model.get_response( + system_instructions=None, + input="please write foo", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + # 1. A streaming context was opened with ToolRequestContent. + opens = [ + c for c in mock_adk_streaming.streaming_task_message_context.call_args_list + if isinstance(c.kwargs.get("initial_content"), ToolRequestContent) + ] + assert len(opens) == 1, f"expected one ToolRequest context, got {len(opens)}" + initial = opens[0].kwargs["initial_content"] + assert initial.tool_call_id == "call_abc" + assert initial.name == "write_file" + + # 2. One StreamTaskMessageDelta(ToolRequestDelta) was streamed per + # ArgumentsDelta event, preserving the delta text exactly. + delta_updates = [ + call.args[0] if call.args else call.kwargs.get("update") + for call in ctx.stream_update.call_args_list + if (call.args and isinstance(call.args[0], StreamTaskMessageDelta) + and isinstance(call.args[0].delta, ToolRequestDelta)) + ] + assert len(delta_updates) == len(chunks) + for update, expected_chunk in zip(delta_updates, chunks): + assert update.delta.tool_call_id == "call_abc" + assert update.delta.name == "write_file" + assert update.delta.arguments_delta == expected_chunk + + # 3. A final StreamTaskMessageFull(ToolRequestContent) was streamed with + # parsed args. + full_updates = [ + call.args[0] if call.args else call.kwargs.get("update") + for call in ctx.stream_update.call_args_list + if (call.args and isinstance(call.args[0], StreamTaskMessageFull) + and isinstance(call.args[0].content, ToolRequestContent)) + ] + assert len(full_updates) == 1 + final = full_updates[0].content + assert final.tool_call_id == "call_abc" + assert final.name == "write_file" + assert final.arguments == {"path": "/tmp/foo.txt", "contents": "hello world"} + + @pytest.mark.asyncio + async def test_function_call_malformed_args_fall_back_to_empty_dict( + self, streaming_model, mock_adk_streaming, _streaming_context_vars, sample_task_id, caplog + ): + """If the model produces invalid JSON for the args, the final + ``ToolRequestContent`` should carry ``arguments={}`` and a warning should + be logged. The raw delta stream is preserved either way. + """ + from agentex.types.task_message_update import StreamTaskMessageFull + from agentex.types.task_message_content import ToolRequestContent + + ctx = self._install_real_task_message(mock_adk_streaming, sample_task_id) + + # Missing closing brace — invalid JSON. + events, _ = self._build_function_call_stream('{"path": "/tmp/foo.txt", "contents":') + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter(events) + streaming_model.client.responses.create = AsyncMock(return_value=mock_stream) + + with caplog.at_level("WARNING"): + await streaming_model.get_response( + system_instructions=None, + input="please write foo", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + full_updates = [ + call.args[0] if call.args else call.kwargs.get("update") + for call in ctx.stream_update.call_args_list + if (call.args and isinstance(call.args[0], StreamTaskMessageFull) + and isinstance(call.args[0].content, ToolRequestContent)) + ] + assert len(full_updates) == 1 + assert full_updates[0].content.arguments == {} + assert any("Failed to parse tool call arguments" in r.getMessage() for r in caplog.records) + + +class TestStreamingModelUsageResponseIdAndCacheKey: + """Cover real-Usage capture, real response_id, span emission, and opt-in prompt_cache_key.""" + + @staticmethod + def _async_iter(events): + async def _gen(): + for event in events: + yield event + return _gen() + + @staticmethod + def _make_response_completed_event( + *, + input_tokens: int = 0, + output_tokens: int = 0, + total_tokens: int = 0, + cached_tokens: int = 0, + reasoning_tokens: int = 0, + with_usage: bool = True, + response_id: Optional[str] = "resp_real_server_id", + ): + usage = MagicMock() + usage.input_tokens = input_tokens + usage.output_tokens = output_tokens + usage.total_tokens = total_tokens + usage.input_tokens_details = MagicMock(cached_tokens=cached_tokens) + usage.output_tokens_details = MagicMock(reasoning_tokens=reasoning_tokens) + + response = MagicMock() + response.output = [] + response.usage = usage if with_usage else None + response.id = response_id + + event = MagicMock(spec=ResponseCompletedEvent) + event.response = response + return event + + @pytest.fixture + def mock_span(self): + return MagicMock() + + @pytest.fixture + def streaming_model_with_mock_tracer(self, streaming_model, mock_span): + """A streaming_model whose tracer.trace().span(...) yields a captured mock span.""" + async_cm = MagicMock() + async_cm.__aenter__ = AsyncMock(return_value=mock_span) + async_cm.__aexit__ = AsyncMock(return_value=False) + trace_obj = MagicMock() + trace_obj.span = MagicMock(return_value=async_cm) + streaming_model.tracer = MagicMock() + streaming_model.tracer.trace = MagicMock(return_value=trace_obj) + return streaming_model + + @pytest.mark.asyncio + async def test_usage_captured_from_completed_event( + self, + streaming_model_with_mock_tracer, + _streaming_context_vars, # noqa: ARG002 + ): + model = streaming_model_with_mock_tracer + completed = self._make_response_completed_event( + input_tokens=1234, output_tokens=56, total_tokens=1290, + cached_tokens=987, reasoning_tokens=42, + ) + model.client.responses.create = AsyncMock(return_value=self._async_iter([completed])) + + response = await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + assert response.usage.input_tokens == 1234 + assert response.usage.output_tokens == 56 + assert response.usage.total_tokens == 1290 + assert response.usage.input_tokens_details.cached_tokens == 987 + assert response.usage.output_tokens_details.reasoning_tokens == 42 + + @pytest.mark.asyncio + async def test_usage_falls_back_when_no_completed_event( + self, + streaming_model_with_mock_tracer, + _streaming_context_vars, # noqa: ARG002 + ): + """Stream ending without a ResponseCompletedEvent (error path) → zero Usage.""" + model = streaming_model_with_mock_tracer + model.client.responses.create = AsyncMock(return_value=self._async_iter([])) + + response = await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + assert response.usage.input_tokens == 0 + assert response.usage.output_tokens == 0 + assert response.usage.total_tokens == 0 + assert response.usage.input_tokens_details.cached_tokens == 0 + assert response.usage.output_tokens_details.reasoning_tokens == 0 + + @pytest.mark.asyncio + async def test_usage_emitted_in_span_output( + self, + streaming_model_with_mock_tracer, + _streaming_context_vars, # noqa: ARG002 + mock_span, + ): + model = streaming_model_with_mock_tracer + completed = self._make_response_completed_event( + input_tokens=100, output_tokens=10, total_tokens=110, + cached_tokens=80, reasoning_tokens=5, + ) + model.client.responses.create = AsyncMock(return_value=self._async_iter([completed])) + + await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + assert isinstance(mock_span.output, dict) + usage_block = mock_span.output["usage"] + assert usage_block == { + "input_tokens": 100, + "output_tokens": 10, + "total_tokens": 110, + "cached_input_tokens": 80, + "reasoning_tokens": 5, + } + + @pytest.mark.asyncio + async def test_response_id_captured_from_completed_event( + self, + streaming_model_with_mock_tracer, + _streaming_context_vars, # noqa: ARG002 + ): + """Real server-issued id flows back on ModelResponse.response_id.""" + model = streaming_model_with_mock_tracer + completed = self._make_response_completed_event(response_id="resp_abcdef123456") + model.client.responses.create = AsyncMock(return_value=self._async_iter([completed])) + + response = await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + assert response.response_id == "resp_abcdef123456" + + @pytest.mark.asyncio + async def test_response_id_is_none_when_no_completed_event( + self, + streaming_model_with_mock_tracer, + _streaming_context_vars, # noqa: ARG002 + ): + """Stream ending without ResponseCompletedEvent → response_id is None. + + Critical: must NOT fabricate a UUID. Returning a fake id would cause + downstream `previous_response_id` chaining to 400 against the server. + """ + model = streaming_model_with_mock_tracer + model.client.responses.create = AsyncMock(return_value=self._async_iter([])) + + response = await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + assert response.response_id is None + + @pytest.mark.asyncio + async def test_prompt_cache_key_not_sent_by_default( + self, + streaming_model_with_mock_tracer, + _streaming_context_vars, # noqa: ARG002 + ): + """Without an opt-in, prompt_cache_key resolves to NOT_GIVEN (omitted from request).""" + model = streaming_model_with_mock_tracer + completed = self._make_response_completed_event() + model.client.responses.create = AsyncMock(return_value=self._async_iter([completed])) + + await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + kwargs = model.client.responses.create.call_args.kwargs + assert kwargs["prompt_cache_key"] is NOT_GIVEN + + @pytest.mark.asyncio + async def test_prompt_cache_key_forwarded_when_opted_in( + self, + streaming_model_with_mock_tracer, + _streaming_context_vars, # noqa: ARG002 + ): + """Caller opt-in via model_settings.extra_args is forwarded to responses.create.""" + model = streaming_model_with_mock_tracer + completed = self._make_response_completed_event() + model.client.responses.create = AsyncMock(return_value=self._async_iter([completed])) + + await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(extra_args={"prompt_cache_key": "my-key"}), + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + kwargs = model.client.responses.create.call_args.kwargs + assert kwargs["prompt_cache_key"] == "my-key" + # Must be popped from extra_args so the SDK doesn't see it twice. + assert list(kwargs).count("prompt_cache_key") == 1 + + @pytest.mark.asyncio + async def test_previous_response_id_not_sent_by_default( + self, + streaming_model_with_mock_tracer, + _streaming_context_vars, # noqa: ARG002 + ): + """Without an opt-in, previous_response_id resolves to NOT_GIVEN. + + Critical for non-Responses-API-native backends (e.g. Claude-via-LiteLLM) + where unknown fields on the request body could be rejected. NOT_GIVEN + is filtered before serialization, so the field is omitted entirely. + """ + model = streaming_model_with_mock_tracer + completed = self._make_response_completed_event() + model.client.responses.create = AsyncMock(return_value=self._async_iter([completed])) + + await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + kwargs = model.client.responses.create.call_args.kwargs + assert kwargs["previous_response_id"] is NOT_GIVEN + + @pytest.mark.asyncio + async def test_previous_response_id_forwarded_via_sdk_kwarg( + self, + streaming_model_with_mock_tracer, + _streaming_context_vars, # noqa: ARG002 + ): + """The SDK threads previous_response_id as a keyword arg per Model.get_response + abstract contract. Verify it reaches responses.create instead of being silently + swallowed (which was the prior behavior under **kwargs).""" + model = streaming_model_with_mock_tracer + completed = self._make_response_completed_event() + model.client.responses.create = AsyncMock(return_value=self._async_iter([completed])) + + await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + previous_response_id="resp_prior_turn", + ) + + kwargs = model.client.responses.create.call_args.kwargs + assert kwargs["previous_response_id"] == "resp_prior_turn" + + @pytest.mark.asyncio + async def test_conversation_and_prompt_not_sent_by_default( + self, + streaming_model_with_mock_tracer, + _streaming_context_vars, # noqa: ARG002 + ): + """Without an opt-in, conversation/prompt resolve to NOT_GIVEN. + + Same opt-in pattern as previous_response_id and prompt_cache_key — the + wire request is unchanged for callers (and non-OpenAI backends) that + don't supply these. + """ + model = streaming_model_with_mock_tracer + completed = self._make_response_completed_event() + model.client.responses.create = AsyncMock(return_value=self._async_iter([completed])) + + await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + kwargs = model.client.responses.create.call_args.kwargs + assert kwargs["conversation"] is NOT_GIVEN + assert kwargs["prompt"] is NOT_GIVEN + + @pytest.mark.asyncio + async def test_conversation_id_forwarded_via_sdk_kwarg( + self, + streaming_model_with_mock_tracer, + _streaming_context_vars, # noqa: ARG002 + ): + """The SDK abstract names this `conversation_id`; the Responses API + endpoint kwarg is `conversation`. Caller passes a string id; we forward + it as-is (the Conversation type accepts str).""" + model = streaming_model_with_mock_tracer + completed = self._make_response_completed_event() + model.client.responses.create = AsyncMock(return_value=self._async_iter([completed])) + + await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + conversation_id="conv_abc123", + ) + + kwargs = model.client.responses.create.call_args.kwargs + assert kwargs["conversation"] == "conv_abc123" + + @pytest.mark.asyncio + async def test_prompt_forwarded_via_sdk_kwarg( + self, + streaming_model_with_mock_tracer, + _streaming_context_vars, # noqa: ARG002 + ): + """ResponsePromptParam (a TypedDict for pre-built prompts) is forwarded + as-is to responses.create.""" + model = streaming_model_with_mock_tracer + completed = self._make_response_completed_event() + model.client.responses.create = AsyncMock(return_value=self._async_iter([completed])) + + prompt_param = {"id": "prompt_test_id", "version": "1"} + await model.get_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + prompt=prompt_param, # type: ignore[arg-type] + ) + + kwargs = model.client.responses.create.call_args.kwargs + assert kwargs["prompt"] == prompt_param \ No newline at end of file diff --git a/src/agentex/lib/core/temporal/services/__init__.py b/src/agentex/lib/core/temporal/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/core/temporal/services/temporal_task_service.py b/src/agentex/lib/core/temporal/services/temporal_task_service.py new file mode 100644 index 000000000..774de4d9e --- /dev/null +++ b/src/agentex/lib/core/temporal/services/temporal_task_service.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import sys +from typing import Any +from datetime import timedelta +from contextlib import contextmanager +from collections.abc import Iterator + +from agentex.types.task import Task +from agentex.types.agent import Agent +from agentex.types.event import Event +from agentex.protocol.acp import SendEventParams, CreateTaskParams, InterruptTaskParams +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.clients.temporal.types import WorkflowState, ConflictWorkflowPolicy +from agentex.lib.core.temporal.types.workflow import SignalName +from agentex.lib.core.clients.temporal.temporal_client import TemporalClient + + +@contextmanager +def _acp_dispatch_span(name: str, task_id: str | None = None) -> Iterator[None]: + """Wrap an ACP -> Temporal dispatch (start_workflow / signal) in an OTel span. + + The Temporal OpenTelemetry interceptor propagates trace context by injecting + the CURRENTLY ACTIVE span into the Temporal message headers on the caller + side (``start_workflow`` / ``signal_workflow``); the worker then extracts it + and roots the workflow / activity spans under it. But the ACP server dispatches + from a bare async handler with no active span, so nothing is injected and the + workflow's activities become DETACHED trace roots -- the business work shows up + in Tempo as a fresh trace with no link back to the ``task/create`` / + ``event/send`` that triggered it. + + Opening a span here gives the interceptor something to inject. It becomes a + child of the ingress request span when one is active (front-of-request + propagation), or a fresh per-turn root otherwise. + + Fail-open across the WHOLE obs setup, not just the import: ``get_tracer`` and + entering ``start_as_current_span`` run the sampler and every + ``SpanProcessor.on_start`` (the SDK does not guard those), so a broken + provider or a custom sampler/processor that raises would otherwise fail the + dispatch itself. If any of it fails we run the dispatch untraced. The dispatch + body (the ``yield``) is OUTSIDE the guard so its exceptions still propagate. + """ + span_cm = None + try: + from opentelemetry import trace as _otel_trace + + tracer = _otel_trace.get_tracer("agentex.acp") + # task_id goes on an attribute, NOT in the span name: a per-task span name is + # high-cardinality and breaks span-name aggregation in Tempo. + attributes = {"agentex.task_id": task_id} if task_id else None + span_cm = tracer.start_as_current_span(name, kind=_otel_trace.SpanKind.PRODUCER, attributes=attributes) + span_cm.__enter__() + except Exception: # pragma: no cover - obs must never break a dispatch + span_cm = None + + try: + yield + finally: + if span_cm is not None: + # Pass exc info so the span reflects a failed dispatch; guard __exit__ + # so closing the span can never mask the dispatch outcome. + try: + span_cm.__exit__(*sys.exc_info()) + except Exception: # pragma: no cover - best-effort close + pass + + +class TemporalTaskService: + """ + Submits Agent agent_tasks to the async runtime for execution. + """ + + def __init__( + self, + temporal_client: TemporalClient, + env_vars: EnvironmentVariables, + ): + self._temporal_client = temporal_client + self._env_vars = env_vars + + async def submit_task(self, agent: Agent, task: Task, params: dict[str, Any] | None) -> str: + """ + Submit a task to the async runtime for execution. + + returns the workflow ID of the temporal workflow + """ + # None / 0 / negative => no execution timeout (workflow can stay open + # indefinitely, which long-lived chat/session agents rely on). A positive + # value bounds the whole continue-as-new chain's wall-clock lifetime. + timeout_seconds = self._env_vars.WORKFLOW_EXECUTION_TIMEOUT_SECONDS + execution_timeout = timedelta(seconds=timeout_seconds) if timeout_seconds and timeout_seconds > 0 else None + # USE_EXISTING makes task/create idempotent + # If same task ID is already running Temporal returns a handle to the existing run instead of raising WorkflowAlreadyStarted + with _acp_dispatch_span("acp.task_create", task_id=task.id): + return await self._temporal_client.start_workflow( + workflow=self._env_vars.WORKFLOW_NAME, + arg=CreateTaskParams( + agent=agent, + task=task, + params=params, + ), + id=task.id, + task_queue=self._env_vars.WORKFLOW_TASK_QUEUE, + execution_timeout=execution_timeout, + conflict_policy=ConflictWorkflowPolicy.USE_EXISTING, + ) + + async def get_state(self, task_id: str) -> WorkflowState: + """ + Get the task state from the async runtime. + """ + return await self._temporal_client.get_workflow_status( + workflow_id=task_id, + ) + + async def send_event(self, agent: Agent, task: Task, event: Event, request: dict | None = None) -> None: + with _acp_dispatch_span("acp.event_send", task_id=task.id): + return await self._temporal_client.send_signal( + workflow_id=task.id, + signal=SignalName.RECEIVE_EVENT.value, + payload=SendEventParams( + agent=agent, + task=task, + event=event, + request=request, + ).model_dump(), + ) + + async def interrupt(self, agent: Agent, task: Task, request: dict | None = None) -> None: + """Forward a task/interrupt to the running workflow as a dedicated signal. + + Non-terminal: unlike ``cancel``/``terminate`` this does NOT tear down the + workflow. It signals ``interrupt_turn`` so the workflow's ``on_interrupt`` + hook can stop the in-flight turn while leaving the task continuable. + """ + return await self._temporal_client.send_signal( + workflow_id=task.id, + signal=SignalName.INTERRUPT_TURN.value, + payload=InterruptTaskParams( + agent=agent, + task=task, + request=request, + ).model_dump(), + ) + + async def cancel(self, task_id: str) -> None: + return await self._temporal_client.cancel_workflow( + workflow_id=task_id, + ) + + async def terminate(self, task_id: str) -> None: + return await self._temporal_client.terminate_workflow( + workflow_id=task_id, + ) diff --git a/src/agentex/lib/core/temporal/types/__init__.py b/src/agentex/lib/core/temporal/types/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/core/temporal/types/workflow.py b/src/agentex/lib/core/temporal/types/workflow.py new file mode 100644 index 000000000..62624832e --- /dev/null +++ b/src/agentex/lib/core/temporal/types/workflow.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class SignalName(str, Enum): + RECEIVE_EVENT = "receive_event" + # Dedicated non-terminal "stop the current turn" signal (design doc section 7). + # Routed to the overridable BaseWorkflow.on_interrupt hook. Kept separate from + # RECEIVE_EVENT so the interrupt handler can interleave with (and cancel) the + # in-flight turn WITHOUT waiting on the turn lock the running turn holds. + INTERRUPT_TURN = "interrupt_turn" diff --git a/src/agentex/lib/core/temporal/workers/__init__.py b/src/agentex/lib/core/temporal/workers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/core/temporal/workers/worker.py b/src/agentex/lib/core/temporal/workers/worker.py new file mode 100644 index 000000000..72631917f --- /dev/null +++ b/src/agentex/lib/core/temporal/workers/worker.py @@ -0,0 +1,319 @@ +from __future__ import annotations + +import os +import uuid +import datetime +import dataclasses +from typing import Any, overload, override +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor + +from aiohttp import web +from temporalio.client import Client, Plugin as ClientPlugin +from temporalio.worker import ( + Plugin as WorkerPlugin, + Worker, + Interceptor, + UnsandboxedWorkflowRunner, +) +from temporalio.runtime import Runtime, TelemetryConfig, OpenTelemetryConfig, OpenTelemetryMetricTemporality +from temporalio.converter import ( + PayloadCodec, + DataConverter, + JSONTypeConverter, + AdvancedJSONEncoder, + DefaultPayloadConverter, + CompositePayloadConverter, + JSONPlainPayloadConverter, +) + +from agentex.lib.utils.logging import make_logger +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.compat.version_guard import assert_backend_compatible + +logger = make_logger(__name__) + + +class DateTimeJSONEncoder(AdvancedJSONEncoder): + @override + def default(self, o: Any) -> Any: + if isinstance(o, datetime.datetime): + return o.isoformat() + return super().default(o) + + +class DateTimeJSONTypeConverter(JSONTypeConverter): + @override + def to_typed_value(self, hint: type, value: Any) -> Any | None: + if hint == datetime.datetime: + return datetime.datetime.fromisoformat(value) + return JSONTypeConverter.Unhandled + + +class DateTimePayloadConverter(CompositePayloadConverter): + def __init__(self) -> None: + json_converter = JSONPlainPayloadConverter( + encoder=DateTimeJSONEncoder, + custom_type_converters=[DateTimeJSONTypeConverter()], + ) + super().__init__( + *[ + c if not isinstance(c, JSONPlainPayloadConverter) else json_converter + for c in DefaultPayloadConverter.default_encoding_payload_converters + ] + ) + + +custom_data_converter = dataclasses.replace( + DataConverter.default, + payload_converter_class=DateTimePayloadConverter, +) + + +def _validate_plugins(plugins: list) -> None: + """Validate that all items in the plugins list are valid Temporal plugins.""" + for i, plugin in enumerate(plugins): + if not isinstance(plugin, (ClientPlugin, WorkerPlugin)): + raise TypeError( + f"Plugin at index {i} must be an instance of temporalio.client.Plugin " + f"or temporalio.worker.Plugin, got {type(plugin).__name__}" + ) + + +def _validate_interceptors(interceptors: list) -> None: + """Validate that all items in the interceptors list are valid Temporal interceptors.""" + for i, interceptor in enumerate(interceptors): + if not isinstance(interceptor, Interceptor): + raise TypeError( + f"Interceptor at index {i} must be an instance of temporalio.worker.Interceptor, " + f"got {type(interceptor).__name__}" + ) + + +async def get_temporal_client( + temporal_address: str, + metrics_url: str | None = None, + plugins: list = [], + payload_codec: PayloadCodec | None = None, + data_converter: DataConverter | None = None, + *, + metrics_headers: dict[str, str] | None = None, + metrics_use_http: bool = False, + metrics_temporality_delta: bool = False, +) -> Client: + if plugins != []: # We don't need to validate the plugins if they are empty + _validate_plugins(plugins) + + if payload_codec is not None and data_converter is not None: + raise ValueError( + "Pass payload_codec inside `data_converter` " + "(DataConverter(..., payload_codec=...)) instead of as a separate " + "kwarg. Specifying both is ambiguous." + ) + + # Lazy import to avoid pulling in opentelemetry.sdk for non-Temporal agents + from temporalio.contrib.openai_agents import OpenAIAgentsPlugin + + has_openai_plugin = any(isinstance(p, OpenAIAgentsPlugin) for p in (plugins or [])) + + if has_openai_plugin and payload_codec is not None and data_converter is None: + raise ValueError( + "payload_codec passed as a kwarg alongside OpenAIAgentsPlugin would " + "be silently dropped by the plugin's data-converter transformer. " + "Build a DataConverter explicitly with " + "`payload_converter_class=OpenAIPayloadConverter` (or a subclass) " + "and `payload_codec=...`, then pass it via the `data_converter` " + "kwarg instead." + ) + + connect_kwargs: dict[str, Any] = { + "target_host": temporal_address, + "plugins": plugins, + # Propagate OTel trace context on outbound start_workflow / execute_activity + # (enabled by default; AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED=false to disable). + "interceptors": temporal_tracing_interceptors(), + } + + if data_converter is not None: + connect_kwargs["data_converter"] = data_converter + elif not has_openai_plugin: + dc = custom_data_converter + if payload_codec: + dc = dataclasses.replace(dc, payload_codec=payload_codec) + connect_kwargs["data_converter"] = dc + + if not metrics_url: + client = await Client.connect(**connect_kwargs) + else: + runtime = Runtime(telemetry=TelemetryConfig(metrics=OpenTelemetryConfig( + url=metrics_url, + headers=metrics_headers or {}, + http=metrics_use_http, + metric_temporality=( + OpenTelemetryMetricTemporality.DELTA + if metrics_temporality_delta + else OpenTelemetryMetricTemporality.CUMULATIVE + ), + ))) + connect_kwargs["runtime"] = runtime + client = await Client.connect(**connect_kwargs) + return client + + +class AgentexWorker: + def __init__( + self, + task_queue, + max_workers: int = 10, + max_concurrent_activities: int = 10, + health_check_port: int | None = None, + plugins: list = [], + interceptors: list = [], + metrics_url: str | None = None, + payload_codec: PayloadCodec | None = None, + data_converter: DataConverter | None = None, + *, + 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 = [] + self.max_workers = max_workers + self.max_concurrent_activities = max_concurrent_activities + self.health_check_server_running = False + self.healthy = False + self.health_check_port = ( + health_check_port if health_check_port is not None else EnvironmentVariables.refresh().HEALTH_CHECK_PORT + ) + self.plugins = plugins + self.interceptors = interceptors + self.metrics_url = metrics_url + self.metrics_headers = metrics_headers + self.metrics_use_http = metrics_use_http + 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( + self, + activities: list[Callable], + *, + workflow: type, + ) -> None: ... + + @overload + async def run( + self, + activities: list[Callable], + *, + workflows: list[type], + ) -> None: ... + + async def run( + self, + activities: list[Callable], + *, + workflow: type | None = None, + workflows: list[type] | None = None, + ): + await self.start_health_check_server() + await self._register_agent() + + # Validate interceptors if any are provided + if self.interceptors: + _validate_interceptors(self.interceptors) + + temporal_client = await get_temporal_client( + temporal_address=os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=self.plugins, + metrics_url=self.metrics_url, + metrics_headers=self.metrics_headers, + metrics_use_http=self.metrics_use_http, + metrics_temporality_delta=self.metrics_temporality_delta, + payload_codec=self.payload_codec, + data_converter=self.data_converter, + ) + + # Enable debug mode if AgentEx debug is enabled (disables deadlock detection) + debug_enabled = os.environ.get("AGENTEX_DEBUG_ENABLED", "false").lower() == "true" + if debug_enabled: + logger.info("🐛 [WORKER] Temporal debug mode enabled - deadlock detection disabled") + + if workflow is None and workflows is None: + raise ValueError("Either workflow or workflows must be provided") + + worker = Worker( + client=temporal_client, + task_queue=self.task_queue, + activity_executor=ThreadPoolExecutor(max_workers=self.max_workers), + workflows=[workflow] if workflows is None else workflows, + activities=activities, + workflow_runner=UnsandboxedWorkflowRunner(), + max_concurrent_activities=self.max_concurrent_activities, + build_id=str(uuid.uuid4()), + debug_mode=debug_enabled, # Disable deadlock detection in debug mode + # Tracing interceptor OUTERMOST so business interceptors (and the spans + # they create) nest under the propagated workflow/activity span. + interceptors=[*temporal_tracing_interceptors(), *self.interceptors], + ) + + 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}") + await worker.run() + + async def _health_check(self): + return web.json_response(self.healthy) + + async def start_health_check_server(self): + if not self.health_check_server_running: + app = web.Application() + app.router.add_get("/readyz", lambda request: self._health_check()) # noqa: ARG005 + + # Disable access logging + runner = web.AppRunner(app, access_log=None) + await runner.setup() + + try: + site = web.TCPSite(runner, "0.0.0.0", self.health_check_port) + await site.start() + logger.info(f"Health check server running on http://0.0.0.0:{self.health_check_port}/readyz") + self.health_check_server_running = True + except OSError as e: + logger.error(f"Failed to start health check server on port {self.health_check_port}: {e}") + # Try alternative port if default fails + try: + alt_port = self.health_check_port + 1 + site = web.TCPSite(runner, "0.0.0.0", alt_port) + await site.start() + logger.info(f"Health check server running on alternative port http://0.0.0.0:{alt_port}/readyz") + self.health_check_server_running = True + except OSError as e: + logger.error(f"Failed to start health check server on alternative port {alt_port}: {e}") + raise + + """ + Register the worker with the Agentex server. + + Even though the Temporal server will also register the agent with the server, + doing this on the worker side is required to make sure that both share the API key + which is returned on registration and used to authenticate the worker with the Agentex server. + """ + + async def _register_agent(self): + env_vars = EnvironmentVariables.refresh() + if env_vars and env_vars.AGENTEX_BASE_URL: + # Fail fast if this worker is pointed at a backend older than the SDK supports — + # 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) + 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 new file mode 100644 index 000000000..e47fd9a5c --- /dev/null +++ b/src/agentex/lib/core/temporal/workflows/workflow.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Callable +from datetime import timedelta + +from temporalio import workflow + +from agentex.protocol.acp import SendEventParams, CreateTaskParams, InterruptTaskParams +from agentex.lib.utils.logging import make_logger +from agentex.lib.core.temporal.types.workflow import SignalName + +logger = make_logger(__name__) + + +class BaseWorkflow(ABC): + def __init__( + self, + display_name: str, + ): + self.display_name = display_name + + @abstractmethod + @workflow.signal(name=SignalName.RECEIVE_EVENT) + async def on_task_event_send(self, params: SendEventParams) -> None: + raise NotImplementedError + + @abstractmethod + async def on_task_create(self, params: CreateTaskParams) -> None: + raise NotImplementedError + + # ------------------------------------------------------------------ # + # Continue-as-new lifecycle helpers # + # # + # These let a long-lived chat/session workflow recycle its event # + # history so it can stay open indefinitely without hitting Temporal's # + # ~50k-event / 50MB history limit. They are OPT-IN: an agent gets # + # recycling only by calling `run_until_complete` from its # + # `@workflow.run` instead of the usual indefinite `wait_condition`. # + # The SDK owns the hard Temporal mechanics (recycle decision and # + # draining in-flight handlers before the continue_as_new call). # + # Restoring state after a recycle is the AGENT's job and is # + # framework-specific (rebuild from `adk.messages`, an `adk.state` # + # snapshot, or a framework's own memory); that lands per-integration # + # in follow-up PRs. The 000_hello_acp example shows the minimal # + # stateless adoption that needs no restoration. # + # ------------------------------------------------------------------ # + + def should_continue_as_new(self) -> bool: + """Whether this run should recycle its event history via continue-as-new. + + True when Temporal suggests it: ``is_continue_as_new_suggested()`` fires as + the event history approaches the server's size/count limit, so we let + Temporal own the threshold rather than configuring one ourselves. + + This reads only a deterministic ``workflow.info()`` value and emits no + commands, so it is safe to use directly as a ``workflow.wait_condition`` + predicate, e.g.:: + + await workflow.wait_condition( + lambda: self._complete_task or self.should_continue_as_new() + ) + """ + return workflow.info().is_continue_as_new_suggested() + + async def drain_and_continue_as_new( + self, + *args: Any, + is_complete: Callable[[], bool] | None = None, + ) -> None: + """Drain in-flight signal handlers, then continue-as-new. + + Call this from the agent's ``@workflow.run`` once the run loop wakes for a + recycle (see :meth:`should_continue_as_new`). ``args`` are forwarded + verbatim to ``workflow.continue_as_new`` and become the new run's input, so + pass whatever your ``@workflow.run`` signature expects — typically the + original ``CreateTaskParams`` (the new run keeps the same workflow id / task + id and re-hydrates its state from ``adk.state``). + + IMPORTANT: keep your data OUTSIDE workflow state BEFORE calling this — + messages in ``adk.messages`` and any other state in ``adk.state``. + In-workflow attributes do NOT survive the recycle; only the forwarded + ``args`` do. + + Waits on ``all_handlers_finished`` first so an in-flight turn (a signal + handler still running an activity) is never lost or duplicated across the + recycle boundary. ``workflow.continue_as_new`` raises to end the run, so + this never returns normally — EXCEPT when ``is_complete`` is given and + returns True after draining: a completion signal can arrive while we wait + for the drain, and the recycled run would start fresh (losing that + completion), so in that case we return without recycling and let the caller + finish. + """ + # Don't recycle until any signal handler still running has finished, so a + # message mid-flight at the boundary is carried into the next run intact. + await workflow.wait_condition(workflow.all_handlers_finished) + # A completion signal may have landed during the drain — re-check before + # recycling so a workflow that should finish isn't kept open by the recycle. + if is_complete is not None and is_complete(): + return + logger.info( + "Recycling workflow via continue-as-new " + f"(history_length={workflow.info().get_current_history_length()}, " + f"run_id={workflow.info().run_id})" + ) + workflow.continue_as_new(*args) + + async def run_until_complete( + self, + *continue_as_new_args: Any, + is_complete: Callable[[], bool], + timeout: timedelta | None = None, + ) -> None: + """Keep the workflow open to field events, recycling history as needed. + + Drop-in replacement for the usual ``await workflow.wait_condition( + lambda: self._complete_task, timeout=None)`` at the end of an agent's + ``@workflow.run``. ``is_complete`` is a no-arg predicate (typically + ``lambda: self._complete_task``); ``continue_as_new_args`` are forwarded to + continue-as-new on recycle (typically the original ``CreateTaskParams``). + + Adopting this method IS the opt-in to recycling — there is no flag. An agent + that keeps the old indefinite ``wait_condition`` never recycles. + + ``timeout`` is an optional cap on how long to wait with no progress; it + defaults to None = wait indefinitely (the usual case — Temporal can keep huge + numbers of idle workflows open). The broader workflow-level lifetime cap is + the execution timeout (``WORKFLOW_EXECUTION_TIMEOUT_SECONDS``, also infinite + by default). On ``timeout`` expiry ``wait_condition`` raises + ``asyncio.TimeoutError`` like before. + + Persist anything you need across a recycle OUTSIDE workflow state first — + messages in ``adk.messages``, other state in ``adk.state`` — and rebuild it + at the top of ``@workflow.run``. + """ + while True: + await workflow.wait_condition( + lambda: is_complete() or self.should_continue_as_new(), + timeout=timeout, + ) + if is_complete(): + return + # Drains in-flight handlers, then continue-as-new (raises; never + # returns) — UNLESS a completion signal arrived during the drain, in + # which case it returns here and the next loop iteration completes. + await self.drain_and_continue_as_new( + *continue_as_new_args, is_complete=is_complete + ) + if is_complete(): + return + + def is_continued_run(self) -> bool: + """Whether this run was produced by a continue-as-new from a prior run. + + True only on a recycled run (``workflow.info().continued_run_id`` is set), + False on the original run a client created. Use it in ``@workflow.run`` to + gate one-time prologue work that must NOT repeat on every recycle — e.g. a + welcome message, or rehydrating state only when there's something to restore. + The recycled run re-enters ``@workflow.run`` from the top, so anything not + gated here runs again on each history rollover. + """ + return workflow.info().continued_run_id is not None + + @workflow.signal(name=SignalName.INTERRUPT_TURN) + async def on_interrupt(self, params: InterruptTaskParams) -> None: + """Handle a task/interrupt: stop the in-flight turn, keep the task continuable. + + This is the durable transport for the platform's ``task/interrupt`` verb + (design doc section 7). The control plane forwards ``task/interrupt`` to the + agent pod over HTTP JSON-RPC; the ACP/Temporal layer routes it to the + ``interrupt_turn`` signal, which invokes this hook. + + Temporal runs signal handlers on the same deterministic event loop as the + workflow ``run`` method, interleaving at ``await`` points. This handler runs + CONCURRENTLY with the in-flight turn coroutine, so it MUST NOT acquire the + turn lock (the running turn already holds it) — doing so would deadlock: the + interrupt could never fire because it would be waiting on the very turn it is + meant to stop. + + The base implementation is a no-op so existing agents keep working (and the + signal is still accepted, avoiding "unhandled signal" warnings). Interruptible + agents (the golden agent is the reference) override this to actually stop the + in-flight model / CLI subprocess — e.g. SIGINT the leased sandbox CLI by pid, + or cancel the inline model ``asyncio.Task`` — while preserving partial output + and resume state so the next turn continues the conversation. + """ + logger.info( + "on_interrupt (no-op base impl) for task %s; override to make this agent " + "interruptible", + params.task.id, + ) diff --git a/src/agentex/lib/core/tracing/__init__.py b/src/agentex/lib/core/tracing/__init__.py new file mode 100644 index 000000000..580b53c20 --- /dev/null +++ b/src/agentex/lib/core/tracing/__init__.py @@ -0,0 +1,29 @@ +from agentex.types.span import Span +from agentex.lib.core.tracing.trace import Trace, AsyncTrace +from agentex.lib.core.tracing.tracer import Tracer, AsyncTracer +from agentex.lib.core.tracing.span_error import ( + ErrorCategory, + PlatformError, + ApplicationError, + CategorizedError, +) +from agentex.lib.core.tracing.span_queue import ( + AsyncSpanQueue, + get_default_span_queue, + shutdown_default_span_queue, +) + +__all__ = [ + "Trace", + "AsyncTrace", + "Span", + "Tracer", + "AsyncTracer", + "CategorizedError", + "ApplicationError", + "PlatformError", + "ErrorCategory", + "AsyncSpanQueue", + "get_default_span_queue", + "shutdown_default_span_queue", +] diff --git a/src/agentex/lib/core/tracing/code_revision.py b/src/agentex/lib/core/tracing/code_revision.py new file mode 100644 index 000000000..7b08dd45f --- /dev/null +++ b/src/agentex/lib/core/tracing/code_revision.py @@ -0,0 +1,105 @@ +"""Opt-in stamping of the agent's source commit onto its spans. + +Nothing is stamped until the agent calls :func:`enable`, mirroring the +``lineage`` registry next door: a process-wide switch the agent sets once at +import, rather than automatic behaviour every agent inherits. When enabled the +resolved commit lands in span data under ``__commit_sha__`` and is searchable in +the SGP Traces UI as ``__commit_sha__:``. + +This is deliberately separate from ``__agent_version__``, which is automatic and +carries the deployed image tag verbatim ("image tag or git sha"). That tag is a +real commit on some build paths but an ``-`` composite (AWS +ECR), ``latest``, or a hand-passed tag on others -- so a field named for a commit +must not simply mirror it. Values that are not git object names are refused, and +a field named ``__commit_sha__`` therefore only ever holds one. +""" + +from __future__ import annotations + +import os +import re + +from agentex.lib.utils.logging import make_logger + +__all__ = ("COMMIT_SHA_KEY", "enable", "disable", "is_enabled", "commit_sha") + +logger = make_logger(__name__) + +COMMIT_SHA_KEY = "__commit_sha__" + +# A git object name: 40 hex for SHA-1, 64 for SHA-256, or an abbreviation down to +# git's own 7-character minimum. +_GIT_SHA_RE = re.compile(r"[0-9a-fA-F]{7,64}") + +_COMMIT_SHA_ENV = "AGENT_COMMIT_SHA" +# Fallback only: automatic, and only usable when it happens to be SHA-shaped. +_AGENT_VERSION_ENV = "AGENT_VERSION" + +# Resolved once at enable() rather than per span: the value is fixed for the +# life of the process, and resolving eagerly means a bad value is reported at +# startup instead of silently producing unstamped spans. +_commit_sha: str | None = None + + +def enable(commit_sha: str | None = None) -> None: + """Opt this process in to stamping ``__commit_sha__`` onto every span. + + Value precedence: the explicit ``commit_sha`` argument, else + ``AGENT_COMMIT_SHA``, else ``AGENT_VERSION`` when the deployment happened to + set it to a bare commit SHA. A value that is not a git object name is + refused with a warning and leaves stamping off -- better an absent field + than one named for a commit that holds an image tag. + """ + global _commit_sha + + for value, source in ( + (commit_sha, "the commit_sha argument"), + (os.environ.get(_COMMIT_SHA_ENV), _COMMIT_SHA_ENV), + (os.environ.get(_AGENT_VERSION_ENV), _AGENT_VERSION_ENV), + ): + candidate = (value or "").strip() + if not candidate: + continue + if _GIT_SHA_RE.fullmatch(candidate): + _commit_sha = candidate + logger.info("code revision stamping enabled from %s", source) + return + # An explicit argument or AGENT_COMMIT_SHA is a direct statement of + # intent, so a bad value there is worth surfacing. AGENT_VERSION is only + # a fallback and is expected to be a non-SHA tag much of the time, so + # falling through it quietly is correct, not a silent failure. + if source != _AGENT_VERSION_ENV: + logger.warning( + "%s=%r is not a git commit SHA; __commit_sha__ will not be stamped.", + source, + candidate, + ) + _commit_sha = None + return + + _commit_sha = None + logger.warning( + "code revision stamping was enabled but no commit SHA was found " + "(checked the commit_sha argument, %s, and %s); __commit_sha__ will not " + "be stamped. Set %s in the agent's environment -- e.g. bake it at build " + "time with a Dockerfile ARG/ENV.", + _COMMIT_SHA_ENV, + _AGENT_VERSION_ENV, + _COMMIT_SHA_ENV, + ) + + +def disable() -> None: + """Turn stamping back off (also used for test isolation).""" + global _commit_sha + _commit_sha = None + + +def is_enabled() -> bool: + """Whether a commit SHA resolved and will be stamped.""" + return _commit_sha is not None + + +def commit_sha() -> str | None: + """The resolved commit SHA, or ``None`` when stamping is not enabled.""" + return _commit_sha diff --git a/src/agentex/lib/core/tracing/lineage.py b/src/agentex/lib/core/tracing/lineage.py new file mode 100644 index 000000000..75eaffdc0 --- /dev/null +++ b/src/agentex/lib/core/tracing/lineage.py @@ -0,0 +1,174 @@ +"""Data-source reference capture for lineage: tools declare which sources they +touch and the refs land in span data under the ``sgp.lineage.refs`` key.""" + +from __future__ import annotations + +import re +import json +from typing import Any, Literal, Callable, Iterable + +from pydantic import Field, BaseModel, field_validator + +try: + from agentex.lib.utils.logging import make_logger + + logger = make_logger(__name__) +except Exception: # ddtrace may be absent in some envs; fall back to stdlib + import logging + + logger = logging.getLogger(__name__) + +LINEAGE_REFS_KEY = "sgp.lineage.refs" + +# The URI arm of the lineage namespace identifier rule (namespace-conventions.md): +# lowercase scheme and host (dots/hyphens only — normalize `_` to `-`), one optional path segment. +_URI_NAMESPACE_RE = re.compile(r"^[a-z][a-z0-9._-]*://[a-z0-9.-]+(/[a-zA-Z0-9._-]*)?$") + +RefResolver = Callable[[dict[str, Any]], "list[DataSourceRef]"] + + +class DataSourceRef(BaseModel): + """One data source a tool call touched, as a lineage coordinate.""" + + namespace: str = Field(max_length=512) + name: str = Field(min_length=1, max_length=512) + version: str | None = Field(default=None, max_length=256) + role: Literal["input", "output"] = "input" + + def __init__(self, namespace: str | None = None, name: str | None = None, **kwargs: Any) -> None: + if namespace is not None: + kwargs["namespace"] = namespace + if name is not None: + kwargs["name"] = name + super().__init__(**kwargs) + + @field_validator("namespace") + @classmethod + def _namespace_is_uri_form(cls, value: str) -> str: + if not _URI_NAMESPACE_RE.match(value): + raise ValueError(f"namespace must be URI-form (scheme://system), got: {value!r}") + return value + + +class _ToolSources(BaseModel): + refs: list[DataSourceRef] = Field(default_factory=list) + resolver: RefResolver | None = None + + model_config = {"arbitrary_types_allowed": True} + + +_tool_sources: dict[str, _ToolSources] = {} + + +def register_tool_sources( + tool_name: str, + refs: Iterable[DataSourceRef] | None = None, + resolver: RefResolver | None = None, +) -> None: + """Declare the data sources a tool touches, keyed by its tool name. + + Use for tools the agent does not own (e.g. MCP proxy tools). Static refs and + a resolver over the tool's parsed arguments may be combined; repeated + registration for the same name replaces the prior entry. The registry is + process-wide: co-located agents sharing a tool name share (and overwrite) + one entry, so disambiguate shared names before co-locating agent types. + """ + _tool_sources[tool_name] = _ToolSources(refs=list(refs or []), resolver=resolver) + + +def data_sources(*refs: DataSourceRef, resolver: RefResolver | None = None) -> Callable[[Any], Any]: + """Decorator form of ``register_tool_sources`` for tools the agent owns. + + Works below or above ``@function_tool``: the tool name is taken from the + decorated object's ``name`` attribute when present, else ``__name__``. + """ + + def _register(obj: Any) -> Any: + tool_name = getattr(obj, "name", None) or getattr(obj, "__name__", None) + if isinstance(tool_name, str) and tool_name: + register_tool_sources(tool_name, refs=refs, resolver=resolver) + else: + logger.warning("data_sources could not determine a tool name for %r; refs not registered", obj) + return obj + + return _register + + +def clear_tool_sources() -> None: + """Reset the registry (test isolation).""" + _tool_sources.clear() + + +def resolve_refs(tool_name: str, arguments: dict[str, Any] | None) -> list[dict[str, Any]]: + """Resolve registered refs for one tool call to serialized, deduplicated dicts. + + Resolver failures are logged and swallowed: ref capture must never break a + tool call or its tracing. + """ + entry = _tool_sources.get(tool_name) + if entry is None: + return [] + refs = list(entry.refs) + if entry.resolver is not None: + try: + refs.extend(entry.resolver(arguments or {})) + except Exception: + logger.warning("data-source resolver for tool %s failed; static refs kept", tool_name, exc_info=True) + return _dedupe(refs) + + +def resolve_refs_from_items(items: Iterable[Any]) -> list[dict[str, Any]]: + """Resolve refs across serialized run items, matching ``function_call`` entries. + + Accepts the item dicts the providers already build for span output; string + ``arguments`` are parsed as JSON for resolver-based registrations. + """ + refs: list[dict[str, Any]] = [] + for item in items: + if not isinstance(item, dict) or item.get("type") != "function_call": + continue + tool_name = item.get("name") + if not isinstance(tool_name, str) or not tool_name: + continue + arguments = item.get("arguments") + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except (ValueError, TypeError): + arguments = {} + refs.extend(resolve_refs(tool_name, arguments if isinstance(arguments, dict) else {})) + return _dedupe_dicts(refs) + + +def record(span: Any, refs: Iterable[DataSourceRef]) -> None: + """Attach refs to a manually managed span (no-op when the span is None).""" + if span is None: + return + merged = merge_refs_into_data(getattr(span, "data", None), _dedupe(list(refs))) + span.data = merged + + +def merge_refs_into_data(data: dict[str, Any] | None, refs: list[dict[str, Any]]) -> dict[str, Any]: + """Merge serialized refs into a span data dict, deduplicating with any present.""" + out = dict(data) if isinstance(data, dict) else {} + if refs: + existing = out.get(LINEAGE_REFS_KEY) + combined = list(existing) if isinstance(existing, list) else [] + combined.extend(refs) + out[LINEAGE_REFS_KEY] = _dedupe_dicts(combined) + return out + + +def _dedupe(refs: list[DataSourceRef]) -> list[dict[str, Any]]: + return _dedupe_dicts([ref.model_dump(exclude_none=True) for ref in refs]) + + +def _dedupe_dicts(refs: list[dict[str, Any]]) -> list[dict[str, Any]]: + seen: set[tuple[Any, ...]] = set() + out: list[dict[str, Any]] = [] + for ref in refs: + key = (ref.get("namespace"), ref.get("name"), ref.get("version"), ref.get("role")) + if key not in seen: + seen.add(key) + out.append(ref) + return out diff --git a/src/agentex/lib/core/tracing/obs_ids.py b/src/agentex/lib/core/tracing/obs_ids.py new file mode 100644 index 000000000..3189b6df8 --- /dev/null +++ b/src/agentex/lib/core/tracing/obs_ids.py @@ -0,0 +1,147 @@ +"""Correlate adk business spans with the active observability trace. + +The adk business ``trace_id`` is the agent **task id** (run-level: it spans the +whole agent run across many requests -- task/create, then each message/send turn), +so we must NOT overwrite it with a per-request observability trace_id. Doing so +would collapse the run-level grouping. + +Instead, each business span is *tagged* with the active observability +trace_id/span_id (this is the OpenTelemetry "span link" pattern -- correlate +across trace granularities rather than merging them). You can then pivot from a +persisted business span to the Tempo/Datadog trace for the turn that produced it, +while the business trace still groups the entire run by task id. + +Source selection follows SGP_OBS_MODE: + - unset / "dd_only": ddtrace context (current stack) + - "lgtm": OTel/LGTM only + +("dual" was removed: co-resident ddtrace+OTel can't be bridged in-process -- +you can't run ddtrace-run and the OTel operator's auto-instrumentation in the +same process, and DD_TRACE_OTEL_ENABLED yields a single tracer with nothing to +bridge. Two-backend export is a collector fan-out under "lgtm", not a mode here. +An unrecognized SGP_OBS_MODE -- including a stale "dual" -- degrades to dd_only.) + +This never fabricates ids -- if no observability context is active, it returns +an empty dict and the span is simply not tagged. +""" + +from __future__ import annotations + +import os +import logging +from typing import Dict, Tuple, Optional + +__all__ = ("get_obs_mode", "obs_correlation", "warn_on_backend_drift") + +DD_ONLY = "dd_only" +LGTM = "lgtm" +_DEFAULT_MODE = DD_ONLY +_VALID_MODES = (DD_ONLY, LGTM) + +_log = logging.getLogger(__name__) +# Deduped (expected, actual) drift directions already warned about, so a genuine +# mismatch logs once instead of once per span. Bounded by construction: at most +# the 2 direction pairs ("otel"/"ddtrace" either way). +_WARNED_DRIFT: set[Tuple[str, str]] = set() + + +def get_obs_mode() -> str: + """Unset/empty/unrecognized -> ``dd_only`` (current behavior).""" + raw = os.getenv("SGP_OBS_MODE") + if not raw: + return _DEFAULT_MODE + mode = raw.strip().lower() + return mode if mode in _VALID_MODES else _DEFAULT_MODE + + +def _lgtm_ids() -> Optional[Tuple[str, str]]: + try: + from opentelemetry import trace + except ImportError: + return None + ctx = trace.get_current_span().get_span_context() + if ctx and ctx.is_valid: + return format(ctx.trace_id, "032x"), format(ctx.span_id, "016x") + return None + + +def _ddtrace_ids() -> Optional[Tuple[str, str]]: + try: + from ddtrace.trace import tracer + except ImportError: + return None + ctx = tracer.current_trace_context() + if ctx and ctx.trace_id: + return format(ctx.trace_id, "032x"), format(ctx.span_id or 0, "016x") + return None + + +def obs_correlation(expect_otel: bool = False) -> Dict[str, str]: + """Return ``{"obs_trace_id": ..., "obs_span_id": ...}`` for the active + observability context, or ``{}`` if none is active. + + These land in the business span's ``data`` -> egp ``operation_metadata`` + (an existing JSONB column, GIN-indexed) -> ClickHouse ``metadata_raw``, so + the correlation edge needs no schema migration. Underscored keys (not + dotted) keep them addressable via Postgres JSON paths + (``operation_metadata->>'obs_trace_id'``). + + ``expect_otel``: on the Temporal path the active span is the temporalio OTel + ``TracingInterceptor`` span regardless of ``SGP_OBS_MODE``, so callers there + read OTel first (falling back to ddtrace) -- otherwise the default ``dd_only`` + mode would read ids for an unrelated ddtrace trace, not the activity span. + + Never fabricates ids -- this is a correlation tag, not the span's id. + """ + try: + if expect_otel: + ids = _lgtm_ids() or _ddtrace_ids() + else: + ids = _lgtm_ids() if get_obs_mode() == LGTM else _ddtrace_ids() + except Exception: # obs must never fail an app call + return {} + + if not ids: + return {} + return {"obs_trace_id": ids[0], "obs_span_id": ids[1]} + + +def warn_on_backend_drift(expect_otel: bool = False) -> None: + """Log once when the EXPECTED obs backend has no active span but the OTHER one + does. + + Expected backend = OTel when ``expect_otel`` (the Temporal path, where the + interceptor span is OTel regardless of ``SGP_OBS_MODE``), otherwise the backend + the mode implies. A mismatch means the mode does not match the tracer actually + running at this call site -- e.g. ``dd_only`` configured but the live span is + OTel -- which is a real config/instrumentation drift worth surfacing rather + than silently correlating against whatever happens to be live. + + Not a hard failure: obs stays fail-open (the caller still reads and falls back, + so no correlation is lost). The warning is deduped per direction, so a standing + mismatch logs once, not once per span. Probes the expected backend first and + returns early when it is live, so the healthy common path never touches the + other backend. Never raises.""" + try: + if expect_otel or get_obs_mode() == LGTM: + expected, expected_probe, other_probe, actual = "otel", _lgtm_ids, _ddtrace_ids, "ddtrace" + else: + expected, expected_probe, other_probe, actual = "ddtrace", _ddtrace_ids, _lgtm_ids, "otel" + if expected_probe() is not None: + return # expected backend is live -> healthy; skip the other probe + if other_probe() is None: + return # nothing live at all -> uninstrumented path, not drift + if (expected, actual) not in _WARNED_DRIFT: + _WARNED_DRIFT.add((expected, actual)) + _log.warning( + "obs backend drift: expected %s here (SGP_OBS_MODE=%s%s) but the " + "active span is %s; correlating against %s. Check SGP_OBS_MODE and " + "the running instrumentation.", + expected, + get_obs_mode(), + ", temporal path" if expect_otel else "", + actual, + actual, + ) + except Exception: # obs must never fail an app call + pass diff --git a/src/agentex/lib/core/tracing/obs_span.py b/src/agentex/lib/core/tracing/obs_span.py new file mode 100644 index 000000000..4c53a600b --- /dev/null +++ b/src/agentex/lib/core/tracing/obs_span.py @@ -0,0 +1,299 @@ +"""Dedicated per-business-span observability wrapper span. + +Capturing obs ids from "whatever instrumentation span happens to be innermost +at emit time" is coarse -- it could be an arbitrary httpx-client span, and every +business span in a request would collapse onto the same request/activity span. + +Instead, when the SDK creates a business span we open a **real obs span named +for that step and make it active**. Then: + - ``obs_span_id`` is stable and meaningful (a span named for the business + step, not an arbitrary leaf), and + - any nested instrumentation (httpx, db, ...) parents under it. + +The wrapper's own trace_id/span_id are read directly from its span context, so +the correlation tag is deterministic regardless of what else is on the stack. + +Backend follows ``SGP_OBS_MODE``: + - ``lgtm`` -> an OpenTelemetry span (the convergence target). + - ``dd_only`` -> a ddtrace span, but ONLY when a ddtrace trace is already + active for the request. Opening one unconditionally would emit orphan root + traces in un-instrumented (bare-uvicorn, no ddtrace-run) agents, so when + nothing is active we return ``None`` and the caller keeps its ambient + behavior. + +No-op when the relevant tracer isn't importable. Never raises -- observability +must never break a business span. +""" + +from __future__ import annotations + +from typing import Dict, Callable, Optional + +from agentex.lib.core.tracing.obs_ids import LGTM, get_obs_mode + +__all__ = ("ObsSpanHandle", "open_obs_span", "close_obs_span", "tag_ambient_obs_span") + +# Instrumentation scope name so these wrapper spans are identifiable in Tempo/DD. +_TRACER_NAME = "agentex.business" + +# Reverse-tag attribute keys: the business span/trace ids stamped onto the obs +# span so you can pivot obs -> business (search these in Tempo/DD). +_ATTR_BUSINESS_SPAN_ID = "agentex.business_span_id" +_ATTR_BUSINESS_TRACE_ID = "agentex.business_trace_id" + + +class ObsSpanHandle: + """Live handle for an open wrapper span: the correlation tag read from it + plus a backend-specific closer (detach/end or finish).""" + + __slots__ = ("correlation", "_close") + + def __init__( + self, + correlation: Dict[str, str], + close: Callable[[Optional[Dict[str, str]]], None], + ): + self.correlation = correlation + self._close = close + + def close(self, error: Optional[Dict[str, str]] = None) -> None: + """Run the backend-specific closer (detach+end for OTel, finish for + ddtrace). ``error`` marks the obs span failed so it isn't a false green.""" + self._close(error) + + +def _hex_ids(trace_id: int, span_id: int) -> Dict[str, str]: + """W3C-hex form: 32-hex trace, 16-hex span.""" + return { + "obs_trace_id": format(trace_id, "032x"), + "obs_span_id": format(span_id, "016x"), + } + + +def _open_otel_span( + name: str, + business_span_id: Optional[str], + business_trace_id: Optional[str], +) -> Optional[ObsSpanHandle]: + try: + from opentelemetry import trace, context + except ImportError: + return None + try: + span = trace.get_tracer(_TRACER_NAME).start_span(name) + if business_span_id: + span.set_attribute(_ATTR_BUSINESS_SPAN_ID, business_span_id) + if business_trace_id: + span.set_attribute(_ATTR_BUSINESS_TRACE_ID, business_trace_id) + token = context.attach(trace.set_span_in_context(span)) + sc = span.get_span_context() + if not (sc and sc.is_valid): + # No real TracerProvider installed (lgtm mode but the agent has no + # OTel provider yet): the proxy tracer hands back a NonRecordingSpan + # with an invalid context. Returning a handle with empty correlation + # here would make the caller (trace.py) take obs_handle.correlation + # == {} and NEVER consult the obs_correlation() ambient fallback -- + # so the business span would get no obs_* ids at all, strictly worse + # than falling back. Detach the useless context, end the no-op span, + # and return None so the caller uses the ambient ids instead. + context.detach(token) + span.end() + return None + correlation = _hex_ids(sc.trace_id, sc.span_id) + + def _close(error: Optional[Dict[str, str]] = None) -> None: + try: + if error: + # Reflect the business-step failure on the obs span so it + # isn't a false green when you pivot from a failed span. + span.set_status(trace.Status(trace.StatusCode.ERROR, error.get("message"))) + if error.get("type"): + span.set_attribute("error.type", error["type"]) + finally: + try: + context.detach(token) + finally: + span.end() + + return ObsSpanHandle(correlation, _close) + except Exception: # pragma: no cover - best-effort; never break the business span + return None + + +def _open_ddtrace_span( + name: str, + business_span_id: Optional[str], + business_trace_id: Optional[str], +) -> Optional[ObsSpanHandle]: + try: + from ddtrace.trace import tracer + except ImportError: + return None + try: + # Only wrap when ddtrace is actually tracing the request; otherwise a + # wrapper would be an orphan root trace in an un-instrumented process. + ctx = tracer.current_trace_context() + if ctx is None: + return None + # child_of=ctx is load-bearing: ddtrace's start_span does NOT auto-parent + # to the active span (unlike OTel), so start_span(name) alone mints a NEW + # root trace every call -- scattering a turn's business spans across N + # Datadog traces. Parenting to the active request/turn context rolls them + # into one trace while obs_span_id stays distinct per step. + span = tracer.start_span(name, child_of=ctx, activate=True) + if not span.trace_id: + # Symmetry with the OTel path: a handle carrying empty correlation + # would suppress the ambient obs_correlation() fallback in trace.py. + # (child_of=ctx normally guarantees a real trace_id, so this is + # belt-and-braces.) Finish the span and fall back to ambient ids. + span.finish() + return None + if business_span_id: + span.set_tag(_ATTR_BUSINESS_SPAN_ID, business_span_id) + if business_trace_id: + span.set_tag(_ATTR_BUSINESS_TRACE_ID, business_trace_id) + correlation = _hex_ids(span.trace_id, span.span_id) + + def _close(error: Optional[Dict[str, str]] = None) -> None: + try: + if error: + # Reflect the business-step failure on the obs span. + span.error = 1 + if error.get("type"): + span.set_tag("error.type", error["type"]) + if error.get("message"): + span.set_tag("error.message", error["message"]) + finally: + span.finish() + + return ObsSpanHandle(correlation, _close) + except Exception: # pragma: no cover - best-effort + return None + + +def open_obs_span( + name: str, + business_span_id: Optional[str] = None, + business_trace_id: Optional[str] = None, + expect_otel: bool = False, +) -> Optional[ObsSpanHandle]: + """Open an obs span named ``name`` in the active backend, make it the active + span, and return a handle carrying its ``{"obs_trace_id","obs_span_id"}``. + + ``business_span_id`` / ``business_trace_id`` are stamped onto the obs span as + the reverse tag (``agentex.business_span_id`` / ``agentex.business_trace_id``) + so you can pivot obs -> business by searching them in Tempo/DD. + + ``expect_otel``: open an OTel wrapper first, regardless of ``SGP_OBS_MODE``. + Set on the Temporal path, where the ambient span is the temporalio OTel + ``TracingInterceptor`` span regardless of mode -- an OTel wrapper nests under + it and yields valid ids, whereas the default ``dd_only`` path would open a + ddtrace wrapper, which finds no request context in a worker and returns None + (dropping the per-step span and its ids). Falls back to ddtrace if no OTel + span materializes. + + Returns ``None`` (so the caller falls back to ambient behavior) when the + backend tracer isn't available or, in ``dd_only``, no request trace is + active. + + Never raises: a top-level guard backstops anything the backend helpers + don't (e.g. a broken tracer install raising on import) so observability can + never fail an app call. + """ + try: + if expect_otel or get_obs_mode() == LGTM: + handle = _open_otel_span(name, business_span_id, business_trace_id) + if handle is not None or not expect_otel: + # In lgtm mode a None handle means "no OTel span -> caller uses the + # ambient fallback". Only when expect_otel is set (Temporal path) + # do we try ddtrace as a second choice. + return handle + return _open_ddtrace_span(name, business_span_id, business_trace_id) + return _open_ddtrace_span(name, business_span_id, business_trace_id) + except Exception: # pragma: no cover - backstop; obs must never break a call + return None + + +def _tag_otel_ambient(business_span_id: Optional[str], business_trace_id: Optional[str]) -> bool: + """Stamp the reverse tag onto the active OTel span. Returns True iff a valid + OTel span was found and tagged.""" + try: + from opentelemetry import trace + except ImportError: + return False + span = trace.get_current_span() + if span is not None and span.get_span_context().is_valid: + if business_span_id: + span.set_attribute(_ATTR_BUSINESS_SPAN_ID, business_span_id) + if business_trace_id: + span.set_attribute(_ATTR_BUSINESS_TRACE_ID, business_trace_id) + return True + return False + + +def _tag_ddtrace_ambient(business_span_id: Optional[str], business_trace_id: Optional[str]) -> bool: + """Stamp the reverse tag onto the active ddtrace span. Returns True iff a + ddtrace span was found and tagged.""" + try: + from ddtrace.trace import tracer + except ImportError: + return False + span = tracer.current_span() + if span is not None: + if business_span_id: + span.set_tag(_ATTR_BUSINESS_SPAN_ID, business_span_id) + if business_trace_id: + span.set_tag(_ATTR_BUSINESS_TRACE_ID, business_trace_id) + return True + return False + + +def tag_ambient_obs_span( + business_span_id: Optional[str] = None, + business_trace_id: Optional[str] = None, + expect_otel: bool = False, +) -> None: + """Stamp the reverse tag onto the CURRENTLY ACTIVE obs span -- without opening + a new one. + + Used inside the SDK's dispatched start-span/end-span activities (see + ``trace._in_tracing_dispatch_activity``): there we must NOT open our own + wrapper span, because start_span/end_span run as separate activities on + possibly different workers and the wrapper could never be closed. Instead we + lean on the span the Temporal OTel ``TracingInterceptor`` already made active + for this activity and just add + ``agentex.business_span_id`` / ``agentex.business_trace_id`` so the obs -> business + pivot still works. Best-effort; never raises. + + ``expect_otel``: on the Temporal path the ambient span is the temporalio OTel + ``TracingInterceptor`` span REGARDLESS of ``SGP_OBS_MODE`` -- so callers there + pass ``expect_otel=True`` to tag OTel first (falling back to ddtrace only if + no valid OTel span is active). Without this, the default ``dd_only`` mode would + tag an unrelated ddtrace span (or nothing) instead of the real activity span.""" + try: + if expect_otel: + if _tag_otel_ambient(business_span_id, business_trace_id): + return + _tag_ddtrace_ambient(business_span_id, business_trace_id) + return + if get_obs_mode() == LGTM: + _tag_otel_ambient(business_span_id, business_trace_id) + else: + _tag_ddtrace_ambient(business_span_id, business_trace_id) + except Exception: # pragma: no cover - best-effort; obs must never break a call + pass + + +def close_obs_span( + handle: Optional[ObsSpanHandle], + error: Optional[Dict[str, str]] = None, +) -> None: + """Close the wrapper span (detach + end, or finish). When ``error`` is given + (the business span failed), mark the obs span errored first so it reflects + failure rather than a false green. Safe on ``None``.""" + if handle is None: + return + try: + handle.close(error) + except Exception: # pragma: no cover - best-effort + pass diff --git a/src/agentex/lib/core/tracing/processors/agentex_tracing_processor.py b/src/agentex/lib/core/tracing/processors/agentex_tracing_processor.py new file mode 100644 index 000000000..448d013e9 --- /dev/null +++ b/src/agentex/lib/core/tracing/processors/agentex_tracing_processor.py @@ -0,0 +1,232 @@ +import os +import asyncio +import weakref +from typing import TYPE_CHECKING, Any, Dict, override + +from agentex import Agentex +from agentex.types.span import Span +from agentex.lib.types.tracing import AgentexTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.lib.adk.utils._modules.client import create_async_agentex_client +from agentex.lib.core.tracing.processors.tracing_processor_interface import ( + SyncTracingProcessor, + AsyncTracingProcessor, +) + +if TYPE_CHECKING: + from agentex import AsyncAgentex + +logger = make_logger(__name__) + + +# NOTE: This is the Agentex-backend toggle (writes to the agentex `spans` +# table via the Agentex API). It is intentionally SEPARATE from the SGP/EGP +# processor's ``AGENTEX_TRACING_SKIP_SPAN_START`` so the two backends can be +# controlled independently. +_SKIP_SPAN_START_ENV = "AGENTEX_TRACING_SKIP_AGENTEX_SPAN_START" + + +def _skip_span_start_enabled() -> bool: + """Whether to skip the Agentex span-start write and persist each span only on end. + + The Agentex processor otherwise writes every span twice: a ``spans.create`` + on start (no ``end_time``/``output`` yet) and a ``spans.update`` on end. + The start row is overwritten by the end write moments later, so persisting + it doubles the per-span HTTP/DB write volume against the Agentex control + plane — the load that timed out span-start activities and pressured the + Agentex Postgres connection pool under load. + + When enabled (the default), the start write is skipped and the END write + becomes a single ``spans.create`` carrying the complete span — one INSERT + per span instead of an INSERT + UPDATE. (A plain ``spans.update`` on end + would 404 because the row was never created.) + + Default ON. Set ``AGENTEX_TRACING_SKIP_AGENTEX_SPAN_START`` to + ``0``/``false``/``no``/``off`` to restore the start write — e.g. if you + need in-flight spans visible before they complete, or spans that never end + (process crash) to still be persisted. + """ + raw = os.environ.get(_SKIP_SPAN_START_ENV, "1").strip().lower() + return raw not in ("0", "false", "no", "off") + + +def _create_kwargs(span: Span) -> Dict[str, Any]: + """Full-span kwargs for ``spans.create`` — used on start (skip disabled) and + on end (skip enabled, single-INSERT path).""" + return { + "name": span.name, + "start_time": span.start_time, + "end_time": span.end_time, + "id": span.id, + "trace_id": span.trace_id, + "parent_id": span.parent_id, + "input": span.input, + "output": span.output, + "data": span.data, + "task_id": span.task_id, + } + + +class AgentexSyncTracingProcessor(SyncTracingProcessor): + def __init__(self, config: AgentexTracingProcessorConfig): # noqa: ARG002 + self.client = Agentex() + # Capture the skip decision once at init: both halves of a span's + # lifecycle MUST agree, otherwise a start-skip + end-update lands on a + # non-existent row (404) — or the reverse double-creates. Re-reading the + # env per event would let a mid-span toggle (tests, config reload) split + # the decision. Deploy-time flag, so a single read is correct. + self._skip_span_start = _skip_span_start_enabled() + logger.info( + "Agentex tracing span-start write %s (%s)", + "disabled — end-only ingest" if self._skip_span_start else "enabled", + _SKIP_SPAN_START_ENV, + ) + + @override + def on_span_start(self, span: Span) -> None: + # End-only ingest: by default the start write is skipped (see + # _skip_span_start_enabled) so each span is persisted once, on end. + if self._skip_span_start: + return + self.client.spans.create(**_create_kwargs(span)) + + @override + def on_span_end(self, span: Span) -> None: + # End-only ingest: the start create was skipped, so persist the complete + # span as a single INSERT here (a bare spans.update would 404 — no row). + if self._skip_span_start: + self.client.spans.create(**_create_kwargs(span)) + return + + update: Dict[str, Any] = {} + if span.trace_id: + update["trace_id"] = span.trace_id + if span.name: + update["name"] = span.name + if span.parent_id: + update["parent_id"] = span.parent_id + if span.start_time: + update["start_time"] = span.start_time.isoformat() + if span.end_time is not None: + update["end_time"] = span.end_time.isoformat() + if span.input is not None: + update["input"] = span.input + if span.output is not None: + update["output"] = span.output + if span.data is not None: + update["data"] = span.data + + self.client.spans.update( + span.id, + **span.model_dump( + mode="json", + exclude={"id"}, + exclude_defaults=True, + exclude_none=True, + exclude_unset=True, + ), + ) + + @override + def shutdown(self) -> None: + pass + + +class AgentexAsyncTracingProcessor(AsyncTracingProcessor): + def __init__(self, config: AgentexTracingProcessorConfig): # noqa: ARG002 + # Per-event-loop client cache. httpx.AsyncClient is bound to the + # loop that created it, so in sync-ACP / streaming contexts (where + # the active loop can change between requests) we keep one client + # per loop instead of disabling keepalive entirely. The cache is a + # WeakKeyDictionary so a GC'd loop and its client are evicted + # automatically — using id() as a key would reuse entries when + # CPython recycles a freed loop's memory address. + self._clients_by_loop: weakref.WeakKeyDictionary[ + asyncio.AbstractEventLoop, "AsyncAgentex" + ] = weakref.WeakKeyDictionary() + # Capture the skip decision once at init: both halves of a span's + # lifecycle MUST agree, otherwise a start-skip + end-update lands on a + # non-existent row (404) — or the reverse double-creates. Re-reading the + # env per event would let a mid-span toggle (tests, config reload) split + # the decision. Deploy-time flag, so a single read is correct. + self._skip_span_start = _skip_span_start_enabled() + logger.info( + "Agentex tracing span-start write %s (%s)", + "disabled — end-only ingest" if self._skip_span_start else "enabled", + _SKIP_SPAN_START_ENV, + ) + + def _build_client(self) -> "AsyncAgentex": + import httpx + + # Keepalive ON: connections are reused within a single event loop, + # eliminating the TLS-handshake-per-span penalty under load. + return create_async_agentex_client( + http_client=httpx.AsyncClient( + limits=httpx.Limits(max_keepalive_connections=20), + ), + ) + + @property + def client(self) -> "AsyncAgentex": + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return self._build_client() + client = self._clients_by_loop.get(loop) + if client is None: + client = self._build_client() + self._clients_by_loop[loop] = client + return client + + # TODO(AGX1-199): Add batch create/update endpoints to Agentex API and use + # them here instead of one HTTP call per span. + # https://linear.app/scale-epd/issue/AGX1-199/add-agentex-batch-endpoint-for-traces + @override + async def on_span_start(self, span: Span) -> None: + # End-only ingest: by default the start write is skipped (see + # _skip_span_start_enabled) so each span is persisted once, on end. + if self._skip_span_start: + return + await self.client.spans.create(**_create_kwargs(span)) + + @override + async def on_span_end(self, span: Span) -> None: + # End-only ingest: the start create was skipped, so persist the complete + # span as a single INSERT here (a bare spans.update would 404 — no row). + if self._skip_span_start: + await self.client.spans.create(**_create_kwargs(span)) + return + + update: Dict[str, Any] = {} + if span.trace_id: + update["trace_id"] = span.trace_id + if span.name: + update["name"] = span.name + if span.parent_id: + update["parent_id"] = span.parent_id + if span.start_time: + update["start_time"] = span.start_time.isoformat() + if span.end_time: + update["end_time"] = span.end_time.isoformat() + if span.input: + update["input"] = span.input + if span.output: + update["output"] = span.output + if span.data: + update["data"] = span.data + + await self.client.spans.update( + span.id, + **span.model_dump( + mode="json", + exclude={"id"}, + exclude_defaults=True, + exclude_none=True, + exclude_unset=True, + ), + ) + + @override + async def shutdown(self) -> None: + pass diff --git a/src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py b/src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py new file mode 100644 index 000000000..9ee269231 --- /dev/null +++ b/src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +import os +import asyncio +import weakref +from typing import Any, cast, override + +import scale_gp_beta.lib.tracing as tracing +from scale_gp_beta import SGPClient, AsyncSGPClient +from scale_gp_beta.lib.tracing import create_span, flush_queue +from scale_gp_beta.lib.tracing.span import Span as SGPSpan + +from agentex.types.span import Span +from agentex.lib.core.tracing import code_revision +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.lib.core.observability import tracing_metrics_recording as _metrics +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.tracing.span_error import get_span_error +from agentex.lib.core.tracing.processors.tracing_processor_interface import ( + SyncTracingProcessor, + AsyncTracingProcessor, +) + +logger = make_logger(__name__) + + +_SKIP_SPAN_START_ENV = "AGENTEX_TRACING_SKIP_SPAN_START" + + +def _skip_span_start_enabled() -> bool: + """Whether to skip the span-start upsert and write each span only on end. + + Tracing writes each span twice — once on start (no ``end_time``) and once + on end. The start row is only ever overwritten by the end write moments + later, so persisting it doubles span-ingest write volume and, on the SGP + backend, costs a non-HOT UPDATE (tsvector/GIN recompute + index churn) plus + a dead tuple per span. Skipping the start makes the end write a single + INSERT. + + Default ON. Set ``AGENTEX_TRACING_SKIP_SPAN_START`` to + ``0``/``false``/``no``/``off`` to restore the start write — e.g. if you + need in-flight spans visible before they complete, or spans that never end + (process crash) to still be persisted. + """ + raw = os.environ.get(_SKIP_SPAN_START_ENV, "1").strip().lower() + return raw not in ("0", "false", "no", "off") + + +def _get_span_type(span: Span) -> str: + """Read span_type from span.data['__span_type__'], defaulting to STANDALONE.""" + if isinstance(span.data, dict): + value = span.data.get("__span_type__", "STANDALONE") + return str(value) + return "STANDALONE" + + +def _add_source_to_span(span: Span, env_vars: EnvironmentVariables) -> None: + if span.data is None: + span.data = {} + if isinstance(span.data, dict): + span.data["__source__"] = "agentex" + if env_vars.ACP_TYPE is not None: + span.data["__acp_type__"] = env_vars.ACP_TYPE + if env_vars.AGENT_NAME is not None: + span.data["__agent_name__"] = env_vars.AGENT_NAME + if env_vars.AGENT_ID is not None: + span.data["__agent_id__"] = env_vars.AGENT_ID + if env_vars.AGENT_VERSION is not None: + span.data["__agent_version__"] = env_vars.AGENT_VERSION + + +def _sgp_metadata(span: Span) -> Any: + """Metadata for the SGP write: ``span.data`` plus the opt-in commit SHA. + + Returns a COPY rather than mutating ``span``. ``trace.py`` hands the same + Span instance to every registered processor, so anything written onto + ``span.data`` here would also be serialized by the Agentex processor and + show up in caller-visible span data. ``__commit_sha__`` is opt-in and + SGP-scoped, so it must not leak that way. + + (The ``__source__`` / ``__agent_*`` keys set by ``_add_source_to_span`` do + leak like that today. Left as-is: changing five long-shipped fields is not + this change's business.) + """ + commit_sha = code_revision.commit_sha() + if commit_sha is None: + return span.data + if isinstance(span.data, dict): + return {**span.data, code_revision.COMMIT_SHA_KEY: commit_sha} + # List-shaped data is an accepted `data` shape and has nowhere to put a + # metadata key; leave it untouched rather than dropping the caller's data. + return span.data + + +def _build_sgp_span(span: Span, env_vars: EnvironmentVariables) -> SGPSpan: + """Build an SGPSpan from an agentex Span. Idempotent on span_id at the SGP backend.""" + _add_source_to_span(span, env_vars) + sgp_span = cast( + SGPSpan, + create_span( + name=span.name, + span_type=_get_span_type(span), + span_id=span.id, + parent_id=span.parent_id, + trace_id=span.trace_id, + input=span.input, + output=span.output, + metadata=_sgp_metadata(span), + ), + ) + sgp_span.start_time = span.start_time.isoformat() # type: ignore[union-attr] + error = get_span_error(span) + if error is not None: + sgp_span.set_error(error_type=error["type"], error_message=error["message"]) + sgp_span.metadata["error_category"] = error.get("category", "unknown") + return sgp_span + + +class SGPSyncTracingProcessor(SyncTracingProcessor): + def __init__(self, config: SGPTracingProcessorConfig): + disabled = config.sgp_api_key == "" or config.sgp_account_id == "" + tracing.init( + SGPClient( + api_key=config.sgp_api_key, + account_id=config.sgp_account_id, + base_url=config.sgp_base_url, + ), + disabled=disabled, + ) + self.env_vars = EnvironmentVariables.refresh() + logger.info( + "SGP tracing span-start upsert %s (%s)", + "disabled — end-only ingest" if _skip_span_start_enabled() else "enabled", + _SKIP_SPAN_START_ENV, + ) + + @override + def on_span_start(self, span: Span) -> None: + # End-only ingest: by default the start write is skipped (see + # _skip_span_start_enabled) so each span is persisted once, on end. + if _skip_span_start_enabled(): + return + sgp_span = _build_sgp_span(span, self.env_vars) + sgp_span.flush(blocking=False) + + @override + def on_span_end(self, span: Span) -> None: + sgp_span = _build_sgp_span(span, self.env_vars) + sgp_span.end_time = span.end_time.isoformat() # type: ignore[union-attr] + sgp_span.flush(blocking=False) + + @override + def shutdown(self) -> None: + flush_queue() + + +class SGPAsyncTracingProcessor(AsyncTracingProcessor): + def __init__(self, config: SGPTracingProcessorConfig): + self.disabled = config.sgp_api_key == "" or config.sgp_account_id == "" + self._config = config + # Per-event-loop client cache. httpx.AsyncClient ties its connection + # pool to the loop it was created on; in sync-ACP / streaming contexts + # the active loop can change between requests. Caching per loop lets + # us keep keepalive on within each loop while staying safe across + # loops. The cache is a WeakKeyDictionary so a GC'd loop and its + # client are evicted automatically — using id() as a key would reuse + # entries when CPython recycles a freed loop's memory address. + self._clients_by_loop: weakref.WeakKeyDictionary[ + asyncio.AbstractEventLoop, AsyncSGPClient + ] = weakref.WeakKeyDictionary() + self.env_vars = EnvironmentVariables.refresh() + logger.info( + "SGP tracing span-start upsert %s (%s)", + "disabled — end-only ingest" if _skip_span_start_enabled() else "enabled", + _SKIP_SPAN_START_ENV, + ) + + def _build_client(self) -> AsyncSGPClient: + import httpx + + return AsyncSGPClient( + api_key=self._config.sgp_api_key, + account_id=self._config.sgp_account_id, + base_url=self._config.sgp_base_url, + # Keepalive ON: connections are reused within a single event loop, + # which removes the TLS-handshake-per-span penalty observed under + # load. Cross-loop safety is preserved by the per-loop cache. + http_client=httpx.AsyncClient( + limits=httpx.Limits(max_keepalive_connections=20), + ), + ) + + def _get_client(self) -> AsyncSGPClient | None: + """Return the AsyncSGPClient bound to the current event loop, creating + one on first use. Returns None when the processor is disabled.""" + if self.disabled: + return None + try: + loop = asyncio.get_running_loop() + except RuntimeError: + # Called from outside an event loop — should not happen on the + # hot path, but build a one-off client rather than crashing. + return self._build_client() + client = self._clients_by_loop.get(loop) + if client is None: + client = self._build_client() + self._clients_by_loop[loop] = client + return client + + @override + async def on_span_start(self, span: Span) -> None: + await self.on_spans_start([span]) + + @override + async def on_span_end(self, span: Span) -> None: + await self.on_spans_end([span]) + + @override + async def on_spans_start(self, spans: list[Span]) -> None: + # End-only ingest: by default the start write is skipped (see + # _skip_span_start_enabled) so each span is persisted once, on end. + if _skip_span_start_enabled(): + return + if not spans: + return + + client = self._get_client() + if client is None: + logger.warning("SGP is disabled, skipping span upsert") + return + + sgp_spans = [_build_sgp_span(span, self.env_vars) for span in spans] + await client.spans.upsert_batch(items=[s.to_request_params() for s in sgp_spans]) + _metrics.record_export_success( + event_type="start", span_count=len(spans), processor="sgp" + ) + + @override + async def on_spans_end(self, spans: list[Span]) -> None: + if not spans: + return + + client = self._get_client() + if client is None: + return + + sgp_spans: list[SGPSpan] = [] + for span in spans: + sgp_span = _build_sgp_span(span, self.env_vars) + sgp_span.end_time = span.end_time.isoformat() # type: ignore[union-attr] + sgp_spans.append(sgp_span) + await client.spans.upsert_batch(items=[s.to_request_params() for s in sgp_spans]) + _metrics.record_export_success( + event_type="end", span_count=len(spans), processor="sgp" + ) + + @override + async def shutdown(self) -> None: + pass diff --git a/src/agentex/lib/core/tracing/processors/tracing_processor_interface.py b/src/agentex/lib/core/tracing/processors/tracing_processor_interface.py new file mode 100644 index 000000000..f352f38c4 --- /dev/null +++ b/src/agentex/lib/core/tracing/processors/tracing_processor_interface.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import asyncio +from abc import ABC, abstractmethod + +from agentex.types.span import Span +from agentex.lib.types.tracing import TracingProcessorConfig +from agentex.lib.utils.logging import make_logger + +logger = make_logger(__name__) + + +class SyncTracingProcessor(ABC): + @abstractmethod + def __init__(self, config: TracingProcessorConfig): + pass + + @abstractmethod + def on_span_start(self, span: Span) -> None: + pass + + @abstractmethod + def on_span_end(self, span: Span) -> None: + pass + + @abstractmethod + def shutdown(self) -> None: + pass + + +class AsyncTracingProcessor(ABC): + @abstractmethod + def __init__(self, config: TracingProcessorConfig): + pass + + @abstractmethod + async def on_span_start(self, span: Span) -> None: + pass + + @abstractmethod + async def on_span_end(self, span: Span) -> None: + pass + + async def on_spans_start(self, spans: list[Span]) -> None: + """Batched variant of on_span_start. + + Default fallback fans out to the single-span method in parallel so + existing processors keep working unchanged. Processors that support + real batching (e.g. sending all spans in one HTTP call) should + override this to avoid the per-span round trip. + + Per-span exceptions are captured and logged individually so that one + failing span does not prevent the others from being processed. + """ + results = await asyncio.gather( + *(self.on_span_start(s) for s in spans), return_exceptions=True + ) + for span, result in zip(spans, results): + if isinstance(result, Exception): + logger.error( + "Tracing processor %s failed on_span_start for span %s", + type(self).__name__, + span.id, + exc_info=result, + ) + + async def on_spans_end(self, spans: list[Span]) -> None: + """Batched variant of on_span_end. See on_spans_start for details.""" + results = await asyncio.gather( + *(self.on_span_end(s) for s in spans), return_exceptions=True + ) + for span, result in zip(spans, results): + if isinstance(result, Exception): + logger.error( + "Tracing processor %s failed on_span_end for span %s", + type(self).__name__, + span.id, + exc_info=result, + ) + + @abstractmethod + async def shutdown(self) -> None: + pass diff --git a/src/agentex/lib/core/tracing/span_error.py b/src/agentex/lib/core/tracing/span_error.py new file mode 100644 index 000000000..f20eae471 --- /dev/null +++ b/src/agentex/lib/core/tracing/span_error.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from typing import Any, cast + +from scale_gp_beta.lib.tracing import ( + PlatformError as PlatformError, + ApplicationError as ApplicationError, + CategorizedError, +) +from scale_gp_beta.lib.tracing.types import ErrorCategory + +from agentex.types.span import Span + +# Reserved key under ``Span.data`` carrying failure info for a span whose +# context-manager body raised. Mirrors the existing ``__span_type__`` / +# ``__source__`` reserved-key convention already read/written by the SGP +# processor. Stored in ``data`` because the Span model is generated from the +# OpenAPI spec and has no first-class status/error field; ``data`` is a real +# field, so it survives ``model_copy(deep=True)`` and round-trips to both the +# SGP and agentex-native span stores. +SPAN_ERROR_KEY = "__error__" + +ERROR_CATEGORY_UNKNOWN: ErrorCategory = "unknown" +_ERROR_CATEGORIES = frozenset({"application", "platform", "unknown"}) + + +def _normalize_error_category(value: object) -> ErrorCategory | None: + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in _ERROR_CATEGORIES: + return cast(ErrorCategory, normalized) + return None + + +def _error_category( + exc: BaseException, + explicit_category: ErrorCategory | str | None = None, +) -> ErrorCategory: + """Return an explicit producer classification, defaulting safely to unknown.""" + return ( + _normalize_error_category(explicit_category) + or (exc.error_category if isinstance(exc, CategorizedError) else None) + or ERROR_CATEGORY_UNKNOWN + ) + + +def set_span_error( + span: Span, + exc: BaseException, + *, + error_category: ErrorCategory | str | None = None, +) -> None: + """Record an exception on ``span`` under ``data[SPAN_ERROR_KEY]``. + + An explicit ``error_category`` takes precedence over a ``CategorizedError`` + classification. Invalid or absent categories become unknown. + No-op when ``span.data`` is a list (matching ``_add_source_to_span``, which + only attaches metadata to dict-shaped data). + """ + error = { + "type": type(exc).__name__, + "message": str(exc), + "category": _error_category(exc, error_category), + } + if span.data is None: + span.data = {} + if isinstance(span.data, dict): + span.data[SPAN_ERROR_KEY] = error + + +def get_span_error(span: Span) -> dict[str, Any] | None: + """Return the error recorded by :func:`set_span_error`, or ``None``.""" + if isinstance(span.data, dict): + value = span.data.get(SPAN_ERROR_KEY) + if isinstance(value, dict): + return value + return None diff --git a/src/agentex/lib/core/tracing/span_queue.py b/src/agentex/lib/core/tracing/span_queue.py new file mode 100644 index 000000000..d6ff7c1f6 --- /dev/null +++ b/src/agentex/lib/core/tracing/span_queue.py @@ -0,0 +1,477 @@ +from __future__ import annotations + +import os +import time +import asyncio +from enum import Enum +from dataclasses import dataclass + +from agentex.types.span import Span +from agentex.lib.utils.logging import make_logger +from agentex.lib.core.observability import tracing_metrics_recording as _metrics +from agentex.lib.core.tracing.processors.tracing_processor_interface import ( + AsyncTracingProcessor, +) + +logger = make_logger(__name__) + +# Max spans coalesced into one ``upsert_batch`` HTTP call (one +# ``INSERT ... ON CONFLICT`` statement server-side). Larger batches amortize +# the per-request round trip and the per-statement parse/plan + index +# maintenance overhead, which dominates at high span volume. Kept well under +# the EGP backend's 1000-row cap; tune per-deploy via +# ``AGENTEX_SPAN_QUEUE_BATCH_SIZE``. +_DEFAULT_BATCH_SIZE = 200 +# Max time the drain lingers after the first span to let a batch fill. Spans +# typically arrive a few ms apart, so a longer linger fills the larger batch +# above rather than shipping near-size-1 batches; bounded so worst-case ingest +# latency (and the in-flight loss window) stays sub-second. +_DEFAULT_LINGER_MS = 250 +# 0 == unbounded (preserves prior behavior). A bound makes backpressure +# visible (dropped spans are counted) and caps worst-case memory. +_DEFAULT_MAX_SIZE = 0 +# Total attempts per batch for a *transient* failure (1 == no retry). +_DEFAULT_MAX_RETRIES = 1 +# Max number of batch-export HTTP requests in flight at once. The export +# backend (EGP) processes each upsert_batch in ~150ms but serves many requests +# concurrently; issuing one batch at a time caps per-pod egress at ~1/latency. +# Sending several concurrently lets a pod keep up with span production under +# load. ``1`` restores the old strictly-serial behavior. +_DEFAULT_CONCURRENCY = 3 +# HTTP statuses worth retrying at the queue level. These are explicit +# backpressure / transient signals; everything else (esp. 401/403/4xx auth and +# validation errors) is a permanent failure that re-enqueuing cannot fix. Note +# the underlying SGP client already retries these internally, so queue-level +# retry only helps when its budget is exhausted by a longer blip. +_RETRYABLE_STATUS_CODES = frozenset({429, 500, 502, 503, 504}) + + +def _read_int_env(name: str, default: int, *, minimum: int = 0) -> int: + """Read a non-negative int from the environment, clamping to ``minimum`` + and falling back to ``default`` when unset or unparseable.""" + raw = os.environ.get(name) + if raw is None: + return default + try: + return max(minimum, int(raw)) + except ValueError: + logger.warning("Ignoring invalid %s=%r; using default %d", name, raw, default) + return default + + +def _read_linger_ms_env() -> int: + """Read AGENTEX_SPAN_QUEUE_LINGER_MS from the environment, falling back to + _DEFAULT_LINGER_MS when unset or unparseable. Negative values are clamped + to 0 (i.e. "drain immediately, no linger").""" + return _read_int_env("AGENTEX_SPAN_QUEUE_LINGER_MS", _DEFAULT_LINGER_MS) + + +def _is_retryable_exc(exc: BaseException) -> bool: + """A failure is retryable only when it carries an HTTP ``status_code`` in + the retryable set. Connection/timeout errors (no status_code) have already + been retried by the SGP client, and bare exceptions (programming bugs) must + never be retried — re-enqueuing them would spin forever.""" + status_code = getattr(exc, "status_code", None) + return isinstance(status_code, int) and status_code in _RETRYABLE_STATUS_CODES + + +class SpanEventType(str, Enum): + START = "start" + END = "end" + + +@dataclass +class _SpanQueueItem: + event_type: SpanEventType + span: Span + processors: list[AsyncTracingProcessor] + enqueued_at: float | None = None + # Number of times this item has already been dispatched. Used to bound + # re-enqueue on transient failures. + attempts: int = 0 + + +class AsyncSpanQueue: + """Background FIFO queue for async span processing. + + Span events are enqueued synchronously (non-blocking) and drained by a + background task. The drain coalesces ready events into batches and + *dispatches* each batch's export as its own task, so up to ``concurrency`` + batch requests can be in flight at once. This matters because each + ``upsert_batch`` HTTP call takes tens-to-hundreds of ms server-side; issuing + them one at a time caps a pod's egress at ~1/latency and lets a backlog + build under load. + + Ordering guarantee: a span's START export always completes before its END + export is issued. END batches wait on the START batches that were in flight + when they were formed; because a span's START is always enqueued before its + END, that span's START send is either still in flight (and waited on) or + already finished. Independent spans export fully concurrently. + + Once the drain loop picks up the first item, it lingers up to ``linger_ms`` + waiting for more items to coalesce into the same batch. Without the linger + the drain almost always returned size-1 batches under real agent workloads, + because spans typically arrive a few ms apart. + + Reliability: + - ``max_size`` bounds the queue. When full, new events are dropped and + counted (see ``dropped_spans``) rather than growing memory without limit. + ``0`` keeps the queue unbounded. + - A batch that fails with a *transient* HTTP status (429/5xx) is + re-enqueued up to ``max_retries`` total attempts. Permanent failures + (auth/validation/bugs) are dropped and counted immediately. + """ + + def __init__( + self, + batch_size: int | None = None, + linger_ms: int | None = None, + max_size: int | None = None, + max_retries: int | None = None, + concurrency: int | None = None, + ) -> None: + resolved_max_size = ( + _read_int_env("AGENTEX_SPAN_QUEUE_MAX_SIZE", _DEFAULT_MAX_SIZE) if max_size is None else max(0, max_size) + ) + self._queue: asyncio.Queue[_SpanQueueItem] = asyncio.Queue(maxsize=resolved_max_size) + self._drain_task: asyncio.Task[None] | None = None + self._stopping = False + self._batch_size = ( + _read_int_env("AGENTEX_SPAN_QUEUE_BATCH_SIZE", _DEFAULT_BATCH_SIZE, minimum=1) + if batch_size is None + else max(1, batch_size) + ) + self._linger_ms = _read_linger_ms_env() if linger_ms is None else max(0, linger_ms) + self._max_retries = ( + _read_int_env("AGENTEX_SPAN_QUEUE_MAX_RETRIES", _DEFAULT_MAX_RETRIES, minimum=1) + if max_retries is None + else max(1, max_retries) + ) + self._concurrency = ( + _read_int_env("AGENTEX_SPAN_QUEUE_CONCURRENCY", _DEFAULT_CONCURRENCY, minimum=1) + if concurrency is None + else max(1, concurrency) + ) + # Bounds concurrent export HTTP requests. + self._send_sema = asyncio.Semaphore(self._concurrency) + # Outstanding dispatched send tasks, and the subset that are START + # sends (END sends wait on these to preserve per-span ordering). + self._inflight: set[asyncio.Task[None]] = set() + self._inflight_starts: set[asyncio.Task[None]] = set() + # Total spans dropped for any reason (full queue, shutdown, permanent + # failure, exhausted retries). Surfaced for metrics/observability so + # span loss stops being silent. + self._dropped_spans = 0 + + @property + def dropped_spans(self) -> int: + """Cumulative count of spans dropped (never delivered).""" + return self._dropped_spans + + @property + def depth(self) -> int: + """Current number of items waiting in the queue.""" + return self._queue.qsize() + + def _record_drop(self, count: int, reason: str) -> None: + if count <= 0: + return + self._dropped_spans += count + if "shutting down" in reason: + _metrics.record_span_dropped("shutdown", count) + elif "queue full" in reason: + _metrics.record_span_dropped("queue_full", count) + # Warn on the first drop and then sparsely, so a drop storm is visible + # without flooding the log. + if self._dropped_spans == count or self._dropped_spans % 100 < count: + logger.warning( + "Span queue dropped %d span(s) (%s); %d dropped in total", + count, + reason, + self._dropped_spans, + ) + + def enqueue( + self, + event_type: SpanEventType, + span: Span, + processors: list[AsyncTracingProcessor], + ) -> None: + if self._stopping: + self._record_drop(1, "queue shutting down") + return + self._ensure_drain_running() + try: + self._queue.put_nowait( + _SpanQueueItem( + event_type=event_type, + span=span, + processors=processors, + enqueued_at=_metrics.monotonic_if_enabled(), + ) + ) + _metrics.record_span_enqueued(event_type.value) + except asyncio.QueueFull: + self._record_drop(1, "queue full") + + def _ensure_drain_running(self) -> None: + if self._drain_task is None or self._drain_task.done(): + self._drain_task = asyncio.create_task(self._drain_loop()) + + # ------------------------------------------------------------------ + # Drain loop + # ------------------------------------------------------------------ + + async def _drain_loop(self) -> None: + while True: + # Backpressure: cap the number of in-flight send tasks so the drain + # does not run unboundedly ahead of the exporters. + while len(self._inflight) >= self._concurrency: + await asyncio.wait(set(self._inflight), return_when=asyncio.FIRST_COMPLETED) + + # Block until at least one item is available. + first = await self._queue.get() + batch: list[_SpanQueueItem] = [first] + + # Linger briefly so spans emitted within the window coalesce into + # one batch. Stop early when the batch fills, when the linger + # window elapses, or as soon as the queue is briefly empty *after* + # the deadline. + if self._linger_ms > 0 and not self._stopping: + loop = asyncio.get_running_loop() + deadline = loop.time() + (self._linger_ms / 1000.0) + while len(batch) < self._batch_size: + remaining = deadline - loop.time() + if remaining <= 0: + break + try: + batch.append(await asyncio.wait_for(self._queue.get(), timeout=remaining)) + except asyncio.TimeoutError: + break + else: + # No linger — drain whatever is already queued and stop. + while len(batch) < self._batch_size: + try: + batch.append(self._queue.get_nowait()) + except asyncio.QueueEmpty: + break + + _metrics.record_batch_coalesced( + queue_depth=self._queue.qsize() + len(batch), + batch_items=batch, + ) + + # Separate START and END events and dispatch each as its own send + # task. Dispatching STARTs first (so they are registered before the + # END snapshot) guarantees an END never outruns a START of the same + # span whose events land in this batch. + starts = [i for i in batch if i.event_type == SpanEventType.START] + ends = [i for i in batch if i.event_type == SpanEventType.END] + if starts: + self._dispatch(starts, SpanEventType.START) + if ends: + # Re-check backpressure before the second dispatch so a batch + # carrying both event types can't push _inflight past the cap. + while len(self._inflight) >= self._concurrency: + await asyncio.wait(set(self._inflight), return_when=asyncio.FIRST_COMPLETED) + self._dispatch(ends, SpanEventType.END) + + def _dispatch(self, items: list[_SpanQueueItem], event_type: SpanEventType) -> None: + """Spawn a background task to export ``items``. + + END sends snapshot the currently in-flight START tasks and wait for them + before issuing, preserving the per-span START-before-END invariant. + """ + barrier = tuple(self._inflight_starts) if event_type == SpanEventType.END else () + task = asyncio.create_task(self._run_send(items, barrier)) + self._inflight.add(task) + task.add_done_callback(self._inflight.discard) + if event_type == SpanEventType.START: + self._inflight_starts.add(task) + task.add_done_callback(self._inflight_starts.discard) + + async def _run_send(self, items: list[_SpanQueueItem], barrier: tuple[asyncio.Task[None], ...]) -> None: + try: + if barrier: + # Wait for the START sends this END batch depends on. Their + # exceptions are irrelevant here — we only need them finished. + await asyncio.gather(*barrier, return_exceptions=True) + phase_start = time.perf_counter() + await self._process_items(items) + if items: + _metrics.record_batch_phase( + phase=items[0].event_type.value, + size=len(items), + duration_ms=(time.perf_counter() - phase_start) * 1000.0, + ) + finally: + # Mark every item done so shutdown's queue.join() can complete only + # once all sends (and their retries) have finished. + for _ in items: + self._queue.task_done() + + async def _process_items(self, items: list[_SpanQueueItem]) -> None: + """Dispatch a batch of same-event-type items to each processor in one call. + + Groups spans by processor so each processor sees its full slice of the + drain batch at once. Processors that override the batched methods can + then send a single HTTP request per drain cycle instead of N. + """ + if not items: + return + + event_type = items[0].event_type + assert all(i.event_type == event_type for i in items), ( + "_process_items requires all items to share the same event_type; " + "callers must split START and END batches before dispatching." + ) + by_processor: dict[AsyncTracingProcessor, list[_SpanQueueItem]] = {} + for item in items: + for p in item.processors: + by_processor.setdefault(p, []).append(item) + + await asyncio.gather(*[self._handle(p, batch, event_type) for p, batch in by_processor.items()]) + + async def _handle( + self, + p: AsyncTracingProcessor, + items: list[_SpanQueueItem], + event_type: SpanEventType, + ) -> None: + spans = [item.span for item in items] + try: + # Hold a concurrency slot only for the duration of the HTTP call. + async with self._send_sema: + if event_type == SpanEventType.START: + await p.on_spans_start(spans) + else: + await p.on_spans_end(spans) + except Exception as exc: + self._handle_failure(p, items, event_type, exc) + + def _handle_failure( + self, + p: AsyncTracingProcessor, + items: list[_SpanQueueItem], + event_type: SpanEventType, + exc: Exception, + ) -> None: + # Re-enqueue transient failures, drop everything else. Re-enqueue is + # bounded by max_retries, so even during shutdown the queue's join() + # still terminates after a finite number of passes. + if _is_retryable_exc(exc): + retriable = [item for item in items if item.attempts + 1 < self._max_retries] + exhausted = len(items) - len(retriable) + if exhausted: + self._record_drop(exhausted, f"{type(p).__name__} retries exhausted during {event_type.value}") + _metrics.record_export_failure( + processor=p, + event_type=event_type.value, + span_count=exhausted, + exc=exc, + ) + for item in retriable: + self._reenqueue(item, p) + if retriable: + logger.warning( + "Tracing processor %s failed handling %d spans during %s (%s); re-enqueued %d for retry", + type(p).__name__, + len(items), + event_type.value, + type(exc).__name__, + len(retriable), + ) + return + + self._record_drop(len(items), f"{type(p).__name__} permanent failure during {event_type.value}") + logger.exception( + "Tracing processor %s failed handling %d spans during %s", + type(p).__name__, + len(items), + event_type.value, + ) + _metrics.record_export_failure( + processor=p, + event_type=event_type.value, + span_count=len(items), + exc=exc, + ) + + def _reenqueue(self, item: _SpanQueueItem, p: AsyncTracingProcessor) -> None: + """Put a single failed item back on the queue, scoped to the processor + that failed, with an incremented attempt count. + + NOTE: a re-enqueued START goes to the *back* of the queue. If an END + for the same span is dispatched concurrently before this START is picked + up again, the END's barrier snapshot won't contain it, breaking the + START-before-END guarantee for that span. This is benign at the default + ``max_retries=1`` (retries disabled) but must be addressed before + enabling retries by default.""" + try: + self._queue.put_nowait( + _SpanQueueItem( + event_type=item.event_type, + span=item.span, + processors=[p], + enqueued_at=item.enqueued_at, + attempts=item.attempts + 1, + ) + ) + except asyncio.QueueFull: + self._record_drop(1, "queue full on retry") + + # ------------------------------------------------------------------ + # Shutdown + # ------------------------------------------------------------------ + + async def shutdown(self, timeout: float = 30.0) -> None: + self._stopping = True + drain_idle = self._drain_task is None or self._drain_task.done() + if self._queue.empty() and drain_idle and not self._inflight: + return + + timed_out = False + try: + # join() returns once every enqueued (and re-enqueued) item has been + # marked done by its send task. + await asyncio.wait_for(self._queue.join(), timeout=timeout) + except asyncio.TimeoutError: + timed_out = True + remaining = self._queue.qsize() + logger.warning( + "Span queue shutdown timed out after %.1fs with %d items remaining", timeout, remaining + ) + _metrics.record_shutdown_timeout(remaining_items=remaining) + + if self._drain_task is not None and not self._drain_task.done(): + self._drain_task.cancel() + try: + await self._drain_task + except asyncio.CancelledError: + pass + + # Clean up any in-flight send tasks. On a clean shutdown these are + # already finishing; on timeout, cancel the stragglers so we don't hang. + inflight = list(self._inflight) + if inflight: + if timed_out: + for task in inflight: + task.cancel() + await asyncio.gather(*inflight, return_exceptions=True) + + +_default_span_queue: AsyncSpanQueue | None = None + + +def get_default_span_queue() -> AsyncSpanQueue: + global _default_span_queue + if _default_span_queue is None: + _default_span_queue = AsyncSpanQueue() + return _default_span_queue + + +async def shutdown_default_span_queue(timeout: float = 30.0) -> None: + global _default_span_queue + if _default_span_queue is not None: + await _default_span_queue.shutdown(timeout=timeout) + _default_span_queue = None diff --git a/src/agentex/lib/core/tracing/temporal.py b/src/agentex/lib/core/tracing/temporal.py new file mode 100644 index 000000000..484abc26b --- /dev/null +++ b/src/agentex/lib/core/tracing/temporal.py @@ -0,0 +1,73 @@ +"""OpenTelemetry trace-context propagation across Temporal boundaries. + +Temporal serializes ``start_workflow`` / ``execute_activity`` across (potentially +cross-process) boundaries, and does NOT carry the active W3C ``traceparent`` by +default. So any span created inside a workflow or activity becomes a **new +detached root** -- the trace shatters at every Temporal hop. + +This bites agentex directly: ``adk.tracing.span`` runs span creation as a +Temporal activity when ``in_temporal_workflow()`` is true, so without propagation +those business spans detach from the turn's obs trace. + +Wiring temporalio's first-party ``TracingInterceptor`` onto the Temporal client +and worker injects the active span context into Temporal headers on the caller +side and extracts + continues it on the workflow/activity side, using the global +OpenTelemetry propagator -- so ``client -> workflow -> activity`` is one trace. + +Enabled by DEFAULT. Set ``AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED=false`` +(also accepts ``0`` / ``no`` / ``off``) to turn it off. It also degrades to a +no-op -- and never raises -- if temporalio's OpenTelemetry contrib isn't +importable, so enabling it by default can't break a worker. +""" + +from __future__ import annotations + +import os +from typing import Any + +from agentex.lib.utils.logging import make_logger + +logger = make_logger(__name__) + +_ENABLE_ENV = "AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED" +_FALSEY = {"0", "false", "no", "off"} + + +def temporal_trace_interceptor_enabled() -> bool: + """Whether the Temporal OTel trace interceptor should be installed. + + Defaults to True; disabled only when ``AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED`` + is set to a falsy value (``0`` / ``false`` / ``no`` / ``off``).""" + return os.environ.get(_ENABLE_ENV, "true").strip().lower() not in _FALSEY + + +def temporal_tracing_interceptors() -> list[Any]: + """Interceptors that propagate OpenTelemetry trace context across Temporal. + + Returns ``[TracingInterceptor()]`` (enabled by default) so callers can splat + it into a client's / worker's ``interceptors=`` list. Returns ``[]`` when + disabled via env, or when temporalio's OpenTelemetry contrib is not + importable. Never raises -- observability wiring must not break a worker. + + ``TracingInterceptor`` implements both the client and worker interceptor + interfaces, so the same call is used on both sides: + - on the **client**, it injects context on outbound ``start_workflow`` / + ``execute_activity`` calls; + - on the **worker**, it extracts context and roots the workflow / activity + execution spans under it. + """ + if not temporal_trace_interceptor_enabled(): + logger.info("Temporal OTel trace interceptor disabled via %s", _ENABLE_ENV) + return [] + try: + from temporalio.contrib.opentelemetry import TracingInterceptor + + # Construct inside the try so a constructor failure (not just a missing + # contrib) also falls back to a no-op instead of aborting worker startup. + return [TracingInterceptor()] + except Exception as exc: # contrib unavailable OR constructor failure -> no-op, never raise + logger.warning( + "Temporal OTel trace interceptor unavailable (%s); traces will not propagate across Temporal boundaries.", + exc, + ) + return [] diff --git a/src/agentex/lib/core/tracing/trace.py b/src/agentex/lib/core/tracing/trace.py new file mode 100644 index 000000000..8f5260913 --- /dev/null +++ b/src/agentex/lib/core/tracing/trace.py @@ -0,0 +1,567 @@ +from __future__ import annotations + +import uuid +from typing import Any, AsyncGenerator +from datetime import UTC, datetime +from contextlib import contextmanager, asynccontextmanager +from collections import OrderedDict + +from pydantic import BaseModel + +from agentex import Agentex, AsyncAgentex +from agentex.types.span import Span +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.model_utils import recursive_model_dump +from agentex.lib.core.tracing.obs_ids import obs_correlation, warn_on_backend_drift +from agentex.lib.core.tracing.obs_span import ( + ObsSpanHandle, + open_obs_span, + close_obs_span, + tag_ambient_obs_span, +) +from agentex.lib.core.tracing.span_error import get_span_error, set_span_error +from agentex.lib.core.tracing.span_queue import ( + SpanEventType, + AsyncSpanQueue, + get_default_span_queue, +) +from agentex.lib.core.tracing.processors.tracing_processor_interface import ( + SyncTracingProcessor, + AsyncTracingProcessor, +) + +logger = make_logger(__name__) + +# Live per-business-span obs wrapper spans, keyed by the (uuid4) business span id, +# in a MODULE-LEVEL registry -- deliberately NOT on the Trace/AsyncTrace instance. +# TracingService creates a FRESH trace object for every call +# (`self._tracer.trace(trace_id)` in both start_span and end_span), so an +# instance-local dict loses the handle between start and end: end_span's new +# instance can't find it, close_obs_span(None) is a no-op, and the OTel wrapper +# span is never .end()ed -> never exported (Simple/Batch processors only emit on +# end). A module-level dict keyed by the unique span id survives across instances; +# uuid4 span ids cannot collide across concurrent traces. +# +# Bounded (OrderedDict + cap): a correct start_span/end_span pair pops its own +# entry, so the registry normally hovers near the live-span count. The cap only +# bites when a caller starts a span and never ends it -- adk.tracing.start_span / +# end_span are public, unpaired API, so a caller-side bug (crash / early return +# between start and end) would otherwise grow this unbounded in a long-lived ACP +# process. Past the cap we evict+close the OLDEST handle so the leak degrades +# gracefully instead of OOMing (and the evicted span still .end()s -> exports). +_OBS_HANDLES_MAX = 2048 +_OBS_HANDLES: OrderedDict[str, ObsSpanHandle] = OrderedDict() + + +def _register_obs_handle(span_id: str, handle: ObsSpanHandle) -> None: + """Register an open obs wrapper handle, bounding the registry at + ``_OBS_HANDLES_MAX``. When over the cap, evict and close the oldest handle + first. close_obs_span is best-effort (detach may warn since it runs on a + different stack than the attach) and always .end()s the span, so an evicted + span still exports rather than dangling.""" + _OBS_HANDLES[span_id] = handle + _OBS_HANDLES.move_to_end(span_id) + while len(_OBS_HANDLES) > _OBS_HANDLES_MAX: + _evicted_id, evicted = _OBS_HANDLES.popitem(last=False) + logger.warning( + "obs handle registry over cap (%d); evicting+closing oldest span %r. " + "This means a caller started a span without ending it.", + _OBS_HANDLES_MAX, + _evicted_id, + ) + close_obs_span(evicted) + + +def _run_on_span_start(processor: SyncTracingProcessor, span: Span) -> None: + """Invoke ``on_span_start`` such that a processor bug can NEVER crash the app. + + Observability must degrade, not propagate: if this raised, the caller's + start_span would never return, the caller would never end_span, and the obs + handle would leak (dict entry + attached OTel context + unended span). By + swallowing here, start_span returns normally and the standard end_span path + pops and closes the handle -- no leak, no app-path failure.""" + try: + processor.on_span_start(span) + except Exception: + logger.warning( + "on_span_start raised for processor %r; skipping (observability must not fail the app path)", + type(processor).__name__, + exc_info=True, + ) + + +def _run_on_span_end(processor: SyncTracingProcessor, span: Span) -> None: + """Invoke ``on_span_end`` such that a processor bug can NEVER crash the app. + + Symmetric with :func:`_run_on_span_start`. The obs wrapper is already closed + before this runs (see end_span), so this only guards the app path against a + buggy processor -- there is no handle left to leak here.""" + try: + processor.on_span_end(span) + except Exception: + logger.warning( + "on_span_end raised for processor %r; skipping (observability must not fail the app path)", + type(processor).__name__, + exc_info=True, + ) + + +def _in_tracing_dispatch_activity() -> bool: + """True only when running inside the SDK's OWN dispatched START_SPAN / END_SPAN + activity (the ``in_temporal_workflow()`` path, where a workflow runs span start + and end as SEPARATE activities that Temporal can route to different workers). + + That is the one case a per-step obs wrapper can't work: the wrapper opened in + the START_SPAN activity could never be closed by the END_SPAN activity. A span + created directly inside a *business* activity (an agent turn's own + ``adk.tracing.span``) runs start AND end in the same activity process, so a + wrapper there is safe -- it nests under the interceptor's ambient RunActivity + span and closes in-process. The tracing dispatch activities are named by + ``TracingActivityName`` (``start-span`` / ``end-span``). Never raises; False + when temporalio isn't importable or we're not in an activity.""" + try: + from temporalio import activity + + if not activity.in_activity(): + return False + # Import only AFTER the in_activity() guard: the pure-sync ACP path never + # runs this, so it doesn't pull the temporal activities module graph + # (activities -> TracingService -> AsyncTracer -> trace, also circular at + # import time) into a process that never runs a workflow, and a broken + # import can't silently disable the guard on that path. Inside an activity + # the graph is fully loaded, so the lazy import is safe -- and it keeps the + # discriminator keyed on the enum, not on drifting string literals. + # ``activity_type`` round-trips as the enum's str value, which a str-Enum + # member compares equal to. + from agentex.lib.core.temporal.activities.adk.tracing_activities import ( + TracingActivityName, + ) + + return activity.info().activity_type in ( + TracingActivityName.START_SPAN, + TracingActivityName.END_SPAN, + ) + except Exception: + return False + + +def _in_temporal_activity() -> bool: + """True inside ANY Temporal activity. There the ambient span is the temporalio + OTel ``TracingInterceptor`` span REGARDLESS of ``SGP_OBS_MODE``, so callers + prefer OTel for both the wrapper backend and the correlation read: a plain + ``dd_only`` read would target ddtrace, which has no request context in a worker + (no inbound HTTP), so ``open_obs_span`` would return None and the fallback ids + would be empty -- the business span would persist with no obs_* ids at all. + Never raises; False when temporalio isn't importable or we're not in an + activity.""" + try: + from temporalio import activity + + return activity.in_activity() + except Exception: + return False + + +def _begin_obs( + name: str, + span_id: str, + trace_id: str | None, +) -> tuple[ObsSpanHandle | None, dict[str, str]]: + """Open the obs wrapper for a business span and return ``(handle, correlation)``. + + Shared by ``Trace.start_span`` and ``AsyncTrace.start_span`` so the two paths + can't drift. The wrapper is named for the step so ``obs_span_id`` is + stable/meaningful (not an arbitrary innermost httpx span), and it carries the + reverse tag (business span/trace id) for the obs -> business pivot. + + We open a real per-step wrapper on the sync path AND inside a *business* + Temporal activity -- there the wrapper nests under the interceptor's ambient + RunActivity span and start/end run in-process, so it closes cleanly and each + business step gets its own obs span (1:1), just like sync. + + The ONE exception is the SDK's own dispatched START_SPAN / END_SPAN activity + (a workflow calling ``adk.tracing`` -- see ``_in_tracing_dispatch_activity``): + there start and end are separate activities on possibly different workers, so + a wrapper could never be closed. We fall back to tagging the ambient + interceptor span instead, with ``expect_otel=True`` (the interceptor span is + OTel regardless of ``SGP_OBS_MODE``, so a plain ``dd_only`` read would + otherwise point at an unrelated ddtrace span). + + Inside ANY activity we also pass ``expect_otel`` to the wrapper and the ambient + fallback: the ambient span is the interceptor's OTel span regardless of mode, + so a per-step OTel wrapper nests under it and yields valid ids, whereas the + default ``dd_only`` path would open a ddtrace wrapper -- which finds no request + context in a worker and returns None, leaving the business span with empty + obs_* ids. + """ + if _in_tracing_dispatch_activity(): + warn_on_backend_drift(expect_otel=True) + tag_ambient_obs_span(business_span_id=span_id, business_trace_id=trace_id, expect_otel=True) + return None, obs_correlation(expect_otel=True) + # TODO(obs-followup): two items formerly tracked on the (now-deleted) + # _in_temporal_activity docstring, still open after this change: + # (1) TurnTrace RETRY/ASYNC roll-up. A retried business activity now emits a + # full per-step wrapper set PER ATTEMPT, each nested under that attempt's + # RunActivity. Each attempt correlates to the turn on its own, but they + # are not yet rolled up, so a retried turn surfaces as N per-attempt span + # sets rather than one PRIMARY + N RETRY view. + # (2) On a multi-replica worker fleet, assert _OBS_HANDLES stays bounded (no + # leak / OOM) and that obs_trace_id resolves to the turn trace. + expect_otel = _in_temporal_activity() + warn_on_backend_drift(expect_otel) + handle = open_obs_span( + name, business_span_id=span_id, business_trace_id=trace_id, expect_otel=expect_otel + ) + correlation = handle.correlation if handle is not None else obs_correlation(expect_otel=expect_otel) + return handle, correlation + + +class Trace: + """ + Trace is a wrapper around the Agentex API for tracing. + It provides a context manager for spans and a way to start and end spans. + It also provides a way to get spans by ID and list all spans in a trace. + """ + + def __init__( + self, + processors: list[SyncTracingProcessor], + client: Agentex, + trace_id: str | None = None, + ): + """ + Initialize a new trace with the specified trace ID. + + Args: + trace_id: Required trace ID to use for this trace. + processors: Optional list of tracing processors to use for this trace. + """ + self.processors = processors + self.client = client + self.trace_id = trace_id + # Obs wrapper spans are tracked in the module-level _OBS_HANDLES registry + # (see comment there): a fresh trace object is created per start/end call, + # so the handle must not live on the instance. + + def start_span( + self, + name: str, + parent_id: str | None = None, + input: dict[str, Any] | list[dict[str, Any]] | BaseModel | None = None, + data: dict[str, Any] | list[dict[str, Any]] | BaseModel | None = None, + task_id: str | None = None, + ) -> Span: + """ + Start a new span and register it with the API. + + Args: + name: Name of the span. + parent_id: Optional parent span ID. + input: Optional input data for the span. + data: Optional additional data for the span. + task_id: Optional ID of the task this span belongs to. + + Returns: + The newly created span. + """ + + if not self.trace_id: + raise ValueError("Trace ID is required to start a span") + + # Create a span using the client's spans resource + start_time = datetime.now(UTC) + + serialized_input = recursive_model_dump(input) if input else None + serialized_data = recursive_model_dump(data) if data else None + # Open the obs wrapper (or tag the ambient Temporal-activity span); see + # _begin_obs. Business trace_id stays the run-level task id. + id = str(uuid.uuid4()) + obs_handle, obs = _begin_obs(name, id, self.trace_id) + if obs: + serialized_data = {**(serialized_data or {}), **obs} + + span = Span( + id=id, + trace_id=self.trace_id, + name=name, + parent_id=parent_id, + start_time=start_time, + input=serialized_input, + data=serialized_data, + task_id=task_id, + ) + if obs_handle is not None: + _register_obs_handle(span.id, obs_handle) + + for processor in self.processors: + _run_on_span_start(processor, span) + + return span + + def end_span( + self, + span: Span, + ) -> Span: + """ + End a span by updating it with any changes made to the span object. + + Args: + span: The span object to update. + + Returns: + The updated span. + """ + if span.end_time is None: + span.end_time = datetime.now(UTC) + + # Close the dedicated obs wrapper span; propagate the business-span error + # (if any) so the obs span reflects failure, not a false green. + close_obs_span(_OBS_HANDLES.pop(span.id, None), error=get_span_error(span)) + + span.input = recursive_model_dump(span.input) if span.input else None + span.output = recursive_model_dump(span.output) if span.output else None + span.data = recursive_model_dump(span.data) if span.data else None + + for processor in self.processors: + _run_on_span_end(processor, span) + + return span + + def get_span(self, span_id: str) -> Span: + """ + Get a span by ID. + + Args: + span_id: The ID of the span to get. + + Returns: + The requested span. + """ + # Query from Agentex API + span = self.client.spans.retrieve(span_id) + return span + + def list_spans(self) -> list[Span]: + """ + List all spans in this trace. + + Returns: + List of spans in this trace. + """ + # Query from Agentex API + spans = self.client.spans.list(trace_id=self.trace_id) + return spans + + @contextmanager + def span( + self, + name: str, + parent_id: str | None = None, + input: dict[str, Any] | list[dict[str, Any]] | BaseModel | None = None, + data: dict[str, Any] | list[dict[str, Any]] | BaseModel | None = None, + task_id: str | None = None, + ): + """ + Context manager for spans. + If trace_id is falsy, acts as a no-op context manager. + """ + if not self.trace_id: + yield None + return + span = self.start_span(name, parent_id, input, data, task_id=task_id) + try: + yield span + except Exception as exc: + set_span_error(span, exc) + raise + finally: + self.end_span(span) + + +class AsyncTrace: + """ + AsyncTrace is a wrapper around the Agentex API for tracing. + It provides a context manager for spans and a way to start and end spans. + It also provides a way to get spans by ID and list all spans in a trace. + """ + + def __init__( + self, + processors: list[AsyncTracingProcessor], + client: AsyncAgentex, + trace_id: str | None = None, + span_queue: AsyncSpanQueue | None = None, + ): + """ + Initialize a new trace with the specified trace ID. + + Args: + trace_id: Required trace ID to use for this trace. + processors: Optional list of tracing processors to use for this trace. + span_queue: Optional span queue for background processing. + """ + self.processors = processors + self.client = client + self.trace_id = trace_id + self._span_queue = span_queue or get_default_span_queue() + # Obs wrapper spans are tracked in the module-level _OBS_HANDLES registry + # (see comment there): a fresh trace object is created per start/end call, + # so the handle must not live on the instance. + + async def start_span( + self, + name: str, + parent_id: str | None = None, + input: dict[str, Any] | list[dict[str, Any]] | BaseModel | None = None, + data: dict[str, Any] | list[dict[str, Any]] | BaseModel | None = None, + task_id: str | None = None, + ) -> Span: + """ + Start a new span and register it with the API. + + Args: + name: Name of the span. + parent_id: Optional parent span ID. + input: Optional input data for the span. + data: Optional additional data for the span. + task_id: Optional ID of the task this span belongs to. + + Returns: + The newly created span. + """ + if not self.trace_id: + raise ValueError("Trace ID is required to start a span") + + # Create a span using the client's spans resource + start_time = datetime.now(UTC) + + serialized_input = recursive_model_dump(input) if input else None + serialized_data = recursive_model_dump(data) if data else None + # Open the obs wrapper (or tag the ambient Temporal-activity span); see + # _begin_obs. Business trace_id stays the run-level task id. + id = str(uuid.uuid4()) + obs_handle, obs = _begin_obs(name, id, self.trace_id) + if obs: + serialized_data = {**(serialized_data or {}), **obs} + + span = Span( + id=id, + trace_id=self.trace_id, + name=name, + parent_id=parent_id, + start_time=start_time, + input=serialized_input, + data=serialized_data, + task_id=task_id, + ) + if obs_handle is not None: + _register_obs_handle(span.id, obs_handle) + + # Enqueueing the START event must not crash the app path either (same + # principle as _run_on_span_start): swallow so start_span still returns + # and end_span cleans up the handle. The processors' on_span_start runs + # later on the queue worker, off the request path. + if self.processors: + try: + self._span_queue.enqueue(SpanEventType.START, span.model_copy(deep=True), self.processors) + except Exception: + logger.warning( + "failed to enqueue START span event; skipping (observability must not fail the app path)", + exc_info=True, + ) + + return span + + async def end_span( + self, + span: Span, + ) -> Span: + """ + End a span by updating it with any changes made to the span object. + + Args: + span: The span object to update. + + Returns: + The updated span. + """ + if span.end_time is None: + span.end_time = datetime.now(UTC) + + # Close the dedicated obs wrapper span; propagate the business-span error + # (if any) so the obs span reflects failure, not a false green. + close_obs_span(_OBS_HANDLES.pop(span.id, None), error=get_span_error(span)) + + span.input = recursive_model_dump(span.input) if span.input else None + span.output = recursive_model_dump(span.output) if span.output else None + span.data = recursive_model_dump(span.data) if span.data else None + + if self.processors: + try: + self._span_queue.enqueue(SpanEventType.END, span.model_copy(deep=True), self.processors) + except Exception: + logger.warning( + "failed to enqueue END span event; skipping (observability must not fail the app path)", + exc_info=True, + ) + + return span + + async def get_span(self, span_id: str) -> Span: + """ + Get a span by ID. + + Args: + span_id: The ID of the span to get. + + Returns: + The requested span. + """ + # Query from Agentex API + span = await self.client.spans.retrieve(span_id) + return span + + async def list_spans(self) -> list[Span]: + """ + List all spans in this trace. + + Returns: + List of spans in this trace. + """ + # Query from Agentex API + spans = await self.client.spans.list(trace_id=self.trace_id) + return spans + + @asynccontextmanager + async def span( + self, + name: str, + parent_id: str | None = None, + input: dict[str, Any] | list[dict[str, Any]] | BaseModel | None = None, + data: dict[str, Any] | list[dict[str, Any]] | BaseModel | None = None, + task_id: str | None = None, + ) -> AsyncGenerator[Span | None, None]: + """ + Context manager for spans. + + Args: + name: Name of the span. + parent_id: Optional parent span ID. + input: Optional input data for the span. + data: Optional additional data for the span. + task_id: Optional ID of the task this span belongs to. + + Yields: + The span object. + """ + if not self.trace_id: + yield None + return + span = await self.start_span(name, parent_id, input, data, task_id=task_id) + try: + yield span + except Exception as exc: + set_span_error(span, exc) + raise + finally: + await self.end_span(span) diff --git a/src/agentex/lib/core/tracing/tracer.py b/src/agentex/lib/core/tracing/tracer.py new file mode 100644 index 000000000..3af79977e --- /dev/null +++ b/src/agentex/lib/core/tracing/tracer.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from agentex import Agentex, AsyncAgentex +from agentex.lib.core.tracing.trace import Trace, AsyncTrace +from agentex.lib.core.tracing.span_queue import AsyncSpanQueue +from agentex.lib.core.tracing.tracing_processor_manager import ( + get_sync_tracing_processors, + get_async_tracing_processors, +) + + +class Tracer: + """ + Tracer is the main entry point for tracing in Agentex. + It manages the client connection and creates traces. + """ + + def __init__(self, client: Agentex): + """ + Initialize a new sync tracer with the provided client. + + Args: + client: Agentex client instance used for API communication. + """ + self.client = client + + def trace(self, trace_id: str | None = None) -> Trace: + """ + Create a new trace with the given trace ID. + + Args: + trace_id: The trace ID to use. + + Returns: + A new Trace instance. + """ + return Trace( + processors=get_sync_tracing_processors(), + client=self.client, + trace_id=trace_id, + ) + + +class AsyncTracer: + """ + AsyncTracer is the async version of Tracer. + It manages the async client connection and creates async traces. + """ + + def __init__(self, client: AsyncAgentex): + """ + Initialize a new async tracer with the provided client. + + Args: + client: AsyncAgentex client instance used for API communication. + """ + self.client = client + + def trace(self, trace_id: str | None = None, span_queue: AsyncSpanQueue | None = None) -> AsyncTrace: + """ + Create a new trace with the given trace ID. + + Args: + trace_id: The trace ID to use. + span_queue: Optional span queue for background processing. + + Returns: + A new AsyncTrace instance. + """ + return AsyncTrace( + processors=get_async_tracing_processors(), + client=self.client, + trace_id=trace_id, + span_queue=span_queue, + ) diff --git a/src/agentex/lib/core/tracing/tracing_processor_manager.py b/src/agentex/lib/core/tracing/tracing_processor_manager.py new file mode 100644 index 000000000..07c440313 --- /dev/null +++ b/src/agentex/lib/core/tracing/tracing_processor_manager.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from threading import Lock + +from agentex.lib.types.tracing import TracingProcessorConfig +from agentex.lib.core.tracing.processors.sgp_tracing_processor import ( + SGPSyncTracingProcessor, + SGPAsyncTracingProcessor, +) +from agentex.lib.core.tracing.processors.tracing_processor_interface import ( + SyncTracingProcessor, + AsyncTracingProcessor, +) + +if TYPE_CHECKING: + from agentex.lib.core.tracing.processors.agentex_tracing_processor import ( # noqa: F401 + AgentexSyncTracingProcessor, + AgentexAsyncTracingProcessor, + ) + + +class TracingProcessorManager: + def __init__(self): + # Mapping of processor config type to processor class + # Use lazy loading for agentex processors to avoid circular imports + self.sync_config_registry: dict[str, type[SyncTracingProcessor]] = { + "sgp": SGPSyncTracingProcessor, + } + self.async_config_registry: dict[str, type[AsyncTracingProcessor]] = { + "sgp": SGPAsyncTracingProcessor, + } + # Cache for processors + self.sync_processors: list[SyncTracingProcessor] = [] + self.async_processors: list[AsyncTracingProcessor] = [] + self.lock = Lock() + self._agentex_registered = False + + def _ensure_agentex_registered(self): + """Lazily register agentex processors to avoid circular imports.""" + if not self._agentex_registered: + from agentex.lib.core.tracing.processors.agentex_tracing_processor import ( + AgentexSyncTracingProcessor, + AgentexAsyncTracingProcessor, + ) + self.sync_config_registry["agentex"] = AgentexSyncTracingProcessor + self.async_config_registry["agentex"] = AgentexAsyncTracingProcessor + self._agentex_registered = True + + def add_processor_config(self, processor_config: TracingProcessorConfig) -> None: + with self.lock: + self._ensure_agentex_registered() + sync_processor = self.sync_config_registry[processor_config.type] + async_processor = self.async_config_registry[processor_config.type] + self.sync_processors.append(sync_processor(processor_config)) + self.async_processors.append(async_processor(processor_config)) + + def set_processor_configs(self, processor_configs: list[TracingProcessorConfig]): + with self.lock: + for processor_config in processor_configs: + self.add_processor_config(processor_config) + + def get_sync_processors(self) -> list[SyncTracingProcessor]: + return self.sync_processors + + def get_async_processors(self) -> list[AsyncTracingProcessor]: + return self.async_processors + + +# Global instance +GLOBAL_TRACING_PROCESSOR_MANAGER = TracingProcessorManager() + +add_tracing_processor_config = GLOBAL_TRACING_PROCESSOR_MANAGER.add_processor_config +set_tracing_processor_configs = GLOBAL_TRACING_PROCESSOR_MANAGER.set_processor_configs + +def get_sync_tracing_processors(): + return GLOBAL_TRACING_PROCESSOR_MANAGER.get_sync_processors() + +def get_async_tracing_processors(): + return GLOBAL_TRACING_PROCESSOR_MANAGER.get_async_processors() diff --git a/src/agentex/lib/environment_variables.py b/src/agentex/lib/environment_variables.py new file mode 100644 index 000000000..00dbbaada --- /dev/null +++ b/src/agentex/lib/environment_variables.py @@ -0,0 +1,134 @@ + +from __future__ import annotations + +import os +from enum import Enum +from pathlib import Path + +from dotenv import load_dotenv + +from agentex.lib.utils.logging import make_logger +from agentex.lib.utils.model_utils import BaseModel + +PROJECT_ROOT = Path(__file__).resolve().parents[2] + +logger = make_logger(__name__) + + +class EnvVarKeys(str, Enum): + ENVIRONMENT = "ENVIRONMENT" + TEMPORAL_ADDRESS = "TEMPORAL_ADDRESS" + REDIS_URL = "REDIS_URL" + AGENTEX_BASE_URL = "AGENTEX_BASE_URL" + # Agent Identifiers + AGENT_NAME = "AGENT_NAME" + AGENT_DESCRIPTION = "AGENT_DESCRIPTION" + AGENT_ID = "AGENT_ID" + AGENT_VERSION = "AGENT_VERSION" + AGENT_COMMIT_SHA = "AGENT_COMMIT_SHA" + AGENT_API_KEY = "AGENT_API_KEY" + # ACP Configuration + ACP_URL = "ACP_URL" + ACP_PORT = "ACP_PORT" + ACP_TYPE = "ACP_TYPE" + # Workflow Configuration + WORKFLOW_NAME = "WORKFLOW_NAME" + WORKFLOW_TASK_QUEUE = "WORKFLOW_TASK_QUEUE" + WORKFLOW_EXECUTION_TIMEOUT_SECONDS = "WORKFLOW_EXECUTION_TIMEOUT_SECONDS" + # Temporal Worker Configuration + HEALTH_CHECK_PORT = "HEALTH_CHECK_PORT" + # Auth Configuration + AUTH_PRINCIPAL_B64 = "AUTH_PRINCIPAL_B64" + AGENT_INPUT_TYPE = "AGENT_INPUT_TYPE" + # Deployment + AGENTEX_DEPLOYMENT_ID = "AGENTEX_DEPLOYMENT_ID" + # Claude Agents SDK Configuration + ANTHROPIC_API_KEY = "ANTHROPIC_API_KEY" + CLAUDE_WORKSPACE_ROOT = "CLAUDE_WORKSPACE_ROOT" + + +class Environment(str, Enum): + LOCAL = "local" + DEV = "development" + STAGING = "staging" + PROD = "production" + + +refreshed_environment_variables: EnvironmentVariables | None = None + + +class EnvironmentVariables(BaseModel): + ENVIRONMENT: str = Environment.DEV + TEMPORAL_ADDRESS: str | None = "localhost:7233" + REDIS_URL: str | None = None + AGENTEX_BASE_URL: str | None = "http://localhost:5003" + # Agent Identifiers + AGENT_NAME: str + AGENT_DESCRIPTION: str | None = None + AGENT_ID: str | None = None + # Build/version discriminator (image tag or git sha), set by the deployment + AGENT_VERSION: str | None = None + # The agent's source commit, baked into the image or set by the deployment. + # Unlike AGENT_VERSION this is expected to be a git SHA and nothing else, and + # it is OPT-IN: nothing is stamped unless the agent calls + # `adk.code_revision.enable()`, which also refuses a value that is not a git + # object name. See agentex.lib.core.tracing.code_revision. + AGENT_COMMIT_SHA: str | None = None + AGENT_API_KEY: str | None = None + ACP_TYPE: str | None = "async" + AGENT_INPUT_TYPE: str | None = None + # ACP Configuration + ACP_URL: str + ACP_PORT: int = 8000 + # Workflow Configuration + WORKFLOW_TASK_QUEUE: str | None = None + WORKFLOW_NAME: str | None = None + # Maximum total wall-clock time (in seconds) a workflow execution can run, + # INCLUDING retries and the entire continue-as-new chain (Temporal does not + # reset it on continue-as-new). Defaults to None = no execution timeout, so + # long-lived chat/session workflows can stay open indefinitely. None / 0 / + # negative are all treated as "no timeout" at the start_workflow call site. + # To bound idle workflows, use an explicit durable timer inside the workflow + # (e.g. run_until_complete's `timeout`), not this chain-wide ceiling. + WORKFLOW_EXECUTION_TIMEOUT_SECONDS: int | None = None + # Temporal Worker Configuration + HEALTH_CHECK_PORT: int = 80 + # Auth Configuration + AUTH_PRINCIPAL_B64: str | None = None + # Deployment + AGENTEX_DEPLOYMENT_ID: str | None = None + # Claude Agents SDK Configuration + ANTHROPIC_API_KEY: str | None = None + CLAUDE_WORKSPACE_ROOT: str | None = None # Defaults to project/workspace if not set + + @classmethod + def refresh(cls) -> EnvironmentVariables: + global refreshed_environment_variables + if refreshed_environment_variables is not None: + return refreshed_environment_variables + + logger.info("Refreshing environment variables") + if os.environ.get(EnvVarKeys.ENVIRONMENT) == Environment.DEV: + # Load global .env file first + global_env_path = PROJECT_ROOT / ".env" + if global_env_path.exists(): + logger.debug(f"Loading global environment variables FROM: {global_env_path}") + load_dotenv(dotenv_path=global_env_path, override=False) + + # Load local project .env.local file (takes precedence) + local_env_path = Path.cwd().parent / ".env.local" + if local_env_path.exists(): + logger.debug(f"Loading local environment variables FROM: {local_env_path}") + load_dotenv(dotenv_path=local_env_path, override=True) + + # Create kwargs dict with environment variables, using None for missing values + # Pydantic will use the default values when None is passed for optional fields + kwargs = {} + for key in EnvVarKeys: + env_value = os.environ.get(key.value) + if env_value is not None: + kwargs[key.value] = env_value + + environment_variables = EnvironmentVariables(**kwargs) + refreshed_environment_variables = environment_variables + return refreshed_environment_variables diff --git a/src/agentex/lib/py.typed b/src/agentex/lib/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/sdk/__init__.py b/src/agentex/lib/sdk/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/sdk/config/__init__.py b/src/agentex/lib/sdk/config/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/sdk/config/agent_config.py b/src/agentex/lib/sdk/config/agent_config.py new file mode 100644 index 000000000..c259375a4 --- /dev/null +++ b/src/agentex/lib/sdk/config/agent_config.py @@ -0,0 +1,7 @@ +"""Back-compat shim. The canonical location is :mod:`agentex.config.agent_config`. + +Kept here so existing ``from agentex.lib.sdk.config.agent_config import ...`` +imports continue to work. New code should import from the canonical path. +""" + +from agentex.config.agent_config import AgentConfig # noqa: F401 diff --git a/src/agentex/lib/sdk/config/agent_manifest.py b/src/agentex/lib/sdk/config/agent_manifest.py new file mode 100644 index 000000000..c2fe03052 --- /dev/null +++ b/src/agentex/lib/sdk/config/agent_manifest.py @@ -0,0 +1,216 @@ +"""Back-compat shim, manifest loader, and Docker build-context machinery. + +The :class:`AgentManifest` model's canonical location is +:mod:`agentex.config.agent_manifest`; it is re-exported here so existing +``from agentex.lib.sdk.config.agent_manifest import AgentManifest`` imports keep +working. The yaml loader (`load_agent_manifest`) and build machinery +(`build_context_manager`, `BuildContextManager`) stay here (CLI/build-side) so +the promoted model remains slim-safe. +""" + +from __future__ import annotations + +import io +import time +import shutil +import tarfile +import tempfile +import subprocess +from typing import IO, Any +from pathlib import Path +from contextlib import contextmanager +from collections.abc import Iterator + +from agentex.lib.utils.io import load_yaml_file +from agentex.lib.utils.logging import make_logger +from agentex.config.agent_manifest import AgentManifest # noqa: F401 +from agentex.lib.utils.build_provenance import iter_context_files + +logger = make_logger(__name__) + + +def load_agent_manifest(file_path: str) -> AgentManifest: + """Load and validate a manifest.yaml file into an AgentManifest.""" + return AgentManifest.model_validate(load_yaml_file(file_path=file_path)) + + +def build_context_manager(agent_manifest: AgentManifest, build_context_root: Path) -> BuildContextManager: + """Create a build context manager for the given manifest.""" + return BuildContextManager(agent_manifest=agent_manifest, build_context_root=build_context_root) + + +class BuildContextManager: + """ + A gateway used to manage the build context for a docker image + """ + + def __init__(self, agent_manifest: AgentManifest, build_context_root: Path): + self.agent_manifest = agent_manifest + self.build_context_root = build_context_root + self._temp_dir: tempfile.TemporaryDirectory | None = None + + self.path: Path | None = None + self.dockerfile_path = "Dockerfile" + self.dockerignore_path = ".dockerignore" + self.directory_paths: list[Path] = [] + + def __enter__(self) -> BuildContextManager: + self._temp_dir = tempfile.TemporaryDirectory() + self.path = Path(self._temp_dir.name) + + dockerfile_path = ( + self.build_context_root / self.agent_manifest.build.context.dockerfile + ) + self.add_dockerfile(root_path=self.path, dockerfile_path=dockerfile_path) + + ignore_patterns = [] + if self.agent_manifest.build.context.dockerignore: + dockerignore_path = ( + self.build_context_root / self.agent_manifest.build.context.dockerignore + ) + if dockerignore_path.exists(): + self.add_dockerignore( + root_path=self.path, dockerignore_path=dockerignore_path + ) + ignore_patterns = _extract_dockerignore_patterns(dockerignore_path) + else: + logger.warning( + f"Dockerignore file not found at {dockerignore_path}, skipping." + ) + + for directory in self.agent_manifest.build.context.include_paths: + directory_path = self.build_context_root / directory + self.add_directory( + root_path=self.path, + directory_path=directory_path, + context_root=self.build_context_root, + ignore_patterns=ignore_patterns, + ) + + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + if self._temp_dir: + self._temp_dir.cleanup() + + def add_dockerfile(self, root_path: Path, dockerfile_path: Path) -> None: + """ + Copies a dockerfile to the temporary context directory root + """ + shutil.copy2(dockerfile_path, root_path / self.dockerfile_path) + + def add_dockerignore(self, root_path: Path, dockerignore_path: Path) -> None: + """ + Copies a dockerignore to the temporary context directory root + """ + shutil.copy2(str(dockerignore_path), root_path / self.dockerignore_path) + + def add_directory( + self, + root_path: Path, + directory_path: Path, + context_root: Path, + ignore_patterns: list[str] | None = None, + ) -> None: + """ + Copies a directory to the temporary context directory root while maintaining its relative + path to the context root. + """ + directory_copy_start_time = time.time() + last_log_time = directory_copy_start_time + + def copy_function_with_progress(src, dst): + nonlocal directory_copy_start_time + nonlocal last_log_time + logger.info(f"Adding {src} to build context...") + shutil.copy2(src, dst) + current_time = time.time() + time_elapsed = current_time - directory_copy_start_time + + if time_elapsed > 1 and current_time - last_log_time >= 1: + logger.info( + f"Time elapsed copying ({directory_path}): {time_elapsed} " + f"seconds" + ) + last_log_time = current_time + if time_elapsed > 5: + logger.warning( + f"This may take a while... " + f"Consider adding {directory_path} or {src} to your .dockerignore file." + ) + + directory_path_relative_to_root = directory_path.relative_to(context_root) + all_ignore_patterns = [f"{root_path}*"] + if ignore_patterns: + all_ignore_patterns += ignore_patterns + shutil.copytree( + src=directory_path, + dst=root_path / directory_path_relative_to_root, + ignore=shutil.ignore_patterns(*all_ignore_patterns), + dirs_exist_ok=True, + copy_function=copy_function_with_progress, + ) + self.directory_paths.append(directory_path_relative_to_root) + + @contextmanager + def zip_stream(self, root_path: Path | None = None) -> Iterator[IO[bytes]]: + """ + Creates a tar archive of the temporary context directory + and returns a stream of the archive. + """ + if not root_path: + raise ValueError("root_path must be provided") + context = str(root_path.absolute()) + folders_to_include = "." + tar_command = ["tar", "-C", context, "-cf", "-"] + tar_command.extend(folders_to_include) + + logger.info(f"Creating archive: {' '.join(tar_command)}") + + with subprocess.Popen( + tar_command, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) as proc: + assert proc.stdout is not None + try: + yield proc.stdout + finally: + pass + + @staticmethod + @contextmanager + def zipped(root_path: Path | None = None) -> Iterator[IO[bytes]]: + """ + Creates a tar.gz archive of the temporary context directory + and returns a stream of the archive. + """ + if not root_path: + raise ValueError("root_path must be provided") + + tar_buffer = io.BytesIO() + + # Sorted, relpath-stable enumeration (shared with the content hash) so the + # archive's member order is deterministic across machines. + with tarfile.open(fileobj=tar_buffer, mode="w:gz") as tar_file: + for path in iter_context_files(Path(root_path)): + tar_file.add(path, arcname=path.relative_to(root_path)) + + tar_buffer.seek(0) # Reset the buffer position to the beginning + yield tar_buffer + + +def _extract_dockerignore_patterns(dockerignore_path: Path) -> list[str]: + """ + Extracts glob patterns to ignore from the dockerignore into a list of patterns + :param dockerignore_path: Path to the dockerignore to extract patterns from + :return: List of glob patterns to ignore + :rtype: List[str] + """ + ignore_patterns = [] + with open(dockerignore_path) as file: + for line in file: + ignored_filepath = line.split("#", 1)[0].strip() + if ignored_filepath: + ignore_patterns.append(ignored_filepath) + return ignore_patterns diff --git a/src/agentex/lib/sdk/config/build_config.py b/src/agentex/lib/sdk/config/build_config.py new file mode 100644 index 000000000..eb7c863f1 --- /dev/null +++ b/src/agentex/lib/sdk/config/build_config.py @@ -0,0 +1,10 @@ +"""Back-compat shim. The canonical location is :mod:`agentex.config.build_config`. + +Kept here so existing ``from agentex.lib.sdk.config.build_config import ...`` +imports continue to work. New code should import from the canonical path. +""" + +from agentex.config.build_config import ( # noqa: F401 + BuildConfig, + BuildContext, +) diff --git a/src/agentex/lib/sdk/config/deployment_config.py b/src/agentex/lib/sdk/config/deployment_config.py new file mode 100644 index 000000000..6ed7ebed7 --- /dev/null +++ b/src/agentex/lib/sdk/config/deployment_config.py @@ -0,0 +1,18 @@ +"""Back-compat shim. The canonical location is :mod:`agentex.config.deployment_config`. + +Kept here so existing ``from agentex.lib.sdk.config.deployment_config import ...`` +imports continue to work. New code should import from the canonical path. +""" + +from agentex.config.deployment_config import ( # noqa: F401 + ImageConfig, + ClusterConfig, + ResourceConfig, + DeploymentConfig, + AuthenticationConfig, + ResourceRequirements, + ImagePullSecretConfig, + InjectedSecretsValues, + GlobalDeploymentConfig, + InjectedImagePullSecretValues, +) diff --git a/src/agentex/lib/sdk/config/environment_config.py b/src/agentex/lib/sdk/config/environment_config.py new file mode 100644 index 000000000..3800ff518 --- /dev/null +++ b/src/agentex/lib/sdk/config/environment_config.py @@ -0,0 +1,73 @@ +"""Back-compat shim and yaml loaders for environment configuration. + +The model classes' canonical location is +:mod:`agentex.config.environment_config`; they are re-exported here so existing +``from agentex.lib.sdk.config.environment_config import ...`` imports keep +working. The yaml-loading helpers stay here (CLI/build-side) so the promoted +models remain slim-safe. +""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +from agentex.config.environment_config import ( # noqa: F401 + AgentAuthConfig, + OciRegistryConfig, + AgentKubernetesConfig, + AgentEnvironmentConfig, + AgentEnvironmentsConfig, +) + + +def load_environments_config(file_path: str) -> AgentEnvironmentsConfig: + """Load and validate an environments.yaml into an AgentEnvironmentsConfig. + + Args: + file_path: Path to environments.yaml file + + Returns: + Parsed and validated AgentEnvironmentsConfig + + Raises: + FileNotFoundError: If file doesn't exist + ValueError: If file is invalid or doesn't validate + """ + path = Path(file_path) + if not path.exists(): + raise FileNotFoundError(f"environments.yaml not found: {file_path}") + + try: + with open(path, "r") as f: + data = yaml.safe_load(f) + + if not data: + raise ValueError("environments.yaml file is empty") + + return AgentEnvironmentsConfig.model_validate(data) + + except yaml.YAMLError as e: + raise ValueError(f"Invalid YAML format in {file_path}: {e}") from e + except Exception as e: + raise ValueError(f"Failed to load environments.yaml from {file_path}: {e}") from e + + +def load_environments_config_from_manifest_dir(manifest_dir: Path) -> AgentEnvironmentsConfig | None: + """Helper function to load environments.yaml from same directory as manifest.yaml. + + Args: + manifest_dir: Directory containing manifest.yaml + + Returns: + AgentEnvironmentsConfig if environments.yaml exists, None otherwise + + Raises: + ValueError: If environments.yaml exists but is invalid + """ + environments_file = manifest_dir / "environments.yaml" + if not environments_file.exists(): + return None + + return load_environments_config(str(environments_file)) diff --git a/src/agentex/lib/sdk/config/local_development_config.py b/src/agentex/lib/sdk/config/local_development_config.py new file mode 100644 index 000000000..ce60c41c9 --- /dev/null +++ b/src/agentex/lib/sdk/config/local_development_config.py @@ -0,0 +1,11 @@ +"""Back-compat shim. The canonical location is :mod:`agentex.config.local_development_config`. + +Kept here so existing ``from agentex.lib.sdk.config.local_development_config +import ...`` imports continue to work. New code should import from the canonical path. +""" + +from agentex.config.local_development_config import ( # noqa: F401 + LocalAgentConfig, + LocalPathsConfig, + LocalDevelopmentConfig, +) diff --git a/src/agentex/lib/sdk/config/project_config.py b/src/agentex/lib/sdk/config/project_config.py new file mode 100644 index 000000000..0621ae37a --- /dev/null +++ b/src/agentex/lib/sdk/config/project_config.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import os +import re +from typing import Any, TypeVar +from pathlib import Path + +import yaml +from jinja2 import BaseLoader, Environment, TemplateError, StrictUndefined + +T = TypeVar("T") + + +class ConfigResolutionError(Exception): + def __init__(self, message: str) -> None: + super().__init__(message) + self.status_code = 400 + + +def _preprocess_template(template_str: str) -> str: + # Replace $env. and $variables. with unique internal names + return template_str.replace("{{ $env.", "{{ __special_env__.").replace( + "{{ $variables.", "{{ __special_variables__." + ) + + +def _extract_variables_section(raw_config_str: str) -> str: + # Use regex to extract the variables: ... block (YAML top-level) + match = re.search( + r"(^variables:.*?)(^config:|\Z)", raw_config_str, re.DOTALL | re.MULTILINE + ) + if not match: + return "" + return match.group(1) + + +def ProjectConfigLoader( + config_path: str, model: type[T] | None = None, env_path: str | None = None +) -> dict[str, Any] | T: + config_path_obj = Path(config_path) + env_path_obj = Path(env_path) if env_path else config_path_obj.parent / ".env" + env = _load_env(env_path_obj) + raw_config_str = _load_file_as_str(config_path_obj) + raw_config_str = _preprocess_template(raw_config_str) + + # Extract and render only the variables section + variables_section_str = _extract_variables_section(raw_config_str) + env_context = {"__special_env__": env, "__special_variables__": {}} + try: + env_only_template = Environment( + loader=BaseLoader(), + undefined=StrictUndefined, + keep_trailing_newline=True, + autoescape=False, + ).from_string(variables_section_str) + rendered_variables_yaml = env_only_template.render(**env_context) + variables_dict = yaml.safe_load(rendered_variables_yaml).get("variables", {}) + except Exception as e: + raise ConfigResolutionError(f"Error rendering variables with $env: {e}") from e + # Second pass: render the whole config with both __special_env__ and resolved __special_variables__ + full_context = {"__special_env__": env, "__special_variables__": variables_dict} + rendered_config_str = _jinja_render(raw_config_str, full_context) + try: + rendered_config = yaml.safe_load(rendered_config_str) + except Exception as e: + raise ConfigResolutionError(f"Error loading rendered YAML: {e}") from e + if "config" not in rendered_config: + raise ConfigResolutionError("Missing 'config' section in config file.") + config_section = rendered_config["config"] + if model is not None: + return model(**config_section) + return config_section + + +def _load_env(env_path: Path) -> dict[str, str]: + env = dict(os.environ) + if env_path.exists(): + with open(env_path) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + k, v = line.split("=", 1) + env[k.strip()] = v.strip() + return env + + +def _load_file_as_str(path: Path) -> str: + with open(path) as f: + return f.read() + + +def _jinja_render(template_str: str, context: dict) -> str: + try: + env = Environment( + loader=BaseLoader(), + undefined=StrictUndefined, + keep_trailing_newline=True, + autoescape=False, + ) + template = env.from_string(template_str) + return template.render(**context) + except TemplateError as e: + raise ConfigResolutionError(f"Jinja template error: {e}") from e diff --git a/src/agentex/lib/sdk/config/validation.py b/src/agentex/lib/sdk/config/validation.py new file mode 100644 index 000000000..12ecc1b4f --- /dev/null +++ b/src/agentex/lib/sdk/config/validation.py @@ -0,0 +1,256 @@ +""" +Validation framework for agent configuration files. + +This module provides validation functions for agent configurations, +with clear error messages and best practices enforcement. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional +from pathlib import Path + +from agentex.lib.utils.logging import make_logger +from agentex.config.environment_config import AgentEnvironmentConfig, AgentEnvironmentsConfig +from agentex.lib.sdk.config.environment_config import load_environments_config + +logger = make_logger(__name__) + + +class ConfigValidationError(Exception): + """Exception raised when configuration validation fails.""" + + def __init__(self, message: str, file_path: Optional[str] = None): + self.file_path = file_path + super().__init__(message) + + +class EnvironmentsValidationError(ConfigValidationError): + """Exception raised when environments.yaml validation fails.""" + + pass + + +def validate_environments_config( + environments_config: AgentEnvironmentsConfig, required_environments: Optional[List[str]] = None +) -> None: + """ + Validate environments configuration with comprehensive checks. + + Args: + environments_config: The loaded environments configuration + required_environments: List of environment names that must be present + + Raises: + EnvironmentsValidationError: If validation fails + """ + # Check for required environments + if required_environments: + # this must exist as a top-level key or via the environment indicator + missing_envs: List[str] = [] + environment_mappings = [env.environment for env in environments_config.environments.values() if env.environment] + top_level_envs = [env for env in environments_config.environments] + all_envs = set(environment_mappings + top_level_envs) + for env_name in required_environments: + if env_name not in all_envs: + missing_envs.append(env_name) + + if missing_envs: + raise EnvironmentsValidationError( + f"Missing required environments: {', '.join(missing_envs)}. " + f"Available environments: {', '.join(all_envs)}" + ) + + # if environment mappings are set, you cannot have a top-level env_name that maps to an `environment: value` + # and another environment that has the mapping i.e. + # enviorments: + # dev: + # .... + # dev1: + # environment: dev + # this is invalid because its unclear if "dev" refers to just that top-level environment or the mapping + # + # Validate each environment configuration + for env_name, env_config in environments_config.environments.items(): + try: + _validate_single_environment_config(env_name, env_config) + except Exception as e: + raise EnvironmentsValidationError(f"Environment '{env_name}' configuration error: {str(e)}") from e + + +def _validate_single_environment_config(env_name: str, env_config: AgentEnvironmentConfig) -> None: + """ + Validate a single environment configuration. + + Args: + env_name: Name of the environment + env_config: AgentEnvironmentConfig instance + + Raises: + ValueError: If validation fails + """ + # Validate namespace naming conventions if kubernetes config exists + if env_config.kubernetes and env_config.kubernetes.namespace: + namespace = env_config.kubernetes.namespace + + # Check for common namespace naming issues + if namespace != namespace.lower(): + logger.warning( + f"Namespace '{namespace}' contains uppercase letters. Kubernetes namespaces should be lowercase." + ) + + if namespace.startswith("-") or namespace.endswith("-"): + raise ValueError(f"Namespace '{namespace}' cannot start or end with hyphens") + + # Validate auth principal + principal = env_config.auth.principal + user_id = principal.get("user_id") + service_account_id = principal.get("service_account_id") + if not user_id and not service_account_id: + raise ValueError("Auth principal must contain non-empty 'user_id' or 'service_account_id'") + if user_id and service_account_id: + raise ValueError("Auth principal must contain only one of 'user_id' or 'service_account_id', not both") + + # Check for environment-specific user_id patterns + if isinstance(user_id, str): + if not any(env_name.lower() in user_id.lower() for env_name in ["dev", "prod", "staging", env_name]): + logger.warning( + f"User ID '{user_id}' doesn't contain environment indicator. " + f"Consider including '{env_name}' in the user_id for clarity." + ) + + # Validate helm overrides if present + if env_config.helm_overrides: + _validate_helm_overrides(env_config.helm_overrides) + + +def _validate_helm_overrides(helm_overrides: Dict[str, Any]) -> None: + """ + Validate helm override configuration. + + Args: + helm_overrides: Dictionary of helm overrides + + Raises: + ValueError: If validation fails + """ + # Check for common helm override issues + if "resources" in helm_overrides: + resources = helm_overrides["resources"] + if isinstance(resources, dict): + # Validate resource format + if "requests" in resources or "limits" in resources: + for resource_type in ["requests", "limits"]: + if resource_type in resources: + resource_config: Any = resources[resource_type] + if isinstance(resource_config, dict): + # Check for valid resource specifications + for key, value in resource_config.items(): + if key in ["cpu", "memory"] and not isinstance(value, str): + logger.warning( + f"Resource {key} should be a string (e.g., '500m', '1Gi'), " + f"got {type(value).__name__}: {value}" + ) + + +def validate_environments_yaml_file(file_path: str) -> AgentEnvironmentsConfig: + """ + Load and validate environments.yaml file. + + Args: + file_path: Path to environments.yaml file + + Returns: + Validated AgentEnvironmentsConfig + + Raises: + EnvironmentsValidationError: If file is invalid + """ + try: + environments_config = load_environments_config(file_path) + validate_environments_config(environments_config) + return environments_config + except FileNotFoundError: + raise EnvironmentsValidationError( + f"environments.yaml not found: {file_path}\n\n" + "📋 Why required:\n" + " Environment-specific settings (auth, namespace, resources)\n" + " must be separated from global manifest for proper isolation.", + file_path=file_path, + ) from None + except Exception as e: + raise EnvironmentsValidationError(f"Invalid environments.yaml file: {str(e)}", file_path=file_path) from e + + +def validate_manifest_and_environments( + manifest_path: str, required_environment: Optional[str] = None +) -> tuple[str, AgentEnvironmentsConfig]: + """ + Validate both manifest.yaml and environments.yaml files together. + + Args: + manifest_path: Path to manifest.yaml file + required_environment: Specific environment that must be present + + Returns: + Tuple of (manifest_path, environments_config) + + Raises: + ConfigValidationError: If validation fails + """ + manifest_file = Path(manifest_path) + if not manifest_file.exists(): + raise ConfigValidationError(f"Manifest file not found: {manifest_path}") + + # Look for environments.yaml in same directory + environments_file = manifest_file.parent / "environments.yaml" + environments_config = validate_environments_yaml_file(str(environments_file)) + + # Validate specific environment if requested + if required_environment: + validate_environments_config(environments_config, required_environments=[required_environment]) + + return manifest_path, environments_config + + +def generate_helpful_error_message(error: Exception, context: str = "") -> str: + """ + Generate helpful error message with troubleshooting tips. + + Args: + error: The original exception + context: Additional context about where the error occurred + + Returns: + Formatted error message with troubleshooting tips + """ + base_msg = str(error) + + if context: + base_msg = f"{context}: {base_msg}" + + # Add troubleshooting tips based on error type + if isinstance(error, FileNotFoundError): + if "environments.yaml" in base_msg: + base_msg += ( + "\n\n🔧 Troubleshooting:\n" + "1. Check file location: should be next to manifest.yaml\n" + "2. Verify file permissions" + ) + elif "user_id" in base_msg.lower() or "service_account_id" in base_msg.lower(): + base_msg += ( + "\n\n💡 Auth Principal Tips:\n" + "- Set exactly one of 'user_id' or 'service_account_id'\n" + "- The id should be unique per environment\n" + "- For user_id, include environment name (e.g., 'dev_my_agent')\n" + "- Use consistent naming convention across agents" + ) + elif "namespace" in base_msg.lower(): + base_msg += ( + "\n\n🏷️ Namespace Tips:\n" + "- Use lowercase letters, numbers, and hyphens only\n" + "- Include team and environment (e.g., 'team-dev-agent')\n" + "- Keep under 63 characters" + ) + + return base_msg diff --git a/src/agentex/lib/sdk/fastacp/__init__.py b/src/agentex/lib/sdk/fastacp/__init__.py new file mode 100644 index 000000000..b69863798 --- /dev/null +++ b/src/agentex/lib/sdk/fastacp/__init__.py @@ -0,0 +1,3 @@ +from agentex.lib.sdk.fastacp.fastacp import FastACP + +__all__ = ["FastACP"] diff --git a/src/agentex/lib/sdk/fastacp/base/base_acp_server.py b/src/agentex/lib/sdk/fastacp/base/base_acp_server.py new file mode 100644 index 000000000..864b466d0 --- /dev/null +++ b/src/agentex/lib/sdk/fastacp/base/base_acp_server.py @@ -0,0 +1,493 @@ +from __future__ import annotations + +import uuid +import asyncio +import inspect +from typing import Any +from datetime import datetime +from contextlib import asynccontextmanager +from collections.abc import Callable, Awaitable, AsyncGenerator + +import uvicorn +from fastapi import FastAPI, Request +from pydantic import TypeAdapter, ValidationError +from starlette.types import Send, Scope, ASGIApp, Receive +from fastapi.responses import StreamingResponse + +from agentex.protocol.acp import ( + RPC_SYNC_METHODS, + PARAMS_MODEL_BY_METHOD, + RPCMethod, + SendEventParams, + CancelTaskParams, + CreateTaskParams, + SendMessageParams, + InterruptTaskParams, +) +from agentex.lib.utils.logging import make_logger, ctx_var_request_id +from agentex.protocol.json_rpc import JSONRPCError, JSONRPCRequest, JSONRPCResponse +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.utils.registration import register_agent + +# from agentex.lib.sdk.fastacp.types import BaseACPConfig +from agentex.lib.environment_variables import EnvironmentVariables, refreshed_environment_variables +from agentex.types.task_message_update import TaskMessageUpdate, StreamTaskMessageFull +from agentex.types.task_message_content import TaskMessageContent +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.sdk.fastacp.base.constants import ( + FASTACP_HEADER_SKIP_EXACT, + FASTACP_HEADER_SKIP_PREFIXES, +) + +logger = make_logger(__name__) + +# Create a TypeAdapter for TaskMessageUpdate validation +task_message_update_adapter = TypeAdapter(TaskMessageUpdate) + + +def _attach_incoming_otel_context(scope_headers: list[tuple[bytes, bytes]]) -> object | None: + """Extract the inbound W3C trace context (traceparent/tracestate) from ASGI + headers and make it the active OpenTelemetry context for the request. + + FastACP is not otherwise instrumented to *continue* an incoming trace: the + gateway forwards the traceparent header, but nothing on the Python side + extracts it, so the active context stays empty. Downstream that means the + Temporal ``start_workflow`` / ``signal`` (including the work dispatched via + ``asyncio.create_task``) fires with no active span, the interceptor injects + nothing, and the workflow + activities detach into fresh traces. + + Attaching here (in the ASGI middleware that wraps the whole request) fixes + that: the request handler and the background task both run under the ingress + trace, so the interceptor propagates it across the Temporal boundary. + Returns a detach token (or None); fail-open. + """ + try: + from opentelemetry import context as _otel_context + from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator + + # ASGI headers are a list that can repeat a name, and a dict comprehension + # keeps only the last value -- which silently drops repeated `tracestate` + # lines (W3C/RFC7230 say they MUST be combined). Build a dict-of-lists so + # the propagator's getter sees every value and `TraceState.from_header` + # combines them; `traceparent` is single-valued so it is unaffected. + carrier: dict[str, list[str]] = {} + for k, v in scope_headers: + carrier.setdefault(k.decode("latin-1").lower(), []).append(v.decode("latin-1")) + # Use the W3C propagator explicitly rather than the ambient global one: + # this ingress is W3C by contract, and `OTEL_PROPAGATORS=datadog` (plausible + # in a DD shop, and dd_only is the default mode) would otherwise silently + # extract nothing. It also parses only traceparent/tracestate, so arbitrary + # inbound `baggage` is not pulled into the downstream context. + return _otel_context.attach(TraceContextTextMapPropagator().extract(carrier)) + except Exception: # pragma: no cover - obs must never break a request + return None + + +def _detach_otel_context(token: object | None) -> None: + if token is None: + return + try: + from opentelemetry import context as _otel_context + + _otel_context.detach(token) # type: ignore[arg-type] + except Exception: # pragma: no cover - best-effort + pass + + +class RequestIDMiddleware: + """Pure ASGI middleware to set request IDs without buffering streaming responses.""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + otel_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) + # 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) + try: + await self.app(scope, receive, send) + finally: + _detach_otel_context(otel_token) + + +class BaseACPServer(FastAPI): + """ + AsyncAgentACP provides RPC-style hooks for agent events and commands asynchronously. + All methods follow JSON-RPC 2.0 format. + + Available methods: + - event/send → Send a message to a task + - task/cancel → Cancel a task + - task/interrupt → Interrupt the in-flight turn (non-terminal; task stays continuable) + - task/approve → Approve a task + """ + + def __init__(self): + super().__init__(lifespan=self.get_lifespan_function()) + + self.get("/healthz")(self._healthz) + self.post("/api")(self._handle_jsonrpc) + + # Method handlers + # this just adds a request ID to the request and response headers + self.add_middleware(RequestIDMiddleware) + self._handlers: dict[RPCMethod, Callable] = {} + + # Agent info to return in healthz + self.agent_id: str | None = None + + # Optional agent card for registration metadata + self._agent_card: Any | None = None + + @classmethod + def create(cls): + """Create and initialize BaseACPServer instance""" + instance = cls() + instance._setup_handlers() + return instance + + def _setup_handlers(self): + """Set up default handlers - override in subclasses""" + # Base class has no default handlers + pass + + def get_lifespan_function(self): + @asynccontextmanager + async def lifespan_context(app: FastAPI): # noqa: ARG001 + env_vars = EnvironmentVariables.refresh() + if env_vars.AGENTEX_BASE_URL: + # Runtime SDK<->backend contract guard: fail fast if the backend is older + # than this SDK supports, instead of opaque 500s later. See compat.version_guard. + await assert_backend_compatible(env_vars.AGENTEX_BASE_URL) + await register_agent(env_vars, agent_card=self._agent_card) + self.agent_id = env_vars.AGENT_ID + else: + logger.warning("AGENTEX_BASE_URL not set, skipping agent registration") + + try: + yield + finally: + await shutdown_default_span_queue() + + return lifespan_context + + async def _healthz(self): + """Health check endpoint""" + result = {"status": "healthy"} + if self.agent_id: + result["agent_id"] = self.agent_id + return result + + def _wrap_handler(self, fn: Callable[..., Awaitable[Any]]): + """Wraps handler functions to provide JSON-RPC 2.0 response format""" + + async def wrapper(*args, **kwargs) -> Any: + return await fn(*args, **kwargs) + + return wrapper + + async def _handle_jsonrpc(self, request: Request): + """Main JSON-RPC endpoint handler""" + rpc_request = None + logger.info(f"[base_acp_server] received request: {datetime.now()}") + try: + data = await request.json() + rpc_request = JSONRPCRequest(**data) + + # Check if the request is authenticated + if refreshed_environment_variables and getattr(refreshed_environment_variables, "AGENT_API_KEY", None): + authorization_header = request.headers.get("x-agent-api-key") + if authorization_header != refreshed_environment_variables.AGENT_API_KEY: + return JSONRPCResponse( + id=rpc_request.id, + error=JSONRPCError(code=-32601, message="Unauthorized"), + ) + + + # Check if method is valid first + try: + method = RPCMethod(rpc_request.method) + except ValueError: + logger.error(f"Method {rpc_request.method} was invalid") + return JSONRPCResponse( + id=rpc_request.id, + error=JSONRPCError( + code=-32601, message=f"Method {rpc_request.method} not found" + ), + ) + + if method not in self._handlers or self._handlers[method] is None: + logger.error(f"Method {method} not found on existing ACP server") + return JSONRPCResponse( + id=rpc_request.id, + error=JSONRPCError( + code=-32601, message=f"Method {method} not found" + ), + ) + + # Extract application headers using allowlist approach (only x-* headers) + # Matches gateway's security filtering rules + # Forward filtered headers via params.request.headers to agent handlers + custom_headers = { + key: value + for key, value in request.headers.items() + if key.lower().startswith("x-") + and key.lower() not in FASTACP_HEADER_SKIP_EXACT + and not any(key.lower().startswith(p) for p in FASTACP_HEADER_SKIP_PREFIXES) + } + + # Parse params into appropriate model based on method and include headers + params_model = PARAMS_MODEL_BY_METHOD[method] + params_data = dict(rpc_request.params) if rpc_request.params else {} + + # Add custom headers to the request structure if any headers were provided + # Gateway sends filtered headers via HTTP, SDK extracts and populates params.request + if custom_headers: + params_data["request"] = {"headers": custom_headers} + params = params_model.model_validate(params_data) + + if method in RPC_SYNC_METHODS: + handler = self._handlers[method] + result = await handler(params) + + if rpc_request.id is None: + # Seems like you should return None for notifications + return None + else: + # Handle streaming vs non-streaming for MESSAGE_SEND + if method == RPCMethod.MESSAGE_SEND and isinstance( + result, AsyncGenerator + ): + return await self._handle_streaming_response( + rpc_request.id, result + ) + else: + if isinstance(result, BaseModel): + result = result.model_dump() + return JSONRPCResponse(id=rpc_request.id, result=result) + else: + # If this is a notification (no request ID), process in background and return immediately + if rpc_request.id is None: + asyncio.create_task(self._process_notification(method, params)) + return JSONRPCResponse(id=None) + + # For regular requests, start processing in background but return immediately + asyncio.create_task( + self._process_request(rpc_request.id, method, params) + ) + + # Return immediate acknowledgment + return JSONRPCResponse( + id=rpc_request.id, result={"status": "processing"} + ) + + except Exception as e: + logger.error(f"Error handling JSON-RPC request: {e}", exc_info=True) + request_id = None + if rpc_request is not None: + request_id = rpc_request.id + return JSONRPCResponse( + id=request_id, + error=JSONRPCError(code=-32603, message=str(e)).model_dump(), + ) + + async def _handle_streaming_response( + self, request_id: int | str, async_gen: AsyncGenerator + ): + """Handle streaming response by formatting TaskMessageUpdate objects as JSON-RPC stream""" + + async def generate_json_rpc_stream(): + try: + async for chunk in async_gen: + # Each chunk should be a TaskMessageUpdate object + # Validate using Pydantic's TypeAdapter to ensure it's a proper TaskMessageUpdate + try: + # This will validate that chunk conforms to the TaskMessageUpdate union type + validated_chunk = task_message_update_adapter.validate_python( + chunk + ) + # Use mode="json" to properly serialize datetime objects + chunk_data = validated_chunk.model_dump(mode="json") + except ValidationError as e: + raise TypeError( + f"Streaming chunks must be TaskMessageUpdate objects. Validation error: {e}" + ) from e + except Exception as e: + raise TypeError( + f"Streaming chunks must be TaskMessageUpdate objects, got {type(chunk)}: {e}" + ) from e + + # Wrap in JSON-RPC response format + response = JSONRPCResponse(id=request_id, result=chunk_data) + # Use model_dump_json() which handles datetime serialization automatically + yield f"{response.model_dump_json()}\n" + + except Exception as e: + logger.error(f"Error in streaming response: {e}", exc_info=True) + error_response = JSONRPCResponse( + id=request_id, + error=JSONRPCError(code=-32603, message=str(e)).model_dump(), + ) + yield f"{error_response.model_dump_json()}\n" + + return StreamingResponse( + generate_json_rpc_stream(), + media_type="application/x-ndjson", # Newline Delimited JSON + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", # Disable nginx buffering + }, + ) + + async def _process_notification(self, method: RPCMethod, params: Any): + """Process a notification (request with no ID) in the background""" + try: + handler = self._handlers[method] + await handler(params) + except Exception as e: + logger.error(f"Error processing notification {method}: {e}", exc_info=True) + + async def _process_request( + self, request_id: int | str, method: RPCMethod, params: Any + ): + """Process a request in the background""" + try: + handler = self._handlers[method] + await handler(params) + # Note: In a real implementation, you might want to store the result somewhere + # or notify the client through a different mechanism + logger.info( + f"Successfully processed request {request_id} for method {method}" + ) + except Exception as e: + logger.error( + f"Error processing request {request_id} for method {method}: {e}", + exc_info=True, + ) + + """ + Define all possible decorators to be overriden and implemented by each ACP implementation + Then the users can override the default handlers by implementing their own handlers + + ACP Type: Async + Decorators: + - on_task_create + - on_task_event_send + - on_task_cancel + - on_task_interrupt + + ACP Type: Sync + Decorators: + - on_message_send + """ + + # Type: Async + def on_task_create(self, fn: Callable[[CreateTaskParams], Awaitable[Any]]): + """Handle task/init method""" + wrapped = self._wrap_handler(fn) + self._handlers[RPCMethod.TASK_CREATE] = wrapped + return fn + + # Type: Async + def on_task_event_send(self, fn: Callable[[SendEventParams], Awaitable[Any]]): + """Handle event/send method""" + + async def wrapped_handler(params: SendEventParams): + # # # Send message to client first most of the time + # ## But, sometimes you may want to process the message first + # ## and then send a message to the client + # await agentex.interactions.send_messages_to_client( + # task_id=params.task_id, + # messages=[params.message] + # ) + return await fn(params) + + wrapped = self._wrap_handler(wrapped_handler) + self._handlers[RPCMethod.EVENT_SEND] = wrapped + return fn + + # Type: Async + def on_task_cancel(self, fn: Callable[[CancelTaskParams], Awaitable[Any]]): + """Handle task/cancel method""" + wrapped = self._wrap_handler(fn) + self._handlers[RPCMethod.TASK_CANCEL] = wrapped + return fn + + # Type: Async + def on_task_interrupt(self, fn: Callable[[InterruptTaskParams], Awaitable[Any]]): + """Handle task/interrupt method. + + Non-terminal counterpart to ``on_task_cancel``: forwards the interrupt to + the agent so it can stop the in-flight turn while leaving the task + continuable. See the interrupt-and-queue design doc, section 7. + """ + wrapped = self._wrap_handler(fn) + self._handlers[RPCMethod.TASK_INTERRUPT] = wrapped + return fn + + # Type: Sync + def on_message_send( + self, + fn: Callable[ + [SendMessageParams], + Awaitable[TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]], + ], + ): + """Handle message/send method - supports both single and streaming responses + + For non-streaming: return a single TaskMessage + For streaming: return an AsyncGenerator that yields TaskMessageUpdate objects + """ + + async def message_send_wrapper(params: SendMessageParams): + """Special wrapper for message_send that handles both regular async functions and async generators""" + # Check if the function is an async generator function + + # Regardless of whether the Agent developer implemented an Async generator or not, we will always turn the function into an async generator and yield SSE events back tot he Agentex server so there is only one way for it to process the response. Then, based on the client's desire to stream or not, the Agentex server will either yield back the async generator objects directly (if streaming) or aggregate the content into a list of TaskMessageContents and to dispatch to the client. This basically gives the Agentex server the flexibility to handle both cases itself. + + if inspect.isasyncgenfunction(fn): + # The client wants streaming, an async generator already streams the content, so just return it + return fn(params) + else: + # The client wants streaming, but the function is not an async generator, so we turn it into one and yield each TaskMessageContent as a StreamTaskMessageFull which will be streamed to the client by the Agentex server. + task_message_content_response = await fn(params) + # Handle None returns gracefully - treat as empty list + if task_message_content_response is None: + task_message_content_list = [] + elif isinstance(task_message_content_response, list): + # Filter out None values from lists + task_message_content_list = [content for content in task_message_content_response if content is not None] + else: + task_message_content_list = [task_message_content_response] + + async def async_generator(task_message_content_list: list[TaskMessageContent]): + for i, task_message_content in enumerate(task_message_content_list): + yield StreamTaskMessageFull(type="full", index=i, content=task_message_content) + + return async_generator(task_message_content_list) + + self._handlers[RPCMethod.MESSAGE_SEND] = message_send_wrapper + return fn + + """ + End of Decorators + """ + + """ + ACP Server Lifecycle Methods + """ + + def run(self, host: str = "0.0.0.0", port: int = 8000, **kwargs): + """Start the Uvicorn server for async handlers.""" + uvicorn.run(self, host=host, port=port, **kwargs) + + diff --git a/src/agentex/lib/sdk/fastacp/base/constants.py b/src/agentex/lib/sdk/fastacp/base/constants.py new file mode 100644 index 000000000..c04287e0c --- /dev/null +++ b/src/agentex/lib/sdk/fastacp/base/constants.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +# Header filtering rules for FastACP server +# These rules match the gateway's security filtering + +# Hop-by-hop headers that should not be forwarded +HOP_BY_HOP_HEADERS: set[str] = { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "content-length", + "content-encoding", + "host", +} + +# Sensitive headers that should never be forwarded +BLOCKED_HEADERS: set[str] = { + "authorization", + "cookie", + "x-agent-api-key", +} + +# Legacy constants for backward compatibility +FASTACP_HEADER_SKIP_EXACT: set[str] = HOP_BY_HOP_HEADERS | BLOCKED_HEADERS + +FASTACP_HEADER_SKIP_PREFIXES: tuple[str, ...] = ( + "x-forwarded-", # proxy headers + "sec-", # security headers added by browsers +) + + diff --git a/src/agentex/lib/sdk/fastacp/fastacp.py b/src/agentex/lib/sdk/fastacp/fastacp.py new file mode 100644 index 000000000..4a76c294e --- /dev/null +++ b/src/agentex/lib/sdk/fastacp/fastacp.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from typing import Any, Literal +from typing_extensions import deprecated + +from agentex.lib.types.fastacp import ( + BaseACPConfig, + SyncACPConfig, + AsyncACPConfig, + AgenticACPConfig, +) +from agentex.lib.utils.logging import make_logger +from agentex.lib.sdk.fastacp.impl.sync_acp import SyncACP +from agentex.lib.sdk.fastacp.impl.temporal_acp import TemporalACP +from agentex.lib.sdk.fastacp.impl.async_base_acp import AsyncBaseACP +from agentex.lib.sdk.fastacp.base.base_acp_server import BaseACPServer + +# Add new mappings between ACP types and configs here +# Add new mappings between ACP types and implementations here +AGENTIC_ACP_IMPLEMENTATIONS: dict[Literal["temporal", "base"], type[BaseACPServer]] = { + "temporal": TemporalACP, + "base": AsyncBaseACP, +} + +logger = make_logger(__name__) + + +class FastACP: + """Factory for creating FastACP instances + + Supports three main ACP types: + - "sync": Simple synchronous ACP implementation + - "async": Advanced ACP with sub-types "base" or "temporal" (requires config) + - "agentic": (Deprecated, use "async") Identical to "async" + """ + + @staticmethod + # Note: the config is optional and not used right now but is there to be extended in the future + def create_sync_acp(config: SyncACPConfig | None = None, **kwargs) -> SyncACP: # noqa: ARG004 + """Create a SyncACP instance""" + return SyncACP.create(**kwargs) + + @staticmethod + def create_async_acp(config: AsyncACPConfig, **kwargs) -> BaseACPServer: + """Create an async ACP instance (base or temporal) + + Args: + config: AsyncACPConfig with type="base" or type="temporal" + **kwargs: Additional configuration parameters + """ + # Get implementation class + implementation_class = AGENTIC_ACP_IMPLEMENTATIONS[config.type] + # Handle temporal-specific configuration + if config.type == "temporal": + # Extract temporal_address, plugins, and interceptors from config if it's a TemporalACPConfig + temporal_config = kwargs.copy() + if hasattr(config, "temporal_address"): + temporal_config["temporal_address"] = config.temporal_address # type: ignore[attr-defined] + if hasattr(config, "plugins"): + temporal_config["plugins"] = config.plugins # type: ignore[attr-defined] + if hasattr(config, "interceptors"): + temporal_config["interceptors"] = config.interceptors # type: ignore[attr-defined] + if hasattr(config, "payload_codec"): + temporal_config["payload_codec"] = config.payload_codec # type: ignore[attr-defined] + if hasattr(config, "data_converter"): + temporal_config["data_converter"] = config.data_converter # type: ignore[attr-defined] + return implementation_class.create(**temporal_config) + else: + return implementation_class.create(**kwargs) + + @staticmethod + @deprecated("Use create_async_acp instead") + def create_agentic_acp(config: AgenticACPConfig, **kwargs) -> BaseACPServer: + """Create an async ACP instance (base or temporal) + + Args: + config: AsyncACPConfig with type="base" or type="temporal" + **kwargs: Additional configuration parameters + """ + return FastACP.create_async_acp(config, **kwargs) + + @staticmethod + def create( + acp_type: Literal["sync", "async", "agentic"], + config: BaseACPConfig | None = None, + agent_card: Any | None = None, + **kwargs, + ) -> BaseACPServer | SyncACP | AsyncBaseACP | TemporalACP: + """Main factory method to create any ACP type + + Args: + acp_type: Type of ACP to create ("sync", "async", or "agentic") + config: Configuration object. Required for async/agentic type. + **kwargs: Additional configuration parameters + """ + + if acp_type == "sync": + sync_config = config if isinstance(config, SyncACPConfig) else None + instance = FastACP.create_sync_acp(sync_config, **kwargs) + elif acp_type == "async" or acp_type == "agentic": + if config is None: + config = AsyncACPConfig(type="base") + if not isinstance(config, AsyncACPConfig): + raise ValueError("AsyncACPConfig is required for async/agentic ACP type") + instance = FastACP.create_async_acp(config, **kwargs) + else: + raise ValueError(f"Unknown acp_type: {acp_type}") + + if agent_card is not None: + instance._agent_card = agent_card # type: ignore[attr-defined] + + return instance diff --git a/src/agentex/lib/sdk/fastacp/impl/async_base_acp.py b/src/agentex/lib/sdk/fastacp/impl/async_base_acp.py new file mode 100644 index 000000000..e9d20f150 --- /dev/null +++ b/src/agentex/lib/sdk/fastacp/impl/async_base_acp.py @@ -0,0 +1,75 @@ +from typing import Any +from typing_extensions import override + +from agentex.protocol.acp import ( + SendEventParams, + CancelTaskParams, + CreateTaskParams, +) +from agentex.lib.utils.logging import make_logger +from agentex.lib.adk.utils._modules.client import create_async_agentex_client +from agentex.lib.sdk.fastacp.base.base_acp_server import BaseACPServer + +logger = make_logger(__name__) + + +class AsyncBaseACP(BaseACPServer): + """ + AsyncBaseACP implementation - a synchronous ACP that provides basic functionality + without any special async orchestration like Temporal. + + This implementation provides simple synchronous processing of tasks + and is suitable for basic agent implementations. + """ + + def __init__(self): + super().__init__() + self._setup_handlers() + self._agentex_client = create_async_agentex_client() + + @classmethod + @override + def create(cls, **kwargs: Any) -> "AsyncBaseACP": + """Create and initialize SyncACP instance + + Args: + **kwargs: Configuration parameters (unused in sync implementation) + + Returns: + Initialized SyncACP instance + """ + logger.info("Initializing AsyncBaseACP instance") + instance = cls() + logger.info("AsyncBaseACP instance initialized with default handlers") + return instance + + @override + def _setup_handlers(self): + """Set up default handlers for sync operations""" + + @self.on_task_create + async def handle_create_task(params: CreateTaskParams) -> None: # type: ignore[unused-function] + """Default create task handler - logs the task""" + logger.info(f"AsyncBaseACP creating task {params.task.id}") + + @self.on_task_event_send + async def handle_event_send(params: SendEventParams) -> None: # type: ignore[unused-function] + """Default event handler - logs the event""" + logger.info( + f"AsyncBaseACP received event for task {params.task.id}: {params.event.id}," + f"content: {params.event.content}" + ) + # TODO: Implement event handling logic here + + # Implement cursor commit logic here + await self._agentex_client.tracker.update( + tracker_id=params.task.id, + last_processed_event_id=params.event.id, + ) + + @self.on_task_cancel + async def handle_cancel(params: CancelTaskParams) -> None: # type: ignore[unused-function] + """Default cancel handler - logs the cancellation""" + logger.info(f"AsyncBaseACP canceling task {params.task.id}") + +AgenticBaseACP = AsyncBaseACP \ No newline at end of file diff --git a/src/agentex/lib/sdk/fastacp/impl/sync_acp.py b/src/agentex/lib/sdk/fastacp/impl/sync_acp.py new file mode 100644 index 000000000..5ecad073e --- /dev/null +++ b/src/agentex/lib/sdk/fastacp/impl/sync_acp.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from typing import Any, override +from collections.abc import AsyncGenerator + +from agentex.protocol.acp import SendMessageParams +from agentex.lib.utils.logging import make_logger +from agentex.types.task_message_delta import TextDelta +from agentex.types.task_message_update import ( + TaskMessageUpdate, + StreamTaskMessageFull, + StreamTaskMessageDelta, +) +from agentex.types.task_message_content import TextContent, TaskMessageContent +from agentex.lib.sdk.fastacp.base.base_acp_server import BaseACPServer + +logger = make_logger(__name__) + + +class SyncACP(BaseACPServer): + """ + SyncACP provides synchronous request-response style communication. + Handlers execute and return responses immediately. + + The SyncACP automatically creates input and output messages, so handlers + don't need to manually create TaskMessage objects via the Agentex API. All that needs + to be done is return the output message via TaskMessageContent objects. + + Usage: + acp = SyncACP() + + @acp.on_message_send + async def handle_message(params: SendMessageParams) -> TaskMessageContent: + # Process message and return response + pass + + acp.run() + """ + + def __init__(self): + super().__init__() + self._setup_handlers() + + @classmethod + @override + def create(cls, **kwargs: Any) -> "SyncACP": + """Create and initialize SyncACP instance + + Args: + **kwargs: Configuration parameters (unused in sync implementation) + + Returns: + Initialized SyncACP instance + """ + logger.info("Creating SyncACP instance") + instance = cls() + logger.info("SyncACP instance created with default handlers") + return instance + + @override + def _setup_handlers(self): + """Set up default handlers for sync operations""" + + @self.on_message_send + async def handle_message_send( # type: ignore[unused-function] + params: SendMessageParams + ) -> TaskMessageContent | AsyncGenerator[TaskMessageUpdate, None]: + """Default message handler with TaskMessageUpdate streaming support + + For streaming, the SyncACP server automatically creates the input and output + messages, so we just return TaskMessageUpdate objects with parent_task_message=None + """ + logger.info( + f"SyncACP received message for task {params.task.id}: {params.content}" + ) + + if params.stream: + # Return streaming response + async def stream_response(): + # Example: Stream 3 chunks + full_message = "" + for i in range(3): + data = f"Streaming chunk {i+1}: Processing your request...\n" + full_message += data + yield StreamTaskMessageDelta( + type="delta", + index=0, + delta=TextDelta( + text_delta=f"Streaming chunk {i+1}: Processing your request...\n" + ), + ) + + # Final response + yield StreamTaskMessageFull( + type="full", + index=0, + content=TextContent( + author="agent", + content=full_message, + format="markdown", + ), + ) + + return stream_response() + else: + # Return single response for non-streaming + return TextContent( + author="agent", + content=f"Processed message for task {params.task.id}", + format="markdown", + ) diff --git a/src/agentex/lib/sdk/fastacp/impl/temporal_acp.py b/src/agentex/lib/sdk/fastacp/impl/temporal_acp.py new file mode 100644 index 000000000..1a9cce7a8 --- /dev/null +++ b/src/agentex/lib/sdk/fastacp/impl/temporal_acp.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from typing import Any, Callable, AsyncGenerator, override +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from temporalio.converter import PayloadCodec, DataConverter + +from agentex.protocol.acp import ( + SendEventParams, + CancelTaskParams, + CreateTaskParams, + InterruptTaskParams, +) +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.sdk.fastacp.base.base_acp_server import BaseACPServer +from agentex.lib.core.clients.temporal.temporal_client import TemporalClient +from agentex.lib.core.temporal.services.temporal_task_service import TemporalTaskService + +logger = make_logger(__name__) + + +class TemporalACP(BaseACPServer): + """ + Temporal-specific implementation of AsyncAgentACP. + Uses TaskService to forward operations to temporal workflows. + """ + + def __init__( + self, + temporal_address: str, + temporal_task_service: TemporalTaskService | None = None, + plugins: list[Any] | None = None, + interceptors: list[Any] | None = None, + payload_codec: PayloadCodec | None = None, + data_converter: DataConverter | None = None, + ): + super().__init__() + self._temporal_task_service = temporal_task_service + self._temporal_address = temporal_address + self._plugins = plugins or [] + self._interceptors = interceptors or [] + self._payload_codec = payload_codec + self._data_converter = data_converter + + @classmethod + @override + def create( + cls, + temporal_address: str, + plugins: list[Any] | None = None, + interceptors: list[Any] | None = None, + payload_codec: PayloadCodec | None = None, + data_converter: DataConverter | None = None, + ) -> "TemporalACP": + logger.info("Initializing TemporalACP instance") + + # Create instance without temporal client initially + temporal_acp = cls( + temporal_address=temporal_address, + plugins=plugins, + interceptors=interceptors, + payload_codec=payload_codec, + data_converter=data_converter, + ) + temporal_acp._setup_handlers() + logger.info("TemporalACP instance initialized now") + return temporal_acp + + @override + def get_lifespan_function(self) -> Callable[[FastAPI], AsyncGenerator[None, None]]: + @asynccontextmanager + async def lifespan(app: FastAPI): + # Create temporal client during startup + if self._temporal_address is None: + raise ValueError("Temporal address is not set") + + if self._temporal_task_service is None: + env_vars = EnvironmentVariables.refresh() + temporal_client = await TemporalClient.create( + temporal_address=self._temporal_address, + plugins=self._plugins, + payload_codec=self._payload_codec, + data_converter=self._data_converter, + ) + self._temporal_task_service = TemporalTaskService( + temporal_client=temporal_client, + env_vars=env_vars, + ) + + # Call parent lifespan for agent registration + async with super().get_lifespan_function()(app): # type: ignore[misc] + yield + + return lifespan # type: ignore[return-value] + + @override + def _setup_handlers(self): + """Set up the handlers for temporal workflow operations""" + + @self.on_task_create + async def handle_task_create(params: CreateTaskParams) -> None: + """Default create task handler - logs the task""" + logger.info(f"TemporalACP received task create rpc call for task {params.task.id}") + if self._temporal_task_service is not None: + await self._temporal_task_service.submit_task( + agent=params.agent, task=params.task, params=params.params + ) + + @self.on_task_event_send + async def handle_event_send(params: SendEventParams) -> None: + """Forward messages to running workflows via TaskService""" + try: + if self._temporal_task_service is not None: + await self._temporal_task_service.send_event( + agent=params.agent, + task=params.task, + event=params.event, + request=params.request, + ) + + except Exception as e: + logger.error(f"Failed to send message: {e}") + raise + + @self.on_task_cancel + async def handle_cancel(params: CancelTaskParams) -> None: + """Cancel running workflows via TaskService""" + try: + if self._temporal_task_service is not None: + await self._temporal_task_service.cancel(task_id=params.task.id) + except Exception as e: + logger.error(f"Failed to cancel task: {e}") + raise + + @self.on_task_interrupt + async def handle_interrupt(params: InterruptTaskParams) -> None: + """Forward task/interrupt to the running workflow via TaskService. + + Non-terminal: signals the workflow's ``interrupt_turn`` handler rather + than tearing the workflow down, so the task stays continuable. + """ + try: + if self._temporal_task_service is not None: + await self._temporal_task_service.interrupt( + agent=params.agent, + task=params.task, + request=params.request, + ) + except Exception as e: + logger.error(f"Failed to interrupt task: {e}") + raise diff --git a/src/agentex/lib/sdk/fastacp/tests/README.md b/src/agentex/lib/sdk/fastacp/tests/README.md new file mode 100644 index 000000000..fa958012b --- /dev/null +++ b/src/agentex/lib/sdk/fastacp/tests/README.md @@ -0,0 +1,297 @@ +# BaseACPServer Test Suite + +This directory contains comprehensive tests for the `BaseACPServer` and its implementations (`SyncACP`, `AsyncBaseACP`, and `TemporalACP`). + +## Test Structure + +The test suite is organized into several categories: + +### 1. Core Unit Tests (`test_base_acp_server.py`) +- **TestBaseACPServerInitialization**: Server initialization and setup +- **TestHealthCheckEndpoint**: Health check endpoint functionality +- **TestJSONRPCEndpointCore**: Basic JSON-RPC endpoint functionality +- **TestHandlerRegistration**: Handler registration and management +- **TestBackgroundProcessing**: Background task processing +- **TestErrorHandling**: Basic error handling scenarios + +### 2. JSON-RPC Endpoint Tests (`test_json_rpc_endpoints.py`) +- **TestJSONRPCMethodHandling**: Method routing and execution +- **TestJSONRPCParameterValidation**: Parameter parsing and validation +- **TestJSONRPCResponseFormat**: Response formatting compliance +- **TestJSONRPCErrorCodes**: JSON-RPC 2.0 error code compliance +- **TestJSONRPCConcurrency**: Concurrent request handling + +### 3. Integration Tests (`test_server_integration.py`) +- **TestServerLifecycle**: Server startup, running, and shutdown +- **TestHTTPClientIntegration**: Real HTTP client interactions +- **TestHandlerExecutionIntegration**: Handler execution in server environment +- **TestServerPerformance**: Performance characteristics + +### 4. Implementation Tests (`test_implementations.py`) +- **TestSyncACP**: SyncACP-specific functionality +- **TestAsyncBaseACP**: AsyncBaseACP-specific functionality +- **TestTemporalACP**: TemporalACP-specific functionality +- **TestImplementationComparison**: Differences between implementations +- **TestImplementationErrorHandling**: Implementation-specific error handling + +### 5. Error Handling Tests (`test_error_handling.py`) +- **TestMalformedRequestHandling**: Invalid and malformed requests +- **TestHandlerErrorHandling**: Handler-level error scenarios +- **TestServerErrorHandling**: Server-level error handling +- **TestEdgeCases**: Edge cases and boundary conditions + +## Running Tests + +### Prerequisites + +Install test dependencies: +```bash +pip install pytest pytest-asyncio httpx pytest-cov pytest-xdist +``` + +### Basic Usage + +Run all tests: +```bash +python run_tests.py +``` + +Run specific test categories: +```bash +python run_tests.py --category unit +python run_tests.py --category integration +python run_tests.py --category implementations +python run_tests.py --category error +``` + +### Advanced Options + +Run with coverage: +```bash +python run_tests.py --coverage +``` + +Run in parallel: +```bash +python run_tests.py --parallel 4 +``` + +Run with increased verbosity: +```bash +python run_tests.py -vv +``` + +Stop on first failure: +```bash +python run_tests.py --failfast +``` + +Run only failed tests from last run: +```bash +python run_tests.py --lf +``` + +### Quick Test Options + +For development, use these quick test commands: + +```bash +# Quick smoke tests +python run_tests.py smoke + +# Quick development tests +python run_tests.py quick + +# Performance tests only +python run_tests.py perf +``` + +### Direct pytest Usage + +You can also run tests directly with pytest: + +```bash +# Run all tests +pytest + +# Run specific test file +pytest test_base_acp_server.py + +# Run specific test class +pytest test_base_acp_server.py::TestBaseACPServerInitialization + +# Run specific test method +pytest test_base_acp_server.py::TestBaseACPServerInitialization::test_base_acp_server_init + +# Run with markers +pytest -m "not slow" +``` + +## Test Configuration + +### Fixtures (`conftest.py`) + +The test suite uses several fixtures: + +- **`free_port`**: Provides a free port for testing +- **`sample_task`**, **`sample_message`**: Sample data objects +- **`base_acp_server`**, **`sync_acp`**, **`agentic_base_acp`**, **`mock_temporal_acp`**: Server instances +- **`test_server_runner`**: Manages server lifecycle for integration tests +- **`jsonrpc_client_factory`**: Creates JSON-RPC test clients +- **`mock_env_vars`**: Mocked environment variables + +### Test Utilities + +- **`TestServerRunner`**: Manages server startup/shutdown for integration tests +- **`JSONRPCTestClient`**: Simplified JSON-RPC client for testing +- **`find_free_port()`**: Utility to find available ports + +## Test Categories Explained + +### Unit Tests +Focus on individual components in isolation: +- Server initialization +- Handler registration +- Basic endpoint functionality +- Parameter validation + +### Integration Tests +Test components working together: +- Full server lifecycle +- Real HTTP requests +- Handler execution in server context +- Performance characteristics + +### Implementation Tests +Test specific ACP implementations: +- SyncACP behavior +- AsyncBaseACP send_event functionality +- TemporalACP workflow integration +- Implementation differences + +### Error Handling Tests +Comprehensive error scenarios: +- Malformed JSON-RPC requests +- Handler exceptions +- Server error recovery +- Edge cases and boundary conditions + +## Writing New Tests + +### Test Naming Convention +- Test files: `test_*.py` +- Test classes: `Test*` +- Test methods: `test_*` + +### Async Test Example +```python +@pytest.mark.asyncio +async def test_my_async_functionality(self, base_acp_server): + # Your async test code here + result = await some_async_operation() + assert result is not None +``` + +### Integration Test Example +```python +@pytest.mark.asyncio +async def test_server_integration(self, base_acp_server, free_port, test_server_runner): + runner = test_server_runner(base_acp_server, free_port) + await runner.start() + + try: + # Test server functionality + async with httpx.AsyncClient() as client: + response = await client.get(f"http://127.0.0.1:{free_port}/healthz") + assert response.status_code == 200 + finally: + await runner.stop() +``` + +### Handler Test Example +```python +@pytest.mark.asyncio +async def test_custom_handler(self, base_acp_server): + handler_called = False + + @base_acp_server.on_task_event_send + async def test_handler(params: SendEventParams): + nonlocal handler_called + handler_called = True + return {"handled": True} + + # Test handler execution + params = SendEventParams(...) + result = await base_acp_server._handlers[RPCMethod.EVENT_SEND](params) + + assert handler_called is True + assert result["handled"] is True +``` + +## Continuous Integration + +The test suite is designed to work well in CI environments: + +- Tests are isolated and don't interfere with each other +- Ports are dynamically allocated to avoid conflicts +- Background tasks are properly cleaned up +- Timeouts are reasonable for CI environments + +### CI Configuration Example + +```yaml +# .github/workflows/test.yml +name: Tests +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: '3.9' + - run: pip install -r requirements.txt + - run: pip install pytest pytest-asyncio httpx pytest-cov + - run: cd agentex/sdk/fastacp/tests && python run_tests.py --coverage +``` + +## Troubleshooting + +### Common Issues + +1. **Port conflicts**: Tests use dynamic port allocation, but if you see port conflicts, try running tests sequentially: + ```bash + python run_tests.py --parallel 1 + ``` + +2. **Async test failures**: Make sure all async tests are marked with `@pytest.mark.asyncio` + +3. **Handler not found errors**: Ensure handlers are properly registered before testing + +4. **Timeout issues**: Some tests have built-in delays for background processing. If tests are flaky, increase sleep times in test code. + +### Debug Mode + +Run tests with maximum verbosity and no capture: +```bash +pytest -vvv -s --tb=long +``` + +### Memory Issues + +If you encounter memory issues with large tests: +```bash +python run_tests.py --markers "not memory_intensive" +``` + +## Contributing + +When adding new tests: + +1. Follow the existing test structure and naming conventions +2. Add appropriate docstrings explaining what the test does +3. Use fixtures for common setup +4. Clean up resources properly (especially in integration tests) +5. Add tests to the appropriate category in `run_tests.py` +6. Update this README if adding new test categories or significant functionality \ No newline at end of file diff --git a/src/agentex/lib/sdk/fastacp/tests/conftest.py b/src/agentex/lib/sdk/fastacp/tests/conftest.py new file mode 100644 index 000000000..59ecbfee3 --- /dev/null +++ b/src/agentex/lib/sdk/fastacp/tests/conftest.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +import time +import socket +import asyncio +from typing import Any +from unittest.mock import AsyncMock, patch + +import httpx +import pytest +import uvicorn +import pytest_asyncio + +from agentex.types.task import Task +from agentex.types.agent import Agent +from agentex.protocol.acp import ( + CancelTaskParams, + CreateTaskParams, + SendMessageParams, +) +from agentex.protocol.json_rpc import JSONRPCRequest +from agentex.types.task_message import TaskMessageContent +from agentex.types.task_message_content import TextContent +from agentex.lib.sdk.fastacp.impl.sync_acp import SyncACP +from agentex.lib.sdk.fastacp.impl.temporal_acp import TemporalACP +from agentex.lib.sdk.fastacp.impl.async_base_acp import AsyncBaseACP +from agentex.lib.sdk.fastacp.base.base_acp_server import BaseACPServer + +# Configure pytest-asyncio +pytest_plugins = ("pytest_asyncio",) + + +def find_free_port() -> int: + """Find a free port for testing""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + s.listen(1) + port = s.getsockname()[1] + return port + + +@pytest.fixture +def free_port() -> int: + """Fixture that provides a free port for testing""" + return find_free_port() + + +@pytest.fixture +def sample_task() -> Task: + """Fixture that provides a sample Task object""" + return Task( + id="test-task-123", status="RUNNING" + ) + + +@pytest.fixture +def sample_message_content() -> TaskMessageContent: + """Fixture that provides a sample TaskMessage object""" + return TextContent( + type="text", + author="user", + content="Hello, this is a test message", + ) + + +@pytest.fixture +def sample_send_message_params( + sample_task: Task, sample_message_content: TaskMessageContent +) -> SendMessageParams: + """Fixture that provides sample SendMessageParams""" + return SendMessageParams( + agent=Agent( + id="test-agent-456", + name="test-agent", + description="test-agent", + acp_type="sync", + created_at="2023-01-01T00:00:00Z", + updated_at="2023-01-01T00:00:00Z", + ), + task=sample_task, + content=sample_message_content, + stream=False, + ) + + +@pytest.fixture +def sample_cancel_task_params() -> CancelTaskParams: + """Fixture that provides sample CancelTaskParams""" + return CancelTaskParams( + agent=Agent(id="test-agent-456", name="test-agent", description="test-agent", acp_type="sync", created_at="2023-01-01T00:00:00Z", updated_at="2023-01-01T00:00:00Z"), + task=Task(id="test-task-123", status="RUNNING"), + ) + + +@pytest.fixture +def sample_create_task_params(sample_task: Task) -> CreateTaskParams: + """Fixture that provides sample CreateTaskParams""" + return CreateTaskParams( + agent=Agent(id="test-agent-456", name="test-agent", description="test-agent", acp_type="sync", created_at="2023-01-01T00:00:00Z", updated_at="2023-01-01T00:00:00Z"), + task=sample_task, + params={}, + ) + + +class TestServerRunner: + """Utility class for running test servers""" + + def __init__(self, app: BaseACPServer, port: int): + self.app = app + self.port = port + self.server = None + self.server_task = None + + async def start(self): + """Start the server in a background task""" + config = uvicorn.Config( + app=self.app, + host="127.0.0.1", + port=self.port, + log_level="error", # Reduce noise in tests + ) + self.server = uvicorn.Server(config) + self.server_task = asyncio.create_task(self.server.serve()) + + # Wait for server to be ready + await self._wait_for_server() + + async def stop(self): + """Stop the server""" + if self.server: + self.server.should_exit = True + if self.server_task: + try: + await asyncio.wait_for(self.server_task, timeout=5.0) + except TimeoutError: + self.server_task.cancel() + try: + await self.server_task + except asyncio.CancelledError: + pass + + async def _wait_for_server(self, timeout: float = 10.0): + """Wait for server to be ready to accept connections""" + start_time = time.time() + while time.time() - start_time < timeout: + try: + async with httpx.AsyncClient() as client: + response = await client.get(f"http://127.0.0.1:{self.port}/healthz") + if response.status_code == 200: + return + except (httpx.ConnectError, httpx.ConnectTimeout): + await asyncio.sleep(0.1) + raise TimeoutError(f"Server did not start within {timeout} seconds") + + +@pytest_asyncio.fixture +async def test_server_runner(): + """Fixture that provides a TestServerRunner factory""" + runners = [] + + def create_runner(app: BaseACPServer, port: int) -> TestServerRunner: + runner = TestServerRunner(app, port) + runners.append(runner) + return runner + + yield create_runner + + # Cleanup all runners + for runner in runners: + await runner.stop() + + +@pytest.fixture +def base_acp_server(): + """Fixture that provides a BaseACPServer instance for sync tests""" + with patch.dict( + "os.environ", {"AGENTEX_BASE_URL": ""} + ): # Disable agent registration + server = BaseACPServer() + return server + + +@pytest_asyncio.fixture +async def async_base_acp_server(): + """Fixture that provides a BaseACPServer instance for async tests""" + with patch.dict( + "os.environ", {"AGENTEX_BASE_URL": ""} + ): # Disable agent registration + server = BaseACPServer.create() + return server + + +@pytest.fixture +def sync_acp_server(): + """Fixture that provides a SyncACP instance for sync tests""" + with patch.dict( + "os.environ", {"AGENTEX_BASE_URL": ""} + ): # Disable agent registration + server = SyncACP() + return server + + +@pytest_asyncio.fixture +async def async_sync_acp_server(): + """Fixture that provides a SyncACP instance for async tests""" + with patch.dict( + "os.environ", {"AGENTEX_BASE_URL": ""} + ): # Disable agent registration + server = SyncACP.create() + return server + + +@pytest.fixture +def agentic_base_acp_server(): + """Fixture that provides an AgenticBaseACP instance for sync tests""" + with patch.dict( + "os.environ", {"AGENTEX_BASE_URL": ""} + ): # Disable agent registration + server = AsyncBaseACP() + return server + + +@pytest_asyncio.fixture +async def async_agentic_base_acp_server(): + """Fixture that provides an AsyncBaseACP instance for async tests""" + with patch.dict( + "os.environ", {"AGENTEX_BASE_URL": ""} + ): # Disable agent registration + server = AsyncBaseACP.create() + return server + + +@pytest_asyncio.fixture +async def mock_temporal_acp_server(): + """Fixture that provides a mocked TemporalACP instance""" + with patch.dict( + "os.environ", {"AGENTEX_BASE_URL": ""} + ): # Disable agent registration + with patch( + "agentex.sdk.fastacp.impl.temporal_acp.TemporalClient" + ) as mock_temporal_client: + with patch( + "agentex.sdk.fastacp.impl.temporal_acp.AsyncAgentexClient" + ) as mock_agentex_client: + # Mock the temporal client creation + mock_temporal_client.create.return_value = AsyncMock() + mock_agentex_client.return_value = AsyncMock() + + server = TemporalACP.create(temporal_address="localhost:7233") + return server + + +class JSONRPCTestClient: + """Test client for making JSON-RPC requests""" + + def __init__(self, base_url: str): + self.base_url = base_url + + async def call_method( + self, method: str, params: dict[str, Any], request_id: str | None = "test-1" + ) -> dict[str, Any]: + """Make a JSON-RPC method call""" + request = JSONRPCRequest(method=method, params=params, id=request_id) + + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.base_url}/api", + json=request.model_dump(), + headers={"Content-Type": "application/json"}, + ) + return response.json() + + async def send_notification( + self, method: str, params: dict[str, Any] + ) -> dict[str, Any]: + """Send a JSON-RPC notification (no ID)""" + return await self.call_method(method, params, request_id=None) + + async def health_check(self) -> dict[str, Any]: + """Check server health""" + async with httpx.AsyncClient() as client: + response = await client.get(f"{self.base_url}/healthz") + return response.json() + + +@pytest.fixture +def jsonrpc_client_factory(): + """Fixture that provides a JSONRPCTestClient factory""" + + def create_client(base_url: str) -> JSONRPCTestClient: + return JSONRPCTestClient(base_url) + + return create_client + + +# Mock environment variables for testing +@pytest.fixture +def mock_env_vars(): + """Fixture that mocks environment variables""" + env_vars = { + "AGENTEX_BASE_URL": "", # Disable agent registration by default + "AGENT_NAME": "test-agent", + "AGENT_DESCRIPTION": "Test agent description", + "ACP_URL": "http://localhost", + "ACP_PORT": "8000", + "WORKFLOW_NAME": "test-workflow", + "WORKFLOW_TASK_QUEUE": "test-queue", + } + + with patch.dict("os.environ", env_vars): + yield env_vars diff --git a/src/agentex/lib/sdk/fastacp/tests/pytest.ini b/src/agentex/lib/sdk/fastacp/tests/pytest.ini new file mode 100644 index 000000000..c36f46f20 --- /dev/null +++ b/src/agentex/lib/sdk/fastacp/tests/pytest.ini @@ -0,0 +1,10 @@ +[tool:pytest] +asyncio_mode = auto +addopts = -v --tb=short +testpaths = . +python_files = test_*.py +python_classes = Test* +python_functions = test_* +filterwarnings = + ignore::DeprecationWarning + ignore::PytestDeprecationWarning \ No newline at end of file diff --git a/src/agentex/lib/sdk/fastacp/tests/run_tests.py b/src/agentex/lib/sdk/fastacp/tests/run_tests.py new file mode 100644 index 000000000..8b23be165 --- /dev/null +++ b/src/agentex/lib/sdk/fastacp/tests/run_tests.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +""" +Test runner for BaseACPServer and implementations. + +This script provides various options for running the test suite: +- Run all tests +- Run specific test categories +- Run with different verbosity levels +- Generate coverage reports +- Run performance tests +""" + +import sys +import argparse +import subprocess +from pathlib import Path + + +def run_command(cmd, description=""): + """Run a command and return the result""" + if description: + print(f"\n{'='*60}") + print(f"Running: {description}") + print(f"Command: {' '.join(cmd)}") + print(f"{'='*60}") + + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + + if result.stdout: + print(result.stdout) + if result.stderr: + print(result.stderr, file=sys.stderr) + + return result.returncode == 0 + + +def main(): + parser = argparse.ArgumentParser(description="Run BaseACPServer tests") + parser.add_argument( + "--category", + choices=["unit", "integration", "implementations", "error", "all"], + default="all", + help="Test category to run", + ) + parser.add_argument( + "--verbose", + "-v", + action="count", + default=0, + help="Increase verbosity (use -v, -vv, or -vvv)", + ) + parser.add_argument("--coverage", action="store_true", help="Run with coverage reporting") + parser.add_argument( + "--parallel", "-n", type=int, help="Run tests in parallel (number of workers)" + ) + parser.add_argument( + "--markers", "-m", help="Run tests with specific markers (e.g., 'not slow')" + ) + parser.add_argument("--failfast", "-x", action="store_true", help="Stop on first failure") + parser.add_argument( + "--lf", + "--last-failed", + action="store_true", + help="Run only tests that failed in the last run", + ) + parser.add_argument( + "--collect-only", action="store_true", help="Only collect tests, don't run them" + ) + + args = parser.parse_args() + + # Base pytest command + cmd = ["python", "-m", "pytest"] + + # Add test files based on category + test_files = { + "unit": ["test_base_acp_server.py", "test_json_rpc_endpoints.py"], + "integration": ["test_server_integration.py"], + "implementations": ["test_implementations.py"], + "error": ["test_error_handling.py"], + "all": [ + "test_base_acp_server.py", + "test_json_rpc_endpoints.py", + "test_server_integration.py", + "test_implementations.py", + "test_error_handling.py", + ], + } + + # Add test files to command + for test_file in test_files[args.category]: + cmd.append(test_file) + + # Add verbosity + if args.verbose: + cmd.append("-" + "v" * min(args.verbose, 3)) + + # Add coverage + if args.coverage: + cmd.extend( + [ + "--cov=agentex.sdk.fastacp", + "--cov-report=html", + "--cov-report=term-missing", + "--cov-branch", + ] + ) + + # Add parallel execution + if args.parallel: + cmd.extend(["-n", str(args.parallel)]) + + # Add markers + if args.markers: + cmd.extend(["-m", args.markers]) + + # Add fail fast + if args.failfast: + cmd.append("-x") + + # Add last failed + if args.lf: + cmd.append("--lf") + + # Add collect only + if args.collect_only: + cmd.append("--collect-only") + + # Add other useful options + cmd.extend( + [ + "--tb=short", # Shorter traceback format + "--strict-markers", # Strict marker checking + "--disable-warnings", # Disable warnings for cleaner output + ] + ) + + # Change to test directory + test_dir = Path(__file__).parent + original_cwd = Path.cwd() + + try: + import os + + os.chdir(test_dir) + + # Run the tests + success = run_command(cmd, f"Running {args.category} tests") + + if success: + print(f"\n✅ All {args.category} tests passed!") + if args.coverage: + print("📊 Coverage report generated in htmlcov/") + else: + print(f"\n❌ Some {args.category} tests failed!") + return 1 + + finally: + os.chdir(original_cwd) + + return 0 + + +def run_quick_tests(): + """Run a quick subset of tests for development""" + cmd = [ + "python", + "-m", + "pytest", + "test_base_acp_server.py::TestBaseACPServerInitialization", + "test_json_rpc_endpoints.py::TestJSONRPCMethodHandling", + "-v", + "--tb=short", + ] + + return run_command(cmd, "Running quick development tests") + + +def run_smoke_tests(): + """Run smoke tests to verify basic functionality""" + cmd = [ + "python", + "-m", + "pytest", + "-m", + "not slow", + "-x", # Stop on first failure + "--tb=line", + "test_base_acp_server.py::TestBaseACPServerInitialization::test_base_acp_server_init", + "test_base_acp_server.py::TestHealthCheckEndpoint::test_health_check_endpoint", + "test_json_rpc_endpoints.py::TestJSONRPCMethodHandling::test_message_received_method_routing", + ] + + return run_command(cmd, "Running smoke tests") + + +def run_performance_tests(): + """Run performance-focused tests""" + cmd = [ + "python", + "-m", + "pytest", + "test_server_integration.py::TestServerPerformance", + "test_error_handling.py::TestServerErrorHandling::test_server_handles_concurrent_errors", + "-v", + "--tb=short", + ] + + return run_command(cmd, "Running performance tests") + + +if __name__ == "__main__": + # Check if specific test type is requested via environment + test_type = ( + sys.argv[1] if len(sys.argv) > 1 and sys.argv[1] in ["quick", "smoke", "perf"] else None + ) + + if test_type == "quick": + success = run_quick_tests() + elif test_type == "smoke": + success = run_smoke_tests() + elif test_type == "perf": + success = run_performance_tests() + else: + success = main() + + sys.exit(0 if success else 1) diff --git a/src/agentex/lib/sdk/fastacp/tests/test_base_acp_server.py b/src/agentex/lib/sdk/fastacp/tests/test_base_acp_server.py new file mode 100644 index 000000000..8a218187e --- /dev/null +++ b/src/agentex/lib/sdk/fastacp/tests/test_base_acp_server.py @@ -0,0 +1,450 @@ +# ruff: noqa: ARG001 +import asyncio +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +from agentex.protocol.acp import ( + RPCMethod, + SendEventParams, + CancelTaskParams, +) +from agentex.lib.sdk.fastacp.base.base_acp_server import BaseACPServer + + +class TestBaseACPServerInitialization: + """Test BaseACPServer initialization and setup""" + + def test_base_acp_server_init(self): + """Test BaseACPServer initialization sets up routes correctly""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + server = BaseACPServer() + + # Check that FastAPI routes are set up + routes = [route.path for route in server.routes] # type: ignore[attr-defined] + assert "/healthz" in routes + assert "/api" in routes + + # Check that handlers dict is initialized + assert hasattr(server, "_handlers") + assert isinstance(server._handlers, dict) + + def test_base_acp_server_create_classmethod(self): + """Test BaseACPServer.create() class method""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + server = BaseACPServer.create() + + assert isinstance(server, BaseACPServer) + assert hasattr(server, "_handlers") + + def test_lifespan_function_setup(self): + """Test that lifespan function is properly configured""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + server = BaseACPServer() + + # Check that lifespan is configured + assert server.router.lifespan_context is not None + + +class TestHealthCheckEndpoint: + """Test health check endpoint functionality""" + + def test_health_check_endpoint(self, base_acp_server): + """Test GET /healthz endpoint returns correct response""" + client = TestClient(base_acp_server) + + response = client.get("/healthz") + + assert response.status_code == 200 + assert response.json() == {"status": "healthy"} + + def test_health_check_content_type(self, base_acp_server): + """Test health check returns JSON content type""" + client = TestClient(base_acp_server) + + response = client.get("/healthz") + + assert response.headers["content-type"] == "application/json" + + +class TestJSONRPCEndpointCore: + """Test core JSON-RPC endpoint functionality""" + + def test_jsonrpc_endpoint_exists(self, base_acp_server): + """Test POST /api endpoint exists""" + client = TestClient(base_acp_server) + + # Send a basic request to check endpoint exists + response = client.post("/api", json={}) + + # Should not return 404 (endpoint exists) + assert response.status_code != 404 + + def test_jsonrpc_malformed_request(self, base_acp_server): + """Test JSON-RPC endpoint handles malformed requests""" + client = TestClient(base_acp_server) + + # Send malformed JSON + response = client.post("/api", json={"invalid": "request"}) + + assert response.status_code == 200 + data = response.json() + assert "error" in data + assert data["jsonrpc"] == "2.0" + + def test_jsonrpc_method_not_found(self, base_acp_server): + """Test JSON-RPC method not found error""" + client = TestClient(base_acp_server) + + request = { + "jsonrpc": "2.0", + "method": "nonexistent/method", + "params": {}, + "id": "test-1", + } + + response = client.post("/api", json=request) + + assert response.status_code == 200 + data = response.json() + assert "error" in data + assert data["error"]["code"] == -32601 # Method not found + assert data["id"] == "test-1" + + def test_jsonrpc_valid_request_structure(self, base_acp_server): + """Test JSON-RPC request parsing with valid structure""" + client = TestClient(base_acp_server) + + # Add a mock handler for testing + async def mock_handler(params): + return {"status": "success"} + + base_acp_server._handlers[RPCMethod.EVENT_SEND] = mock_handler + + request = { + "jsonrpc": "2.0", + "method": "event/send", + "params": { + "task": {"id": "test-task", "agent_id": "test-agent", "status": "RUNNING"}, + "message": { + "type": "text", + "author": "user", + "content": "test message", + }, + }, + "id": "test-1", + } + + response = client.post("/api", json=request) + + assert response.status_code == 200 + data = response.json() + assert data["jsonrpc"] == "2.0" + assert data["id"] == "test-1" + # Should return immediate acknowledgment + assert data["result"]["status"] == "processing" + + +class TestHandlerRegistration: + """Test handler registration and management""" + + def test_on_task_event_send_decorator(self): + """Test on_task_event_send decorator registration""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + server = BaseACPServer() + + @server.on_task_event_send + async def test_handler(params: SendEventParams): + return {"test": "response"} + + # Check handler is registered + assert RPCMethod.EVENT_SEND in server._handlers + assert server._handlers[RPCMethod.EVENT_SEND] is not None + + def test_cancel_task_decorator(self): + """Test cancel_task decorator registration""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + server = BaseACPServer() + + @server.on_task_cancel + async def test_handler(params: CancelTaskParams): + return {"test": "response"} + + # Check handler is registered + assert RPCMethod.TASK_CANCEL in server._handlers + assert server._handlers[RPCMethod.TASK_CANCEL] is not None + + @pytest.mark.asyncio + async def test_handler_wrapper_functionality(self): + """Test that handler wrapper works correctly""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + server = BaseACPServer() + + # Create a test handler + async def test_handler(params): + return {"handler_called": True, "params_received": True} + + # Wrap the handler + wrapped = server._wrap_handler(test_handler) + + # Test the wrapped handler + result = await wrapped({"test": "params"}) + assert result["handler_called"] is True + assert result["params_received"] is True + + +class TestBackgroundProcessing: + """Test background processing functionality""" + + @pytest.mark.asyncio + async def test_notification_processing(self, async_base_acp_server): + """Test notification processing (requests with no ID)""" + # Add a mock handler + handler_called = False + received_params = None + + async def mock_handler(params): + nonlocal handler_called, received_params + handler_called = True + received_params = params + return {"status": "processed"} + + async_base_acp_server._handlers[RPCMethod.EVENT_SEND] = mock_handler + + client = TestClient(async_base_acp_server) + + request = { + "jsonrpc": "2.0", + "method": "event/send", + "params": { + "task": {"id": "test-task", "agent_id": "test-agent", "status": "RUNNING"}, + "message": { + "type": "text", + "author": "user", + "content": "test message", + }, + }, + # No ID = notification + } + + response = client.post("/api", json=request) + + assert response.status_code == 200 + data = response.json() + assert data["id"] is None # Notification response + + # Give background task time to execute + await asyncio.sleep(0.1) + + # Handler should have been called + assert handler_called is True + assert received_params is not None + + @pytest.mark.asyncio + async def test_request_processing_with_id(self, async_base_acp_server): + """Test request processing with ID returns immediate acknowledgment""" + + # Add a mock handler + async def mock_handler(params): + return {"status": "processed"} + + async_base_acp_server._handlers[RPCMethod.TASK_CANCEL] = mock_handler + + client = TestClient(async_base_acp_server) + + request = { + "jsonrpc": "2.0", + "method": "task/cancel", + "params": {"task_id": "test-task-123"}, + "id": "test-request-1", + } + + response = client.post("/api", json=request) + + assert response.status_code == 200 + data = response.json() + assert data["jsonrpc"] == "2.0" + assert data["id"] == "test-request-1" + assert data["result"]["status"] == "processing" # Immediate acknowledgment + + +class TestSynchronousRPCMethods: + """Test synchronous RPC methods that return results immediately""" + + def test_send_message_synchronous_response(self, base_acp_server): + """Test that MESSAGE_SEND method returns handler result synchronously""" + client = TestClient(base_acp_server) + + # Add a mock handler that returns a specific result + async def mock_execute_handler(params): + return { + "task_id": params.task.id, + "message_content": params.message.content, + "status": "executed_synchronously", + "custom_data": {"processed": True, "timestamp": "2024-01-01T12:00:00Z"}, + } + + base_acp_server._handlers[RPCMethod.MESSAGE_SEND] = mock_execute_handler + + request = { + "jsonrpc": "2.0", + "method": "message/send", + "params": { + "task": {"id": "test-task-123", "agent_id": "test-agent", "status": "RUNNING"}, + "message": { + "type": "text", + "author": "user", + "content": "Execute this task please", + }, + }, + "id": "test-execute-1", + } + + response = client.post("/api", json=request) + + assert response.status_code == 200 + data = response.json() + + # Verify JSON-RPC structure + assert data["jsonrpc"] == "2.0" + assert data["id"] == "test-execute-1" + assert "result" in data + assert data.get("error") is None + + # Verify the handler's result is returned directly (not "processing" status) + result = data["result"] + assert result["task_id"] == "test-task-123" + assert result["message_content"] == "Execute this task please" + assert result["status"] == "executed_synchronously" + assert result["custom_data"]["processed"] is True + assert result["custom_data"]["timestamp"] == "2024-01-01T12:00:00Z" + + # Verify it's NOT the async "processing" response + assert result.get("status") != "processing" + + def test_create_task_async_response(self, base_acp_server): + """Test that TASK_CREATE method returns processing status (async behavior)""" + client = TestClient(base_acp_server) + + # Add a mock handler for init task + async def mock_init_handler(params): + return { + "task_id": params.task.id, + "status": "initialized", + } + + base_acp_server._handlers[RPCMethod.TASK_CREATE] = mock_init_handler + + request = { + "jsonrpc": "2.0", + "method": "task/create", + "params": { + "task": {"id": "test-task-456", "agent_id": "test-agent", "status": "RUNNING"} + }, + "id": "test-init-1", + } + + response = client.post("/api", json=request) + + assert response.status_code == 200 + data = response.json() + + # Verify JSON-RPC structure + assert data["jsonrpc"] == "2.0" + assert data["id"] == "test-init-1" + assert "result" in data + assert data.get("error") is None + + # Verify it returns async "processing" status (not the handler's result) + result = data["result"] + assert result["status"] == "processing" + + # Verify it's NOT the handler's actual result + assert result.get("status") != "initialized" + + +class TestErrorHandling: + """Test error handling scenarios""" + + def test_invalid_json_request(self, base_acp_server): + """Test handling of invalid JSON in request body""" + client = TestClient(base_acp_server) + + # Send invalid JSON + response = client.post( + "/api", content="invalid json", headers={"Content-Type": "application/json"} + ) + + assert response.status_code == 200 + data = response.json() + assert "error" in data + assert data["jsonrpc"] == "2.0" + + def test_missing_required_fields(self, base_acp_server): + """Test handling of requests missing required JSON-RPC fields""" + client = TestClient(base_acp_server) + + # Missing method field + request = {"jsonrpc": "2.0", "params": {}, "id": "test-1"} + + response = client.post("/api", json=request) + + assert response.status_code == 200 + data = response.json() + assert "error" in data + + def test_invalid_method_enum(self, base_acp_server): + """Test handling of invalid method names""" + client = TestClient(base_acp_server) + + request = { + "jsonrpc": "2.0", + "method": "invalid/method/name", + "params": {}, + "id": "test-1", + } + + response = client.post("/api", json=request) + + assert response.status_code == 200 + data = response.json() + assert "error" in data + assert data["error"]["code"] == -32601 # Method not found + + @pytest.mark.asyncio + async def test_handler_exception_handling(self, async_base_acp_server): + """Test that handler exceptions are properly handled""" + + # Add a handler that raises an exception + async def failing_handler(params): + raise ValueError("Test exception") + + async_base_acp_server._handlers[RPCMethod.EVENT_SEND] = failing_handler + + client = TestClient(async_base_acp_server) + + request = { + "jsonrpc": "2.0", + "method": "event/send", + "params": { + "task": {"id": "test-task", "agent_id": "test-agent", "status": "RUNNING"}, + "message": { + "type": "text", + "author": "user", + "content": "test message", + }, + }, + "id": "test-1", + } + + response = client.post("/api", json=request) + + # Should still return immediate acknowledgment + assert response.status_code == 200 + data = response.json() + assert data["result"]["status"] == "processing" + + # Give background task time to fail + await asyncio.sleep(0.1) + # Exception should be logged but not crash the server diff --git a/src/agentex/lib/sdk/fastacp/tests/test_fastacp_factory.py b/src/agentex/lib/sdk/fastacp/tests/test_fastacp_factory.py new file mode 100644 index 000000000..8c62efa03 --- /dev/null +++ b/src/agentex/lib/sdk/fastacp/tests/test_fastacp_factory.py @@ -0,0 +1,371 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from agentex.lib.types.fastacp import ( + SyncACPConfig, + AsyncACPConfig, + TemporalACPConfig, + AsyncBaseACPConfig, +) +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.lib.sdk.fastacp.impl.sync_acp import SyncACP +from agentex.lib.sdk.fastacp.impl.temporal_acp import TemporalACP +from agentex.lib.sdk.fastacp.impl.async_base_acp import AsyncBaseACP +from agentex.lib.sdk.fastacp.base.base_acp_server import BaseACPServer + + +class TestFastACPInitialization: + """Test FastACP basic functionality""" + + def test_factory_class_exists(self): + """Test that FastACP class exists and is properly structured""" + assert hasattr(FastACP, "create") + assert hasattr(FastACP, "create_sync_acp") + assert hasattr(FastACP, "create_async_acp") + + +class TestSyncACPCreation: + """Test SyncACP creation through factory""" + + @pytest.mark.asyncio + async def test_create_sync_acp_direct_method(self): + """Test creating SyncACP using direct method""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + sync_acp = FastACP.create_sync_acp() + + assert isinstance(sync_acp, SyncACP) + assert isinstance(sync_acp, BaseACPServer) + assert hasattr(sync_acp, "_handlers") + + @pytest.mark.asyncio + async def test_create_sync_acp_with_config(self): + """Test creating SyncACP with configuration""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + config = SyncACPConfig() + sync_acp = FastACP.create_sync_acp(config=config) + + assert isinstance(sync_acp, SyncACP) + + @pytest.mark.asyncio + async def test_create_sync_acp_via_generic_create(self): + """Test creating SyncACP via generic create method""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + sync_acp = FastACP.create("sync") + + assert isinstance(sync_acp, SyncACP) + + @pytest.mark.asyncio + async def test_create_sync_acp_via_generic_create_with_config(self): + """Test creating SyncACP via generic create method with config""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + config = SyncACPConfig() + sync_acp = FastACP.create("sync", config=config) + + assert isinstance(sync_acp, SyncACP) + + @pytest.mark.asyncio + async def test_create_sync_acp_with_enum(self): + """Test creating SyncACP using ACPType enum""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + sync_acp = FastACP.create("sync") + + assert isinstance(sync_acp, SyncACP) + + @pytest.mark.asyncio + async def test_create_sync_acp_with_kwargs(self): + """Test creating SyncACP with additional kwargs""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + sync_acp = FastACP.create_sync_acp(custom_param="test_value") + + assert isinstance(sync_acp, SyncACP) + + +class TestAsyncBaseACPCreation: + """Test AsyncBaseACP creation through factory""" + + @pytest.mark.asyncio + async def test_create_async_base_acp_direct_method(self): + """Test creating AsyncBaseACP using direct method""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + config = AsyncACPConfig(type="base") + async_acp = FastACP.create_async_acp(config=config) + + assert isinstance(async_acp, AsyncBaseACP) + assert isinstance(async_acp, BaseACPServer) + + @pytest.mark.asyncio + async def test_create_async_base_acp_with_specific_config(self): + """Test creating AsyncBaseACP with AsyncBaseACPConfig""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + config = AsyncBaseACPConfig(type="base") + async_acp = FastACP.create_async_acp(config=config) + + assert isinstance(async_acp, AsyncBaseACP) + + @pytest.mark.asyncio + async def test_create_async_base_acp_via_generic_create(self): + """Test creating AsyncBaseACP via generic create method""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + config = AsyncACPConfig(type="base") + async_acp = FastACP.create("async", config=config) + + assert isinstance(async_acp, AsyncBaseACP) + + @pytest.mark.asyncio + async def test_create_async_base_acp_with_enum(self): + """Test creating AsyncBaseACP using ACPType enum""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + config = AsyncACPConfig(type="base") + async_acp = FastACP.create("async", config=config) + + assert isinstance(async_acp, AsyncBaseACP) + + +class TestAsyncTemporalACPCreation: + """Test AsyncTemporalACP (TemporalACP) creation through factory""" + + @pytest.mark.asyncio + async def test_create_temporal_acp_direct_method(self): + """Test creating TemporalACP using direct method""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + config = AsyncACPConfig(type="temporal") + + # Mock the TemporalACP.create method since it requires temporal dependencies + with patch.object(TemporalACP, "create", new_callable=AsyncMock) as mock_create: + mock_temporal_instance = MagicMock(spec=TemporalACP) + mock_create.return_value = mock_temporal_instance + + temporal_acp = FastACP.create_async_acp(config=config) + + assert temporal_acp == mock_temporal_instance + mock_create.assert_called_once() + + @pytest.mark.asyncio + async def test_create_temporal_acp_with_temporal_config(self): + """Test creating TemporalACP with TemporalACPConfig""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + config = TemporalACPConfig(type="temporal", temporal_address="localhost:7233") + + with patch.object(TemporalACP, "create", new_callable=AsyncMock) as mock_create: + mock_temporal_instance = MagicMock(spec=TemporalACP) + mock_create.return_value = mock_temporal_instance + + temporal_acp = FastACP.create_async_acp(config=config) + + assert temporal_acp == mock_temporal_instance + # Verify temporal_address was passed + mock_create.assert_called_once_with(temporal_address="localhost:7233") + + @pytest.mark.asyncio + async def test_create_temporal_acp_via_generic_create(self): + """Test creating TemporalACP via generic create method""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + config = AsyncACPConfig(type="temporal") + + with patch.object(TemporalACP, "create", new_callable=AsyncMock) as mock_create: + mock_temporal_instance = MagicMock(spec=TemporalACP) + mock_create.return_value = mock_temporal_instance + + temporal_acp = FastACP.create("async", config=config) + + assert temporal_acp == mock_temporal_instance + + @pytest.mark.asyncio + async def test_create_temporal_acp_with_custom_address(self): + """Test creating TemporalACP with custom temporal address""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + config = TemporalACPConfig(type="temporal", temporal_address="custom-temporal:9999") + + with patch.object(TemporalACP, "create", new_callable=AsyncMock) as mock_create: + mock_temporal_instance = MagicMock(spec=TemporalACP) + mock_create.return_value = mock_temporal_instance + + FastACP.create_async_acp(config=config) + + mock_create.assert_called_once_with(temporal_address="custom-temporal:9999") + + +class TestConfigurationValidation: + """Test configuration validation and error handling""" + + @pytest.mark.asyncio + async def test_async_requires_config(self): + """Test that async ACP creation requires configuration""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + with pytest.raises(ValueError, match="AsyncACPConfig is required"): + FastACP.create("async") + + @pytest.mark.asyncio + async def test_async_requires_correct_config_type(self): + """Test that async ACP creation requires AsyncACPConfig type""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + sync_config = SyncACPConfig() + + with pytest.raises(ValueError, match="AsyncACPConfig is required"): + FastACP.create("async", config=sync_config) + + @pytest.mark.asyncio + async def test_async_direct_method_requires_config(self): + """Test that direct async method requires configuration""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + # This should raise TypeError since config is required parameter + with pytest.raises(TypeError): + FastACP.create_async_acp() # type: ignore[call-arg] + + def test_invalid_acp_type_string(self): + """Test that invalid ACP type string raises ValueError""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + with pytest.raises(ValueError): + asyncio.run(FastACP.create("invalid_type")) + + def test_invalid_async_type_in_config(self): + """Test that invalid async type in config raises ValueError""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + # This should raise ValueError during config creation + with pytest.raises(ValueError): + AsyncACPConfig(type="invalid_async_type") + + @pytest.mark.asyncio + async def test_unsupported_acp_type_enum(self): + """Test handling of unsupported ACP type enum values""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + # Create a mock enum value that's not supported + with patch("agentex.sdk.fastacp.fastacp.ACPType") as mock_enum: + mock_enum.SYNC = "sync" + mock_enum.ASYNC = "async" + mock_enum.AGENTIC = "agentic" + unsupported_type = "unsupported" + + with pytest.raises(ValueError, match="Unsupported ACP type"): + FastACP.create(unsupported_type) + + +class TestErrorHandling: + """Test error handling scenarios""" + + @pytest.mark.asyncio + async def test_sync_acp_creation_failure(self): + """Test handling of SyncACP creation failure""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + with patch.object(SyncACP, "create", side_effect=Exception("Creation failed")): + with pytest.raises(Exception, match="Creation failed"): + FastACP.create_sync_acp() + + @pytest.mark.asyncio + async def test_async_acp_creation_failure(self): + """Test handling of AsyncACP creation failure""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + config = AsyncACPConfig(type="base") + + with patch.object(AsyncBaseACP, "create", side_effect=Exception("Creation failed")): + with pytest.raises(Exception, match="Creation failed"): + FastACP.create_async_acp(config=config) + + @pytest.mark.asyncio + async def test_temporal_acp_creation_failure(self): + """Test handling of TemporalACP creation failure""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + config = AsyncACPConfig(type="temporal") + + with patch.object( + TemporalACP, "create", side_effect=Exception("Temporal connection failed") + ): + with pytest.raises(Exception, match="Temporal connection failed"): + FastACP.create_async_acp(config=config) + + +class TestIntegrationScenarios: + """Test integration scenarios and real-world usage patterns""" + + @pytest.mark.asyncio + async def test_create_all_acp_types(self): + """Test creating all supported ACP types""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + # Create SyncACP + sync_acp = FastACP.create("sync") + assert isinstance(sync_acp, SyncACP) + + # Create AsyncBaseACP + base_config = AsyncACPConfig(type="base") + async_base = FastACP.create("async", config=base_config) + assert isinstance(async_base, AsyncBaseACP) + + # Create TemporalACP (mocked) + temporal_config = AsyncACPConfig(type="temporal") + with patch.object(TemporalACP, "create", new_callable=AsyncMock) as mock_create: + mock_temporal_instance = MagicMock(spec=TemporalACP) + mock_create.return_value = mock_temporal_instance + + temporal_acp = FastACP.create("async", config=temporal_config) + assert temporal_acp == mock_temporal_instance + + @pytest.mark.asyncio + async def test_async_type_backwards_compatibility(self): + """Test that 'async' type works the same as 'async' for backwards compatibility""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + # Test async with base config + base_config = AsyncACPConfig(type="base") + async_base = FastACP.create("async", config=base_config) + assert isinstance(async_base, AsyncBaseACP) + + # Test async with temporal config (mocked) + temporal_config = AsyncACPConfig(type="temporal") + with patch.object(TemporalACP, "create", new_callable=AsyncMock) as mock_create: + mock_temporal_instance = MagicMock(spec=TemporalACP) + mock_create.return_value = mock_temporal_instance + + temporal_acp = FastACP.create("async", config=temporal_config) + assert temporal_acp == mock_temporal_instance + + # Test that async requires config + with pytest.raises(ValueError, match="AsyncACPConfig is required"): + sync_config = SyncACPConfig() + FastACP.create("async", config=sync_config) + + @pytest.mark.asyncio + async def test_configuration_driven_creation(self): + """Test configuration-driven ACP creation""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + configs = [ + ("sync", None), + ("async", AsyncACPConfig(type="base")), + ("async", AsyncACPConfig(type="base")), + ("async", TemporalACPConfig(type="temporal", temporal_address="localhost:7233")), + ("async", TemporalACPConfig(type="temporal", temporal_address="localhost:7233")), + ] + + created_acps = [] + + for acp_type, config in configs: + if acp_type in ("async", "async") and config and config.type == "temporal": + # Mock temporal creation + with patch.object(TemporalACP, "create", new_callable=AsyncMock) as mock_create: + mock_temporal_instance = MagicMock(spec=TemporalACP) + mock_create.return_value = mock_temporal_instance + + acp = FastACP.create(acp_type, config=config) + created_acps.append(acp) + else: + acp = FastACP.create(acp_type, config=config) + created_acps.append(acp) + + assert len(created_acps) == 5 + assert isinstance(created_acps[0], SyncACP) + assert isinstance(created_acps[1], AsyncBaseACP) + assert isinstance(created_acps[2], AsyncBaseACP) + # Fourth and fifth ones are mocked TemporalACP + + @pytest.mark.asyncio + async def test_factory_with_custom_kwargs(self): + """Test factory methods with custom keyword arguments""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + # Test sync with kwargs + sync_acp = FastACP.create_sync_acp(custom_param="test") + assert isinstance(sync_acp, SyncACP) + + # Test async base with kwargs + config = AsyncACPConfig(type="base") + async_acp = FastACP.create_async_acp(config=config, custom_param="test") + assert isinstance(async_acp, AsyncBaseACP) diff --git a/src/agentex/lib/sdk/fastacp/tests/test_integration.py b/src/agentex/lib/sdk/fastacp/tests/test_integration.py new file mode 100644 index 000000000..c72d336e3 --- /dev/null +++ b/src/agentex/lib/sdk/fastacp/tests/test_integration.py @@ -0,0 +1,478 @@ +# ruff: noqa: ARG001 +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from agentex.protocol.acp import ( + RPCMethod, + SendEventParams, + CancelTaskParams, + CreateTaskParams, +) +from agentex.lib.sdk.fastacp.impl.sync_acp import SyncACP +from agentex.lib.sdk.fastacp.impl.temporal_acp import TemporalACP +from agentex.lib.sdk.fastacp.impl.async_base_acp import AsyncBaseACP + + +class TestImplementationBehavior: + """Test specific behavior differences between ACP implementations""" + + @pytest.mark.asyncio() + async def test_sync_acp_default_handlers(self): + """Test SyncACP has expected default handlers""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + sync_acp = SyncACP.create() + + # Should have send_message_message handler by default + assert RPCMethod.MESSAGE_SEND in sync_acp._handlers + + @pytest.mark.asyncio() + async def test_async_acp_default_handlers(self): + """Test AsyncBaseACP has expected default handlers""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + async_acp = AsyncBaseACP.create() + + # Should have create, message, and cancel handlers by default + assert RPCMethod.TASK_CREATE in async_acp._handlers + assert RPCMethod.EVENT_SEND in async_acp._handlers + assert RPCMethod.TASK_CANCEL in async_acp._handlers + + @pytest.mark.asyncio() + async def test_temporal_acp_creation_with_mocked_client(self): + """Test TemporalACP creation with mocked temporal client""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + with patch.object(TemporalACP, "create", new_callable=AsyncMock) as mock_create: + mock_temporal_instance = MagicMock(spec=TemporalACP) + mock_temporal_instance._handlers = {} + mock_temporal_instance.temporal_client = MagicMock() + mock_create.return_value = mock_temporal_instance + + temporal_acp = TemporalACP.create(temporal_address="localhost:7233") + + assert temporal_acp == mock_temporal_instance + assert hasattr(temporal_acp, "temporal_client") + + +class TestRealWorldScenarios: + """Test real-world usage scenarios and integration""" + + @pytest.mark.asyncio() + async def test_message_handling_workflow(self, sync_acp, free_port, test_server_runner): + """Test complete message handling workflow""" + messages_received = [] + + @sync_acp.on_task_event_send + async def message_handler(params: SendEventParams): + messages_received.append( + { + "task_id": params.task.id, + "message_content": params.message.content, # type: ignore[attr-defined] + "author": params.message.author, # type: ignore[attr-defined] + } + ) + return {"processed": True} + + runner = test_server_runner(sync_acp, free_port) + await runner.start() + + # Send multiple messages + async with httpx.AsyncClient() as client: + for i in range(3): + request_data = { + "jsonrpc": "2.0", + "method": "event/send", + "params": { + "task": { + "id": f"workflow-task-{i}", + "agent_id": "workflow-agent", + "status": "RUNNING", + }, + "message": { + "type": "text", + "author": "user", + "content": f"Workflow message {i}", + }, + }, + "id": f"workflow-{i}", + } + + response = await client.post(f"http://127.0.0.1:{free_port}/api", json=request_data) + assert response.status_code == 200 + + # Give background tasks time to process + await asyncio.sleep(0.2) + + # Verify all messages were processed + assert len(messages_received) == 3 + for i, msg in enumerate(messages_received): + assert msg["task_id"] == f"workflow-task-{i}" + assert msg["message_content"] == f"Workflow message {i}" + assert msg["author"] == "user" + + await runner.stop() + + @pytest.mark.asyncio() + async def test_task_lifecycle_management(self, async_base_acp, free_port, test_server_runner): + """Test complete task lifecycle: create -> message -> cancel""" + task_events = [] + + @async_base_acp.on_task_create + async def create_handler(params: CreateTaskParams): + task_events.append(("created", params.task.id)) + + @async_base_acp.on_task_event_send + async def message_handler(params: SendEventParams): + task_events.append(("message", params.task.id)) + + @async_base_acp.on_task_cancel + async def cancel_handler(params: CancelTaskParams): + task_events.append(("cancelled", params.task_id)) # type: ignore[attr-defined] + + runner = test_server_runner(async_base_acp, free_port) + await runner.start() + + async with httpx.AsyncClient() as client: + # Create task + create_request = { + "jsonrpc": "2.0", + "method": "task/create", + "params": { + "task": { + "id": "lifecycle-task", + "agent_id": "lifecycle-agent", + "status": "RUNNING", + } + }, + "id": "create-1", + } + + response = await client.post(f"http://127.0.0.1:{free_port}/api", json=create_request) + assert response.status_code == 200 + + # Send message + message_request = { + "jsonrpc": "2.0", + "method": "event/send", + "params": { + "task": { + "id": "lifecycle-task", + "agent_id": "lifecycle-agent", + "status": "RUNNING", + }, + "message": { + "type": "text", + "author": "user", + "content": "Lifecycle test message", + }, + }, + "id": "message-1", + } + + response = await client.post(f"http://127.0.0.1:{free_port}/api", json=message_request) + assert response.status_code == 200 + + # Cancel task + cancel_request = { + "jsonrpc": "2.0", + "method": "task/cancel", + "params": {"task_id": "lifecycle-task"}, + "id": "cancel-1", + } + + response = await client.post(f"http://127.0.0.1:{free_port}/api", json=cancel_request) + assert response.status_code == 200 + + # Give background tasks time to process + await asyncio.sleep(0.2) + + # Verify task lifecycle events + assert len(task_events) == 3 + assert task_events[0] == ("created", "lifecycle-task") + assert task_events[1] == ("message", "lifecycle-task") + assert task_events[2] == ("cancelled", "lifecycle-task") + + await runner.stop() + + +class TestErrorRecovery: + """Test error handling and recovery scenarios""" + + @pytest.mark.asyncio() + async def test_server_resilience_to_handler_failures( + self, sync_acp, free_port, test_server_runner + ): + """Test server continues working after handler failures""" + failure_count = 0 + success_count = 0 + + @sync_acp.on_task_event_send + async def unreliable_handler(params: SendEventParams): + nonlocal failure_count, success_count + if "fail" in params.message.content: # type: ignore[attr-defined] + failure_count += 1 + raise RuntimeError("Simulated handler failure") + else: + success_count += 1 + return {"success": True} + + runner = test_server_runner(sync_acp, free_port) + await runner.start() + + async with httpx.AsyncClient() as client: + # Send failing request + fail_request = { + "jsonrpc": "2.0", + "method": "event/send", + "params": { + "task": {"id": "fail-task", "agent_id": "test-agent", "status": "RUNNING"}, + "message": {"type": "text", "author": "user", "content": "This should fail"}, + }, + "id": "fail-1", + } + + response = await client.post(f"http://127.0.0.1:{free_port}/api", json=fail_request) + assert response.status_code == 200 # Server should still respond + + # Send successful request after failure + success_request = { + "jsonrpc": "2.0", + "method": "event/send", + "params": { + "task": {"id": "success-task", "agent_id": "test-agent", "status": "RUNNING"}, + "message": {"type": "text", "author": "user", "content": "This should succeed"}, + }, + "id": "success-1", + } + + response = await client.post(f"http://127.0.0.1:{free_port}/api", json=success_request) + assert response.status_code == 200 + + # Verify server is still healthy + health_response = await client.get(f"http://127.0.0.1:{free_port}/healthz") + assert health_response.status_code == 200 + + # Give background tasks time to process + await asyncio.sleep(0.2) + + assert failure_count == 1 + assert success_count == 1 + + await runner.stop() + + @pytest.mark.asyncio() + async def test_concurrent_request_handling(self, sync_acp, free_port, test_server_runner): + """Test handling multiple concurrent requests""" + processed_requests = [] + + @sync_acp.on_task_event_send + async def concurrent_handler(params: SendEventParams): + # Simulate some processing time + await asyncio.sleep(0.05) + processed_requests.append(params.task.id) + return {"processed": params.task.id} + + runner = test_server_runner(sync_acp, free_port) + await runner.start() + + # Send multiple concurrent requests + async def send_request(client, task_id): + request_data = { + "jsonrpc": "2.0", + "method": "event/send", + "params": { + "task": {"id": task_id, "agent_id": "concurrent-agent", "status": "RUNNING"}, + "message": { + "type": "text", + "author": "user", + "content": f"Concurrent message for {task_id}", + }, + }, + "id": f"concurrent-{task_id}", + } + + return await client.post(f"http://127.0.0.1:{free_port}/api", json=request_data) + + async with httpx.AsyncClient() as client: + # Send 5 concurrent requests + tasks = [send_request(client, f"task-{i}") for i in range(5)] + responses = await asyncio.gather(*tasks) + + # All should return immediate acknowledgment + for response in responses: + assert response.status_code == 200 + data = response.json() + assert data["result"]["status"] == "processing" + + # Give background tasks time to complete + await asyncio.sleep(0.3) + + # All requests should have been processed + assert len(processed_requests) == 5 + assert set(processed_requests) == {f"task-{i}" for i in range(5)} + + await runner.stop() + + +class TestSpecialCases: + """Test edge cases and special scenarios""" + + @pytest.mark.asyncio() + async def test_notification_vs_request_behavior(self, sync_acp, free_port, test_server_runner): + """Test difference between notifications (no ID) and requests (with ID)""" + notifications_received = 0 + requests_received = 0 + + @sync_acp.on_task_event_send + async def tracking_handler(params: SendEventParams): + nonlocal notifications_received, requests_received + if "notification" in params.message.content: # type: ignore[attr-defined] + notifications_received += 1 + else: + requests_received += 1 + return {"handled": True} + + runner = test_server_runner(sync_acp, free_port) + await runner.start() + + async with httpx.AsyncClient() as client: + # Send notification (no ID) + notification_data = { + "jsonrpc": "2.0", + "method": "event/send", + "params": { + "task": { + "id": "notification-task", + "agent_id": "test-agent", + "status": "RUNNING", + }, + "message": { + "type": "text", + "author": "user", + "content": "This is a notification", + }, + }, + # Note: no "id" field + } + + notification_response = await client.post( + f"http://127.0.0.1:{free_port}/api", json=notification_data + ) + assert notification_response.status_code == 200 + notification_result = notification_response.json() + assert notification_result["id"] is None + + # Send regular request (with ID) + request_data = { + "jsonrpc": "2.0", + "method": "event/send", + "params": { + "task": {"id": "request-task", "agent_id": "test-agent", "status": "RUNNING"}, + "message": {"type": "text", "author": "user", "content": "This is a request"}, + }, + "id": "request-1", + } + + request_response = await client.post( + f"http://127.0.0.1:{free_port}/api", json=request_data + ) + assert request_response.status_code == 200 + request_result = request_response.json() + assert request_result["id"] == "request-1" + assert request_result["result"]["status"] == "processing" + + # Give background tasks time to process + await asyncio.sleep(0.1) + + assert notifications_received == 1 + assert requests_received == 1 + + await runner.stop() + + @pytest.mark.asyncio() + async def test_unicode_message_handling(self, sync_acp, free_port, test_server_runner): + """Test handling of unicode characters in messages""" + received_message = None + + @sync_acp.on_task_event_send + async def unicode_handler(params: SendEventParams): + nonlocal received_message + received_message = params.message.content # type: ignore[attr-defined] + return {"unicode_handled": True} + + runner = test_server_runner(sync_acp, free_port) + await runner.start() + + unicode_text = "Hello 世界 🌍 émojis 🚀 and special chars: \n\t\r" + + async with httpx.AsyncClient() as client: + request_data = { + "jsonrpc": "2.0", + "method": "event/send", + "params": { + "task": { + "id": "unicode-task", + "agent_id": "unicode-agent", + "status": "RUNNING", + }, + "message": {"type": "text", "author": "user", "content": unicode_text}, + }, + "id": "unicode-test", + } + + response = await client.post(f"http://127.0.0.1:{free_port}/api", json=request_data) + + assert response.status_code == 200 + + # Give background task time to process + await asyncio.sleep(0.1) + + assert received_message == unicode_text + + await runner.stop() + + +class TestImplementationIsolation: + """Test that different implementations don't interfere with each other""" + + @pytest.mark.asyncio() + async def test_handler_isolation_between_implementations(self): + """Test handlers registered on one implementation don't affect others""" + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + sync_acp = SyncACP.create() + async_acp = AsyncBaseACP.create() + + sync_handled = False + async_handled = False + + @sync_acp.on_task_event_send + async def sync_handler(params: SendEventParams): + nonlocal sync_handled + sync_handled = True + return {"sync": True} + + @async_acp.on_task_event_send + async def async_handler(params: SendEventParams): + nonlocal async_handled + async_handled = True + return {"async": True} + + # Create test parameters + message_params = SendEventParams( # type: ignore[call-arg] + task={"id": "isolation-test-task", "agent_id": "test-agent", "status": "RUNNING"}, + event={"type": "text", "author": "user", "content": "Isolation test"}, # type: ignore[misc] + ) + + # Execute sync handler + sync_result = await sync_acp._handlers[RPCMethod.EVENT_SEND](message_params) + assert sync_handled is True + assert async_handled is False + assert sync_result == {"sync": True} + + # Reset and execute async handler + sync_handled = False + async_result = await async_acp._handlers[RPCMethod.EVENT_SEND](message_params) + assert sync_handled is False + assert async_handled is True + assert async_result == {"async": True} diff --git a/src/agentex/lib/sdk/state_machine/__init__.py b/src/agentex/lib/sdk/state_machine/__init__.py new file mode 100644 index 000000000..6013d28f6 --- /dev/null +++ b/src/agentex/lib/sdk/state_machine/__init__.py @@ -0,0 +1,16 @@ +from agentex.lib.types.agent_card import AgentCard, AgentLifecycle, LifecycleState + +from .state import State +from .noop_workflow import NoOpWorkflow +from .state_machine import StateMachine +from .state_workflow import StateWorkflow + +__all__ = [ + "StateMachine", + "StateWorkflow", + "State", + "NoOpWorkflow", + "AgentCard", + "AgentLifecycle", + "LifecycleState", +] diff --git a/src/agentex/lib/sdk/state_machine/noop_workflow.py b/src/agentex/lib/sdk/state_machine/noop_workflow.py new file mode 100644 index 000000000..a7c54cfb9 --- /dev/null +++ b/src/agentex/lib/sdk/state_machine/noop_workflow.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, override + +from pydantic import BaseModel + +from agentex.lib.utils.logging import make_logger +from agentex.lib.sdk.state_machine.state_workflow import StateWorkflow + +if TYPE_CHECKING: + from agentex.lib.sdk.state_machine import StateMachine + +logger = make_logger(__name__) + + +class NoOpWorkflow(StateWorkflow): + """ + Workflow that does nothing. This is commonly used as a terminal state. + """ + + @override + async def execute( + self, state_machine: "StateMachine", state_machine_data: BaseModel | None = None + ) -> str: + return state_machine.get_current_state() # Stay in current state diff --git a/src/agentex/lib/sdk/state_machine/state.py b/src/agentex/lib/sdk/state_machine/state.py new file mode 100644 index 000000000..6ddddc0c0 --- /dev/null +++ b/src/agentex/lib/sdk/state_machine/state.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel, ConfigDict + +from agentex.lib.sdk.state_machine.state_workflow import StateWorkflow + + +class State(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + name: str + workflow: StateWorkflow diff --git a/src/agentex/lib/sdk/state_machine/state_machine.py b/src/agentex/lib/sdk/state_machine/state_machine.py new file mode 100644 index 000000000..5679a6bd8 --- /dev/null +++ b/src/agentex/lib/sdk/state_machine/state_machine.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from enum import Enum +from typing import Any, Generic, TypeVar + +from agentex.lib import adk +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.sdk.state_machine.state import State +from agentex.lib.sdk.state_machine.state_workflow import StateWorkflow + +T = TypeVar("T", bound=BaseModel) + + +class StateMachine(ABC, Generic[T]): + def __init__( + self, + initial_state: str, + states: list[State], + task_id: str | None = None, + state_machine_data: T | None = None, + trace_transitions: bool = False, + ): + self._task_id = task_id + self._state_map: dict[str, State] = {state.name: state for state in states} + self.state_machine_data = state_machine_data + self._initial_state = initial_state + self._trace_transitions = trace_transitions + + # Validate that initial state exists + if initial_state not in self._state_map: + raise ValueError(f"Initial state '{initial_state}' not found in states") + self._current_state = self._state_map[initial_state] + + def set_task_id(self, task_id: str): + self._task_id = task_id + + def get_current_state(self) -> str: + return self._current_state.name + + def get_current_workflow(self) -> StateWorkflow: + """ + Get the workflow of the current state. + + Returns: + The workflow of the current state + + Raises: + ValueError: If the current state is not found in the state map + """ + current_state = self._state_map.get(self.get_current_state()) + if not current_state: + raise ValueError(f"State {self.get_current_state()} not found") + return current_state.workflow + + async def transition(self, target_state_name: str): + if not self._state_map.get(target_state_name): + raise ValueError(f"State {target_state_name} not found") + self._current_state = self._state_map[target_state_name] + + def get_state_machine_data(self) -> T | None: + return self.state_machine_data + + def require_state_machine_data(self) -> T: + """Get state machine data, raising an error if not set.""" + if self.state_machine_data is None: + raise ValueError("State machine data not initialized - ensure data is provided") + return self.state_machine_data + + @abstractmethod + async def terminal_condition(self) -> bool: + pass + + # Overwrite this if you want to add more logic to the state machine + async def run(self): + while not await self.terminal_condition(): + await self.step() + + async def step(self) -> str: + current_state_name = self.get_current_state() + current_state = self._state_map.get(current_state_name) + if current_state is None: + raise ValueError(f"Current state '{current_state_name}' not found in state map") + + span = None + if self._trace_transitions: + if self._task_id is None: + raise ValueError( + "Task ID is must be set before tracing can be enabled" + ) + span = await adk.tracing.start_span( + trace_id=self._task_id, + name="state_transition", + input=self.require_state_machine_data().model_dump(), + data={"input_state": current_state_name}, + ) + + next_state_name = await current_state.workflow.execute( + state_machine=self, state_machine_data=self.state_machine_data + ) + + if self._trace_transitions and span is not None: + span.output = self.require_state_machine_data().model_dump() # type: ignore[assignment] + if span.data is not None: + span.data["output_state"] = next_state_name # type: ignore[index] + await adk.tracing.end_span(trace_id=self._task_id, span=span) + + await self.transition(next_state_name) + + return next_state_name + + async def reset_to_initial_state(self): + """ + Reset the state machine to its initial state. + """ + span = None + if self._trace_transitions: + if self._task_id is None: + raise ValueError( + "Task ID is must be set before tracing can be enabled" + ) + span = await adk.tracing.start_span( + trace_id=self._task_id, + name="state_transition_reset", + input={"input_state": self.get_current_state()}, + ) + + await self.transition(self._initial_state) + + if self._trace_transitions and span is not None: + span.output = {"output_state": self._initial_state} # type: ignore[assignment,union-attr] + await adk.tracing.end_span(trace_id=self._task_id, span=span) + + def get_lifecycle(self) -> dict[str, Any]: + """Export the state machine's lifecycle as a dict suitable for AgentCard.""" + states = [] + for state in self._state_map.values(): + workflow = state.workflow + states.append({ + "name": state.name, + "description": workflow.description, + "waits_for_input": workflow.waits_for_input, + "accepts": list(workflow.accepts), + "transitions": [ + t.value if isinstance(t, Enum) else str(t) + for t in workflow.transitions + ], + }) + initial: str = self._initial_state.value if isinstance(self._initial_state, Enum) else self._initial_state + + return { + "states": states, + "initial_state": initial, + } + + def dump(self) -> dict[str, Any]: + """ + Save the current state of the state machine to a serializable dictionary. + This includes the current state, task_id, state machine data, and initial state. + + Returns: + Dict[str, Any]: A dictionary containing the serialized state machine state + """ + return { + "task_id": self._task_id, + "current_state": self.get_current_state(), + "initial_state": self._initial_state, + "state_machine_data": self.state_machine_data.model_dump(mode="json") + if self.state_machine_data + else None, + "trace_transitions": self._trace_transitions, + } + + @classmethod + async def load(cls, data: dict[str, Any], states: list[State]) -> "StateMachine[T]": + """ + Load a state machine from a previously saved dictionary. + + Args: + data: The dictionary containing the saved state machine state + states: List of all possible states + + Returns: + StateMachine: A new state machine instance restored to the saved state + + Raises: + ValueError: If the data is invalid or missing required fields + """ + try: + task_id = data.get("task_id") + current_state_name = data.get("current_state") + initial_state = data.get("initial_state") + state_machine_data_dict = data.get("state_machine_data") + trace_transitions = data.get("trace_transitions") + + if initial_state is None: + raise ValueError("Initial state not found in saved data") + + # Reconstruct the state machine data into its Pydantic model + state_machine_data = None + if state_machine_data_dict is not None: + # Get the actual model type from the class's type parameters + model_type = cls.__orig_bases__[0].__args__[0] # type: ignore[attr-defined] + state_machine_data = model_type.model_validate(state_machine_data_dict) + + # Create a new instance + instance = cls( + initial_state=initial_state, + states=states, + task_id=task_id, + state_machine_data=state_machine_data, + trace_transitions=trace_transitions, + ) + + # If there's a saved state, transition to it + if current_state_name: + await instance.transition(target_state_name=current_state_name) + + return instance + except Exception as e: + raise ValueError(f"Failed to restore state machine: {str(e)}") from e diff --git a/src/agentex/lib/sdk/state_machine/state_workflow.py b/src/agentex/lib/sdk/state_machine/state_workflow.py new file mode 100644 index 000000000..dc5f5ff83 --- /dev/null +++ b/src/agentex/lib/sdk/state_machine/state_workflow.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from pydantic import BaseModel + +# Import StateMachine only for type checking to avoid circular imports +if TYPE_CHECKING: + from agentex.lib.sdk.state_machine import StateMachine + + +class StateWorkflow(ABC): + description: str = "" + waits_for_input: bool = False + accepts: list[str] = [] + transitions: list[str] = [] + + @abstractmethod + async def execute( + self, state_machine: "StateMachine", state_machine_data: BaseModel | None = None + ) -> str: + pass diff --git a/src/agentex/lib/sdk/utils/__init__.py b/src/agentex/lib/sdk/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/sdk/utils/messages.py b/src/agentex/lib/sdk/utils/messages.py new file mode 100644 index 000000000..bddd81050 --- /dev/null +++ b/src/agentex/lib/sdk/utils/messages.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import json +from abc import ABC, abstractmethod +from typing import Any, Literal, override + +from agentex.types.data_content import DataContent +from agentex.types.task_message import TaskMessage +from agentex.types.text_content import TextContent +from agentex.lib.types.llm_messages import ( + Message, + ToolCall, + ToolMessage, + UserMessage, + ToolCallRequest, + AssistantMessage, +) +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent + + +class TaskMessageConverter(ABC): + """ + Abstract base class for converting a specific type of TaskMessage to an LLM Message. + + Each converter should be responsible for one content type. + """ + + @abstractmethod + def convert(self, task_message: TaskMessage) -> Message: + """ + Convert a TaskMessage to an LLM Message. + + Args: + task_message: The TaskMessage to convert + + Returns: + A Message (Pydantic model) + """ + pass + + +class DefaultTextContentConverter(TaskMessageConverter): + """Converter for TEXT content type.""" + + @override + def convert(self, task_message: TaskMessage) -> Message: + """Convert TEXT content to UserMessage or AssistantMessage based on author.""" + if not isinstance(task_message.content, TextContent): + raise ValueError(f"Expected TextContent, got {type(task_message.content)}") + content = task_message.content + if content.author == "user": + return UserMessage(content=content.content) + else: # AGENT or custom author + return AssistantMessage(content=content.content) + + +class DefaultToolRequestConverter(TaskMessageConverter): + """Converter for TOOL_REQUEST content type.""" + + @override + def convert(self, task_message: TaskMessage) -> Message: + """Convert TOOL_REQUEST content to AssistantMessage with tool_calls.""" + if not isinstance(task_message.content, ToolRequestContent): + raise ValueError(f"Expected ToolRequestContent, got {type(task_message.content)}") + + content = task_message.content + + # Ensure arguments are properly JSON serialized + arguments_str = json.dumps(content.arguments) + + tool_call = ToolCallRequest( + id=content.tool_call_id, + function=ToolCall(name=content.name, arguments=arguments_str), + ) + return AssistantMessage(content=None, tool_calls=[tool_call]) + + +class DefaultToolResponseConverter(TaskMessageConverter): + """Converter for TOOL_RESPONSE content type.""" + + @override + def convert(self, task_message: TaskMessage) -> Message: + """Convert TOOL_RESPONSE content to ToolMessage.""" + if not isinstance(task_message.content, ToolResponseContent): + raise ValueError(f"Expected ToolResponseContent, got {type(task_message.content)}") + + content = task_message.content + return ToolMessage( + content=str(content.content), + tool_call_id=content.tool_call_id, + name=content.name, + ) + + +class DefaultDataContentConverter(TaskMessageConverter): + """Converter for DATA content type.""" + + @override + def convert(self, task_message: TaskMessage) -> Message: + """Convert DATA content to UserMessage or AssistantMessage based on author.""" + if not isinstance(task_message.content, DataContent): + raise ValueError(f"Expected DataContent, got {type(task_message.content)}") + + content = task_message.content + content_str = str(content.data) + if content.author == "user": + return UserMessage(content=content_str) + else: # AGENT or custom author + return AssistantMessage(content=content_str) + + +class DefaultUnknownContentConverter(TaskMessageConverter): + """Converter for unknown content types.""" + + @override + def convert(self, task_message: TaskMessage) -> Message: + """Convert unknown content types to AssistantMessage with fallback text.""" + + content = task_message.content + fallback_content = f"Unknown message type: {content.type}" + return AssistantMessage(content=fallback_content) + + +def convert_task_message_to_llm_messages( + task_message: TaskMessage, + output_mode: Literal["pydantic", "dict"] = "pydantic", + text_converter: TaskMessageConverter | None = None, + tool_request_converter: TaskMessageConverter | None = None, + tool_response_converter: TaskMessageConverter | None = None, + data_converter: TaskMessageConverter | None = None, + unknown_converter: TaskMessageConverter | None = None, +) -> Message | dict[str, Any]: + """ + Convert a TaskMessage to an LLM Message format. + + Args: + task_message: The TaskMessage to convert + output_mode: Whether to return a Pydantic model or dict + text_converter: Optional converter for TEXT content. Uses DefaultTextContentConverter if None. + tool_request_converter: Optional converter for TOOL_REQUEST content. Uses DefaultToolRequestConverter if None. + tool_response_converter: Optional converter for TOOL_RESPONSE content. Uses DefaultToolResponseConverter if None. + data_converter: Optional converter for DATA content. Uses DefaultDataContentConverter if None. + unknown_converter: Optional converter for unknown content. Uses DefaultUnknownContentConverter if None. + + Returns: + Either a Message (Pydantic model) or dict representation + """ + content = task_message.content + + # Get the appropriate converter for this content type + if content.type == "text": + converter = ( + text_converter + if text_converter is not None + else DefaultTextContentConverter() + ) + elif content.type == "tool_request": + converter = ( + tool_request_converter + if tool_request_converter is not None + else DefaultToolRequestConverter() + ) + elif content.type == "tool_response": + converter = ( + tool_response_converter + if tool_response_converter is not None + else DefaultToolResponseConverter() + ) + elif content.type == "data": + converter = ( + data_converter + if data_converter is not None + else DefaultDataContentConverter() + ) + else: + converter = ( + unknown_converter + if unknown_converter is not None + else DefaultUnknownContentConverter() + ) + + message = converter.convert(task_message) + + if output_mode == "dict": + return message.model_dump() + return message + + +def convert_task_messages_to_llm_messages( + task_messages: list[TaskMessage], + output_mode: Literal["pydantic", "dict"] = "pydantic", + text_converter: TaskMessageConverter | None = None, + tool_request_converter: TaskMessageConverter | None = None, + tool_response_converter: TaskMessageConverter | None = None, + data_converter: TaskMessageConverter | None = None, + unknown_converter: TaskMessageConverter | None = None, +) -> list[Message | dict[str, Any]]: + """ + Convert a list of TaskMessages to LLM Message format. + + Args: + task_messages: List of TaskMessages to convert + output_mode: Whether to return Pydantic models or dicts + text_converter: Optional converter for TEXT content. Uses DefaultTextContentConverter if None. + tool_request_converter: Optional converter for TOOL_REQUEST content. Uses DefaultToolRequestConverter if None. + tool_response_converter: Optional converter for TOOL_RESPONSE content. Uses DefaultToolResponseConverter if None. + data_converter: Optional converter for DATA content. Uses DefaultDataContentConverter if None. + unknown_converter: Optional converter for unknown content. Uses DefaultUnknownContentConverter if None. + + Returns: + List of either Messages (Pydantic models) or dicts + """ + return [ + convert_task_message_to_llm_messages( + task_message, + output_mode, + text_converter, + tool_request_converter, + tool_response_converter, + data_converter, + unknown_converter, + ) + for task_message in task_messages + ] diff --git a/src/agentex/lib/sdk/utils/webhooks.py b/src/agentex/lib/sdk/utils/webhooks.py new file mode 100644 index 000000000..d4b7b43e1 --- /dev/null +++ b/src/agentex/lib/sdk/utils/webhooks.py @@ -0,0 +1,389 @@ +"""Drive an agent turn from an inbound webhook, inside a forward-route handler. + +The Agentex server already exposes a webhook ingress: a request to +``/agents/forward/name/{agent}/{path}`` is signature-verified (GitHub ``sha256=`` / +Slack ``v0:`` HMAC via the agent's registered keys) and proxied to the agent's own +HTTP route. This helper is what that route handler calls to turn the inbound payload +into an agent turn — without each agent re-implementing payload shaping, config +resolution, session continuity, and reply handling. + +Typical use inside an agent:: + + from fastapi import Request + from agentex.lib.sdk.utils.webhooks import handle_webhook + + + @acp.post("/github-pr") + async def github_pr(request: Request): + body = await request.json() + result = await handle_webhook( + agent_name="my-agent", + payload=body, + acp_type="sync", + shaper="github_pr", + params_source="https:///public/v5/agent_configs//resolve", + params_source_headers={"x-api-key": ..., "x-selected-account-id": ...}, + wait=True, + ) + return {"task_id": result.task_id, "reply": result.reply} + +Config-by-id: pass ``params_source`` pointing at the platform's config-resolve +endpoint; the resolved params (e.g. system_prompt / harness / model / tools) are +forwarded opaquely to ``task/create``. Or pass inline ``params`` for a one-off. +""" + +from __future__ import annotations + +import json +import hashlib +from typing import Any, Literal +from dataclasses import field, dataclass +from collections.abc import Mapping, Callable, Awaitable + +from agentex.lib import adk +from agentex.lib.utils.logging import make_logger +from agentex.types.task_message_content import TextContent + +logger = make_logger(__name__) + +# Injectable params fetcher (url -> JSON). Default uses httpx; tests inject a fake. +ParamsFetcher = Callable[[str], Awaitable[dict[str, Any]]] + +MAX_BODY_CHARS = 4000 +MAX_DIFF_CHARS = 30000 + + +class WebhookError(RuntimeError): + """Raised when a webhook turn cannot be driven (e.g. params resolution failed).""" + + +@dataclass +class WebhookResult: + task_id: str + # Sync agents reply inline. For async agents, ``reply`` is None unless ``wait`` was + # set, in which case it is the polled reply (or None if it didn't settle in time). + reply: str | None = None + task_metadata: dict[str, str] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- shaping + + +def session_key(agent_name: str, channel: str, peer_id: str) -> str: + """Stable per-conversation task name → reused for get-or-create on task/create, so + repeat events from the same source fold into one task instead of spawning new ones.""" + basis = peer_id or "main" + digest = hashlib.sha1(f"{agent_name}:{channel}:{basis}".encode()).hexdigest()[:16] + return f"wh-{channel}-{digest}" + + +# Top-level fields a generic webhook payload might carry its prompt in, in priority +# order. Matched case-insensitively against the payload's keys. +GENERIC_PROMPT_KEYS = ( + "text", + "message", + "prompt", + "goal", + "content", + "body", + "description", + "title", +) + + +def render_generic(body: dict[str, Any]) -> str: + """Generic payload → prompt text: first non-empty string among GENERIC_PROMPT_KEYS + (case-insensitive), else raw JSON.""" + lowered = {key.lower(): value for key, value in body.items() if isinstance(key, str)} + for key in GENERIC_PROMPT_KEYS: + value = lowered.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return json.dumps(body, indent=2)[:8000] + + +def shape_github_pr(body: dict[str, Any]) -> tuple[str, str | None, str]: + """Shape a GitHub/Gitea pull-request webhook into (prompt, peer_id, sender). + + ``peer_id`` is ``repo#number`` so repeated events for the same PR (opened, + synchronize, ...) fold into one task. Falls back to generic rendering for non-PR + payloads (ping, issue, ...). + """ + pull_request = body.get("pull_request") + if not isinstance(pull_request, dict): + return render_generic(body), None, _github_actor(body) + + repo = _repo_full_name(body) + number = pull_request.get("number") + title = (pull_request.get("title") or "").strip() + action = (body.get("action") or "").strip() + description = (pull_request.get("body") or "").strip() + html_url = pull_request.get("html_url") or pull_request.get("url") + + header = "Pull request" + if repo and number is not None: + header = f"Pull request {repo}#{number}" + elif number is not None: + header = f"Pull request #{number}" + + lines = [f"{header}: {title}" if title else header] + if action: + lines.append(f"Action: {action}") + if html_url: + lines.append(f"URL: {html_url}") + if description: + lines.extend(["", "Description:", description[:MAX_BODY_CHARS]]) + + diff = _inline_diff(body, pull_request) + if diff: + lines.extend(["", "Diff:", diff[:MAX_DIFF_CHARS]]) + else: + # Standard GitHub/Gitea payloads carry a diff/patch URL, not the patch body. + # Surface it so a tool-enabled agent (or the caller) can fetch the diff; inline + # `diff` wins. Gitea sends patch_url alongside diff_url, so accept either. + diff_url = pull_request.get("diff_url") or pull_request.get("patch_url") + if diff_url: + lines.extend(["", f"Diff URL: {diff_url}"]) + + peer_id = None + if repo and number is not None: + peer_id = f"{repo}#{number}" + elif number is not None: + peer_id = f"pr#{number}" + return "\n".join(lines), peer_id, _github_actor(body) + + +def _repo_full_name(body: dict[str, Any]) -> str | None: + repo = body.get("repository") + if isinstance(repo, dict) and isinstance(repo.get("full_name"), str): + return repo["full_name"] or None + return None + + +def _github_actor(body: dict[str, Any]) -> str: + sender = body.get("sender") + if isinstance(sender, dict) and isinstance(sender.get("login"), str) and sender["login"]: + return sender["login"] + return "webhook" + + +def _inline_diff(body: dict[str, Any], pull_request: dict[str, Any]) -> str | None: + for source in (body, pull_request): + diff = source.get("diff") + if isinstance(diff, str) and diff.strip(): + return diff.strip() + return None + + +# ------------------------------------------------------------------- params resolution + + +async def _default_fetch(url: str, headers: dict[str, str]) -> dict[str, Any]: + """GET a params source over HTTP. Imported lazily so callers that only pass inline + params carry no httpx dependency.""" + import httpx + + request_headers = {"accept": "application/json", **headers} + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=request_headers) + response.raise_for_status() + return response.json() + except httpx.HTTPError as exc: + raise WebhookError(f"params source request failed: {exc}") from exc + except ValueError as exc: # json.JSONDecodeError subclasses ValueError + raise WebhookError(f"params source returned invalid JSON: {exc}") from exc + + +async def resolve_remote_params( + url: str, + headers: dict[str, str] | None = None, + *, + fetch: ParamsFetcher | None = None, +) -> tuple[dict[str, Any], dict[str, str]]: + """Fetch params (+ optional task_metadata) from a config-resolve URL. + + Response shape (lenient):: + + {"params": {...}, "task_metadata": {...}} + + A bare object with no ``params`` key is treated as the params dict itself (minus a + top-level ``task_metadata``, which is returned separately for stamping). + """ + do_fetch = fetch or (lambda u: _default_fetch(u, headers or {})) + payload = await do_fetch(url) + if not isinstance(payload, dict): + raise WebhookError("params source returned a non-object response") + + metadata_raw = payload.get("task_metadata") + task_metadata = {str(k): str(v) for k, v in metadata_raw.items()} if isinstance(metadata_raw, dict) else {} + params = payload.get("params") + if not isinstance(params, dict): + params = {k: v for k, v in payload.items() if k != "task_metadata"} + return params, task_metadata + + +# ------------------------------------------------------------------------- dispatch + + +def _agent_reply_text(messages: object) -> str | None: + """Join agent-authored text from a message list (sync result or polled stream).""" + if not isinstance(messages, list): + return None + parts = [] + for message in messages: + content = getattr(message, "content", None) + if ( + content is not None + and getattr(content, "author", None) == "agent" + and getattr(content, "type", None) == "text" + ): + text = (getattr(content, "content", "") or "").strip() + if text: + parts.append(text) + return "\n\n".join(parts) if parts else None + + +async def handle_webhook( + *, + agent_name: str, + payload: dict[str, Any], + acp_type: Literal["sync", "async"] = "sync", + shaper: Literal["generic", "github_pr"] = "generic", + channel: str | None = None, + params: dict[str, Any] | None = None, + params_source: str | None = None, + params_source_headers: dict[str, str] | None = None, + peer_id: str | None = None, + extra_task_metadata: dict[str, str] | None = None, + wait: bool = False, + fetch: ParamsFetcher | None = None, +) -> WebhookResult: + """Drive an agent turn from a webhook payload, agent-side, via the ADK client. + + - Shapes the payload (generic or GitHub PR) into a prompt + conversation scope. + - Resolves task params: inline ``params``, or fetched from ``params_source`` + (config-by-id). The platform never interprets params — they're forwarded to the + agent as ``task/create`` params. + - Get-or-creates a task keyed on a stable session key, so repeat events fold in. + - Sends the turn (sync → message/send returns the reply inline; async → event/send, + with optional ``wait`` to poll for the reply). + """ + channel = channel or shaper + if shaper == "github_pr": + text, derived_peer, sender = shape_github_pr(payload) + peer_id = peer_id or derived_peer + else: + text, sender = render_generic(payload), "webhook" + + task_metadata: dict[str, str] = {"channel": channel, "sender_id": sender} + if peer_id: + task_metadata["peer_id"] = peer_id + + resolved_params = dict(params) if params else {} + if params_source: + resolved_params, source_metadata = await resolve_remote_params( + params_source, params_source_headers, fetch=fetch + ) + # Source metadata + caller extras never override the canonical fields above. + for key, value in {**source_metadata, **(extra_task_metadata or {})}.items(): + task_metadata.setdefault(key, str(value)) + elif extra_task_metadata: + for key, value in extra_task_metadata.items(): + task_metadata.setdefault(key, str(value)) + + name = session_key(agent_name, channel, peer_id or "") + # task/create carries only name/params (CreateTaskParams has no task_metadata field), + # so we create first, then stamp task_metadata via a follow-up update below. + task = await adk.acp.create_task( + name=name, + agent_name=agent_name, + params=resolved_params or None, + ) + + # Best-effort: stamp the resolved task_metadata (channel/sender/peer_id, plus the + # display_name etc. from params_source) onto the task so it's labeled in the UI. + # Failure must never break the run — the metadata is also returned on the result. + if task_metadata: + try: + merged_task_metadata = { + **_task_metadata_dict(getattr(task, "task_metadata", None)), + **task_metadata, + } + await adk.tasks.update(task_id=task.id, task_metadata=merged_task_metadata) + except Exception: + logger.warning("Failed to stamp task_metadata on task %s", task.id, exc_info=True) + + content = TextContent(author="user", content=text, format="markdown") + + if acp_type == "sync": + messages = await adk.acp.send_message(task_id=task.id, agent_name=agent_name, content=content) + return WebhookResult(task_id=task.id, reply=_agent_reply_text(messages), task_metadata=task_metadata) + + # Async: when we'll wait for the reply, snapshot existing message ids BEFORE the + # event so a reused task's prior reply (session continuity) isn't mistaken for it. + if wait: + seen_ids, seen_count = await _message_snapshot(task.id) + await adk.acp.send_event(task_id=task.id, agent_name=agent_name, content=content) + reply = await _await_reply(task.id, seen_ids, seen_count=seen_count) + else: + await adk.acp.send_event(task_id=task.id, agent_name=agent_name, content=content) + reply = None + return WebhookResult(task_id=task.id, reply=reply, task_metadata=task_metadata) + + +def _task_metadata_dict(value: object) -> dict[str, Any]: + if isinstance(value, Mapping): + return dict(value) + return {} + + +async def _message_snapshot(task_id: str) -> tuple[set[str], int]: + messages = await adk.messages.list(task_id=task_id) + messages = messages or [] + return {mid for m in messages if (mid := getattr(m, "id", None)) is not None}, len(messages) + + +async def _message_ids(task_id: str) -> set[str]: + # Only track real ids. Keeping None in the set would let a later id-less message + # collide with it and be wrongly treated as already-seen (dropping a fresh reply). + seen_ids, _ = await _message_snapshot(task_id) + return seen_ids + + +async def _await_reply( + task_id: str, + seen_ids: set[str | None], + *, + seen_count: int | None = None, + timeout_s: float = 120.0, + interval_s: float = 2.0, + quiescence_s: float = 6.0, +) -> str | None: + """Poll for THIS turn's reply — agent text in messages that weren't present before + the event — until it settles (unchanged for ``quiescence_s``) or times out. Filtering + on new message ids avoids returning a stale prior reply on a reused task.""" + import asyncio + + waited = 0.0 + last: str | None = None + stable_for = 0.0 + while waited < timeout_s: + await asyncio.sleep(interval_s) + waited += interval_s + messages = await adk.messages.list(task_id=task_id) + new = [] + for index, message in enumerate(messages or []): + mid = getattr(message, "id", None) + if mid is not None and mid not in seen_ids: + new.append(message) + elif mid is None and seen_count is not None and index >= seen_count: + new.append(message) + text = _agent_reply_text(new) + if text and text == last: + stable_for += interval_s + if stable_for >= quiescence_s: + return text + elif text: + last, stable_for = text, 0.0 + return last diff --git a/src/agentex/lib/types/__init__.py b/src/agentex/lib/types/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/types/acp.py b/src/agentex/lib/types/acp.py new file mode 100644 index 000000000..e86ff7ddf --- /dev/null +++ b/src/agentex/lib/types/acp.py @@ -0,0 +1,16 @@ +"""Back-compat shim. The canonical location is :mod:`agentex.protocol.acp`. + +Kept here so existing ``from agentex.lib.types.acp import ...`` imports +continue to work. New code should import from the canonical path. +""" + +from agentex.protocol.acp import ( # noqa: F401 + RPC_SYNC_METHODS, + PARAMS_MODEL_BY_METHOD, + RPCMethod, + SendEventParams, + CancelTaskParams, + CreateTaskParams, + SendMessageParams, + InterruptTaskParams, +) diff --git a/src/agentex/lib/types/agent_card.py b/src/agentex/lib/types/agent_card.py new file mode 100644 index 000000000..d0af817a5 --- /dev/null +++ b/src/agentex/lib/types/agent_card.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import types +import typing +from enum import Enum +from typing import TYPE_CHECKING, Any, get_args, get_origin + +from pydantic import Field, BaseModel + +if TYPE_CHECKING: + from agentex.lib.sdk.state_machine.state import State + + +class LifecycleState(BaseModel): + name: str + description: str = "" + waits_for_input: bool = False + accepts: list[str] = [] + transitions: list[str] = [] + + +class AgentLifecycle(BaseModel): + states: list[LifecycleState] + initial_state: str + queries: list[str] = [] + + +class AgentCard(BaseModel): + protocol: str = "acp" + lifecycle: AgentLifecycle | None = None + 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( + cls, + initial_state: str | Enum, + states: list[State], + 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. + + Agents can share their `states` list between the StateMachine and acp.py + without constructing a temporary StateMachine instance. + """ + lifecycle_states = [ + LifecycleState( + name=state.name, + description=state.workflow.description, + waits_for_input=state.workflow.waits_for_input, + accepts=list(state.workflow.accepts), + transitions=[ + t.value if isinstance(t, Enum) else str(t) + for t in state.workflow.transitions + ], + ) + for state in states + ] + + initial = initial_state.value if isinstance(initial_state, Enum) else initial_state + + data_events: list[str] = [] + output_schema: dict | None = None + if output_event_model: + data_events = extract_literal_values(output_event_model, "type") + output_schema = output_event_model.model_json_schema() + + derived_input_types: set[str] = set() + for ls in lifecycle_states: + derived_input_types.update(ls.accepts) + + return cls( + lifecycle=AgentLifecycle( + states=lifecycle_states, + initial_state=initial, + queries=queries or [], + ), + data_events=data_events, + input_types=sorted(derived_input_types | set(extra_input_types or [])), + output_schema=output_schema, + metadata=metadata or {}, + ) + + @classmethod + def from_state_machine( + cls, + state_machine: Any, + 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() + states_data = lifecycle["states"] + initial = lifecycle["initial_state"] + + # Reconstruct lightweight State-like objects from the lifecycle dict + # so we can reuse from_states logic via the dict path + data_events: list[str] = [] + output_schema: dict | None = None + if output_event_model: + data_events = extract_literal_values(output_event_model, "type") + output_schema = output_event_model.model_json_schema() + + derived_input_types: set[str] = set() + lifecycle_states = [] + for s in states_data: + derived_input_types.update(s.get("accepts", [])) + lifecycle_states.append(LifecycleState( + name=s["name"], + description=s.get("description", ""), + waits_for_input=s.get("waits_for_input", False), + accepts=s.get("accepts", []), + transitions=s.get("transitions", []), + )) + + return cls( + lifecycle=AgentLifecycle( + states=lifecycle_states, + initial_state=initial, + queries=queries or [], + ), + data_events=data_events, + input_types=sorted(derived_input_types | set(extra_input_types or [])), + output_schema=output_schema, + metadata=metadata or {}, + ) + + +def extract_literal_values(model: type[BaseModel], field: str) -> list[str]: + """Extract allowed values from a Literal[...] type annotation on a Pydantic model field.""" + field_info = model.model_fields.get(field) + if field_info is None: + return [] + + annotation = field_info.annotation + if annotation is None: + return [] + + # Unwrap Optional (Union[X, None] or PEP 604 X | None) to get the inner type + if get_origin(annotation) is typing.Union or isinstance(annotation, types.UnionType): + args = [a for a in get_args(annotation) if a is not type(None)] + annotation = args[0] if len(args) == 1 else annotation + + if get_origin(annotation) is typing.Literal: + return list(get_args(annotation)) + + return [] diff --git a/src/agentex/lib/types/agent_configs.py b/src/agentex/lib/types/agent_configs.py new file mode 100644 index 000000000..2c855d3df --- /dev/null +++ b/src/agentex/lib/types/agent_configs.py @@ -0,0 +1,11 @@ +"""Back-compat shim. The canonical location is :mod:`agentex.config.agent_configs`. + +Kept here so existing ``from agentex.lib.types.agent_configs import ...`` imports +continue to work. New code should import from the canonical path. +""" + +from agentex.config.agent_configs import ( # noqa: F401 + TemporalConfig, + TemporalWorkerConfig, + TemporalWorkflowConfig, +) diff --git a/src/agentex/lib/types/agent_results.py b/src/agentex/lib/types/agent_results.py new file mode 100644 index 000000000..909593c18 --- /dev/null +++ b/src/agentex/lib/types/agent_results.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel + + +class SerializableRunResult(BaseModel): + """ + Serializable version of RunResult. + + Attributes: + final_output: The final output of the run. + final_input_list: The final input list of the run. + """ + + final_output: Any + final_input_list: list[dict[str, Any]] + + +class SerializableRunResultStreaming(BaseModel): + """ + Serializable version of RunResultStreaming. + + Attributes: + final_output: The final output of the run. + final_input_list: The final input list of the run. + """ + + final_output: Any + final_input_list: list[dict[str, Any]] diff --git a/src/agentex/lib/types/converters.py b/src/agentex/lib/types/converters.py new file mode 100644 index 000000000..1e3676b55 --- /dev/null +++ b/src/agentex/lib/types/converters.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import json + +from agents import TResponseInputItem + +from agentex.types.task_message import TaskMessage +from agentex.types.text_content import TextContent +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent + + +def convert_task_messages_to_oai_agents_inputs( + task_messages: list[TaskMessage], +) -> list[TResponseInputItem]: + """ + Convert a list of TaskMessages to a list of OpenAI Agents SDK inputs (TResponseInputItem). + + Args: + task_messages: The list of TaskMessages to convert. + + Returns: + A list of OpenAI Agents SDK inputs (TResponseInputItem). + """ + converted_messages = [] + for task_message in task_messages: + task_message_content = task_message.content + if isinstance(task_message_content, TextContent): + converted_messages.append( + { + "role": ( + "user" if task_message_content.author == "user" else "assistant" + ), + "content": task_message_content.content, + } + ) + elif isinstance(task_message_content, ToolRequestContent): + converted_messages.append( + { + "type": "function_call", + "call_id": task_message_content.tool_call_id, + "name": task_message_content.name, + "arguments": json.dumps(task_message_content.arguments), + } + ) + elif isinstance(task_message_content, ToolResponseContent): + content_str = ( + task_message_content.content + if isinstance(task_message_content.content, str) + else json.dumps(task_message_content.content) + ) + converted_messages.append( + { + "type": "function_call_output", + "call_id": task_message_content.tool_call_id, + "output": content_str, + } + ) + else: + raise ValueError( + f"Unsupported content type for converting TaskMessage to OpenAI Agents SDK input: {type(task_message.content)}" + ) + + return converted_messages diff --git a/src/agentex/lib/types/credentials.py b/src/agentex/lib/types/credentials.py new file mode 100644 index 000000000..abab8b508 --- /dev/null +++ b/src/agentex/lib/types/credentials.py @@ -0,0 +1,7 @@ +"""Back-compat shim. The canonical location is :mod:`agentex.config.credentials`. + +Kept here so existing ``from agentex.lib.types.credentials import ...`` imports +continue to work. New code should import from the canonical path. +""" + +from agentex.config.credentials import CredentialMapping # noqa: F401 diff --git a/src/agentex/lib/types/fastacp.py b/src/agentex/lib/types/fastacp.py new file mode 100644 index 000000000..493ca5f11 --- /dev/null +++ b/src/agentex/lib/types/fastacp.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import Field, BaseModel, field_validator, model_validator + +from agentex.lib.core.clients.temporal.utils import validate_client_plugins, validate_worker_interceptors + + +class BaseACPConfig(BaseModel): + """ + Base configuration for all ACP implementations + + Attributes: + type: The type of ACP implementation + """ + + pass + + +class SyncACPConfig(BaseACPConfig): + """ + Configuration for SyncACP implementation + + Attributes: + type: The type of ACP implementation + """ + + pass + + +class AsyncACPConfig(BaseACPConfig): + """ + Base class for async ACP configurations + + Attributes: + type: The type of ACP implementation + """ + + type: Literal["temporal", "base"] = Field(..., frozen=True) + + +AgenticACPConfig = AsyncACPConfig + + +class TemporalACPConfig(AsyncACPConfig): + """ + Configuration for TemporalACP implementation + + Attributes: + type: The type of ACP implementation + temporal_address: The address of the temporal server + plugins: List of Temporal client plugins + interceptors: List of Temporal worker interceptors + payload_codec: Optional ``temporalio.converter.PayloadCodec`` for + encoding/decoding payloads (e.g. encryption, compression). NOTE: + this only configures the ACP (client) side. The worker side must + be configured separately via ``AgentexWorker(payload_codec=...)`` + with the SAME codec, or decode will fail at runtime. Cannot be + combined with ``OpenAIAgentsPlugin``; use ``data_converter`` + instead in that case. + data_converter: Optional pre-built ``temporalio.converter.DataConverter``. + Use this when composing the ``OpenAIAgentsPlugin`` with a payload + codec: build a ``DataConverter(payload_converter_class= + OpenAIPayloadConverter, payload_codec=...)`` and pass it here. + Mutually exclusive with ``payload_codec``. The worker side must + be configured separately via ``AgentexWorker(data_converter=...)`` + with the SAME converter, or decode will fail at runtime. + """ + + type: Literal["temporal"] = Field(default="temporal", frozen=True) + temporal_address: str = Field(default="temporal-frontend.temporal.svc.cluster.local:7233", frozen=True) + plugins: list[Any] = Field(default=[], frozen=True) + interceptors: list[Any] = Field(default=[], frozen=True) + payload_codec: Any = Field(default=None, frozen=True) + data_converter: Any = Field(default=None, frozen=True) + + @field_validator("plugins") + @classmethod + def validate_plugins(cls, v: list[Any]) -> list[Any]: + """Validate that all plugins are valid Temporal client plugins.""" + validate_client_plugins(v) + return v + + @field_validator("interceptors") + @classmethod + def validate_interceptors(cls, v: list[Any]) -> list[Any]: + """Validate that all interceptors are valid Temporal worker interceptors.""" + validate_worker_interceptors(v) + return v + + @model_validator(mode="after") + def _validate_codec_and_data_converter_mutually_exclusive(self) -> "TemporalACPConfig": + if self.payload_codec is not None and self.data_converter is not None: + raise ValueError( + "Pass payload_codec inside `data_converter` " + "(DataConverter(..., payload_codec=...)) instead of as a separate " + "field. Specifying both is ambiguous." + ) + return self + + +class AsyncBaseACPConfig(AsyncACPConfig): + """Configuration for AsyncBaseACP implementation + + Attributes: + type: The type of ACP implementation + """ + + type: Literal["base"] = Field(default="base", frozen=True) + + +AgenticBaseACPConfig = AsyncBaseACPConfig diff --git a/src/agentex/lib/types/files.py b/src/agentex/lib/types/files.py new file mode 100644 index 000000000..ddf104dd2 --- /dev/null +++ b/src/agentex/lib/types/files.py @@ -0,0 +1,13 @@ +from agentex.lib.utils.model_utils import BaseModel + + +class FileContentResponse(BaseModel): + """Response model for downloaded file content. + + Attributes: + mime_type: The MIME type of the file + base64_content: The base64 encoded content of the file + """ + + mime_type: str + base64_content: str diff --git a/src/agentex/lib/types/json_rpc.py b/src/agentex/lib/types/json_rpc.py new file mode 100644 index 000000000..b010f93f7 --- /dev/null +++ b/src/agentex/lib/types/json_rpc.py @@ -0,0 +1,11 @@ +"""Back-compat shim. The canonical location is :mod:`agentex.protocol.json_rpc`. + +Kept here so existing ``from agentex.lib.types.json_rpc import ...`` imports +continue to work. New code should import from the canonical path. +""" + +from agentex.protocol.json_rpc import ( # noqa: F401 + JSONRPCError, + JSONRPCRequest, + JSONRPCResponse, +) diff --git a/src/agentex/lib/types/llm_messages.py b/src/agentex/lib/types/llm_messages.py new file mode 100644 index 000000000..04192c003 --- /dev/null +++ b/src/agentex/lib/types/llm_messages.py @@ -0,0 +1,357 @@ +from __future__ import annotations + +from typing import Any, Literal + +try: + from typing import Annotated +except ImportError: + from typing import Annotated +from pydantic import Field + +from agentex.lib.utils.model_utils import BaseModel + + +class LLMConfig(BaseModel): + """ + LLMConfig is the configuration for the LLM. + + Attributes: + model: The model to use + messages: The messages to send to the LLM + temperature: The temperature to use + top_p: The top_p to use + n: The number of completions to generate + stream: Whether to stream the completions + stream_options: The options for the stream + stop: The stop sequence to use + max_tokens: The maximum number of tokens to generate + max_completion_tokens: The maximum number of tokens to generate for the completion + presence_penalty: The presence penalty to use + frequency_penalty: The frequency penalty to use + logit_bias: The logit bias to use + response_format: The response format to use + seed: The seed to use + tools: The tools to use + tool_choice: The tool choice to use + parallel_tool_calls: Whether to allow parallel tool calls + logprobs: Whether to return log probabilities + top_logprobs: The number of top log probabilities to return + """ + + model: str + messages: list = [] + temperature: float | None = None + top_p: float | None = None + n: int | None = None + stream: bool | None = None + stream_options: dict | None = None + stop: str | list | None = None + max_tokens: int | None = None + max_completion_tokens: int | None = None + presence_penalty: float | None = None + frequency_penalty: float | None = None + logit_bias: dict | None = None + response_format: dict | type[BaseModel] | str | None = None + seed: int | None = None + tools: list | None = None + tool_choice: str | None = None + parallel_tool_calls: bool | None = None + logprobs: bool | None = None + top_logprobs: int | None = None + num_retries: int | None = 3 + + +class ContentPartText(BaseModel): + """ + ContentPartText is the text content of the message. + + Attributes: + text: The text content. + type: The type of the content part. + """ + + text: str = Field(..., description="The text content.") + type: Literal["text"] = Field( + default="text", description="The type of the content part." + ) + + +class ImageURL(BaseModel): + """ + ImageURL is the URL of the image. + + Attributes: + url: The URL of the image. + detail: The detail level of the image. + """ + + url: str = Field( + ..., description="Either a URL of the image or the base64 encoded image data." + ) + detail: Literal["auto", "low", "high"] = Field( + ..., + description="""Specifies the detail level of the image. + +Learn more in the +[Vision guide](https://platform.openai.com/docs/guides/vision/low-or-high-fidelity-image-understanding). +""", + ) + + +class ContentPartImage(BaseModel): + """ + ContentPartImage is the image content of the message. + + Attributes: + image_url: The URL of the image. + type: The type of the content part. + """ + + image_url: ImageURL = Field(..., description="The image URL.") + type: Literal["image_url"] = Field(..., description="The type of the content part.") + + +class FileContent(BaseModel): + """ + FileContent is the file content of the message. + + Attributes: + filename: The name of the file. + file_data: The base64 encoded file data with MIME type, e.g., 'data:application/pdf;base64,...' + """ + + filename: str = Field(..., description="The name of the file.") + file_data: str = Field( + ..., + description="The base64 encoded file data with MIME type, e.g., 'data:application/pdf;base64,...'", + ) + + +class ContentPartFile(BaseModel): + """ + ContentPartFile is the file content of the message. + + Attributes: + file: The file content. + type: The type of the content part. + """ + + file: FileContent = Field(..., description="The file content.") + type: Literal["file"] = Field( + default="file", description="The type of the content part." + ) + + +ContentPart = ContentPartText | ContentPartImage | ContentPartFile + + +class SystemMessage(BaseModel): + """ + SystemMessage is the system message of the message. + + Attributes: + role: The role of the messages author, in this case `system`. + content: The contents of the system message. + """ + + role: Literal["system"] = Field( + default="system", + description="The role of the messages author, in this case `system`.", + ) + content: str = Field(..., description="The contents of the system message.") + + +class UserMessage(BaseModel): + """ + UserMessage is the user message of the message. + + Attributes: + role: The role of the messages author, in this case `user`. + content: The contents of the user message. + """ + + role: Literal["user"] = Field( + default="user", + description="The role of the messages author, in this case `user`.", + ) + content: str | list[ContentPart] = Field( + ..., + description="The contents of the user message. Can be a string or a list of content parts.", + ) + + +class ToolCall(BaseModel): + """ + ToolCall is the tool call of the message. + + Attributes: + name: The name of the function to call. + arguments: The arguments to call the function with, as generated by the model in JSON format. + """ + + name: str | None = Field( + default=None, description="The name of the function to call." + ) + arguments: str | None = Field( + default=None, + description=""" +The arguments to call the function with, as generated by the model in JSON +format. Note that the model does not always generate valid JSON, and may +hallucinate parameters not defined by your function schema. Validate the +arguments in your code before calling your function. +""", + ) + + +class ToolCallRequest(BaseModel): + """ + ToolCallRequest is the tool call request of the message. + + Attributes: + type: The type of the tool. Currently, only `function` is supported. + id: The ID of the tool call request. + function: The function that the model is requesting. + index: The index of the tool call request. + """ + + type: Literal["function"] = Field( + default="function", + description="The type of the tool. Currently, only `function` is supported.", + ) + id: str | None = Field(default=None, description="The ID of the tool call request.") + function: ToolCall = Field( + ..., description="The function that the model is requesting." + ) + index: int | None = None + + +class AssistantMessage(BaseModel): + """ + AssistantMessage is the assistant message of the message. + + Attributes: + role: The role of the messages author, in this case `assistant`. + content: The contents of the assistant message. + tool_calls: The tool calls generated by the model, such as function calls. + parsed: The parsed content of the message to a specific type + """ + + role: Literal["assistant"] = Field( + default="assistant", + description="The role of the messages author, in this case `assistant`.", + ) + content: str | None = Field( + default=None, + description="""The contents of the assistant message. + +Required unless `tool_calls` or `function_call` is specified. +""", + ) + tool_calls: list[ToolCallRequest] | None = Field( + default=None, + description="The tool calls generated by the model, such as function calls.", + ) + parsed: Any | None = Field( + default=None, description="The parsed content of the message to a specific type" + ) + + +class ToolMessage(BaseModel): + """ + ToolMessage is the tool message of the message. + + Attributes: + role: The role of the messages author, in this case `tool`. + content: The contents of the tool message. + tool_call_id: The tool call that this message is responding to. + name: The name of the tool called. + is_error: Whether the tool call was successful. + """ + + role: Literal["tool"] = Field( + default="tool", + description="The role of the messages author, in this case `tool`.", + ) + content: str | list[ContentPart] = Field( + ..., description="The contents of the tool message." + ) + tool_call_id: str = Field( + ..., description="Tool call that this message is responding to." + ) + # name is optional based on OAI API defined here for chat_completion_input: https://platform.openai.com/docs/api-reference/chat/create + name: str | None = Field(default=None, description="The name of the tool called.") + is_error: bool | None = Field( + default=None, description="Whether the tool call was successful." + ) + + +Message = Annotated[ + SystemMessage | UserMessage | AssistantMessage | ToolMessage, + Field(discriminator="role"), +] + + +class Delta(BaseModel): + """ + Delta is the delta of the message. + + Attributes: + content: The content of the delta. + role: The role of the delta. + tool_calls: The tool calls of the delta. + """ + + content: str | None = Field(default=None) + role: str | None = Field(default=None) + tool_calls: list[ToolCallRequest] | None = Field(default=None) + + +class Choice(BaseModel): + """ + Choice is the choice of the message. + + Attributes: + index: The index of the choice. + finish_reason: The finish reason of the choice. + message: The message of the choice. + delta: The delta of the choice. + """ + + index: int + finish_reason: Literal["stop", "length", "content_filter", "tool_calls"] | None = ( + None + ) + message: AssistantMessage | None = None + delta: Delta | None = None + + +class Usage(BaseModel): + """ + Usage is the usage of the message. + + Attributes: + prompt_tokens: The number of prompt tokens. + completion_tokens: The number of completion tokens. + total_tokens: The total number of tokens. + """ + + prompt_tokens: int + completion_tokens: int + total_tokens: int + + +class Completion(BaseModel): + """ + Completion is the completion of the message. + + Attributes: + choices: The choices of the completion. + created: The created time of the completion. + model: The model of the completion. + usage: The usage of the completion. + """ + + choices: list[Choice] + created: int | None = None + model: str | None = None + usage: Usage | None = None diff --git a/src/agentex/lib/types/tracing.py b/src/agentex/lib/types/tracing.py new file mode 100644 index 000000000..721d87794 --- /dev/null +++ b/src/agentex/lib/types/tracing.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Literal, Annotated + +from pydantic import Field + +from agentex.lib.utils.model_utils import BaseModel + + +class BaseModelWithTraceParams(BaseModel): + """ + Base model with trace parameters. + + Attributes: + trace_id: The trace ID + parent_span_id: The parent span ID + """ + + trace_id: str | None = None + parent_span_id: str | None = None + + +class AgentexTracingProcessorConfig(BaseModel): + type: Literal["agentex"] = "agentex" + + +class SGPTracingProcessorConfig(BaseModel): + type: Literal["sgp"] = "sgp" + sgp_api_key: str + sgp_account_id: str + sgp_base_url: str | None = None + + +TracingProcessorConfig = Annotated[ + AgentexTracingProcessorConfig | SGPTracingProcessorConfig, + Field(discriminator="type"), +] diff --git a/src/agentex/lib/utils/__init__.py b/src/agentex/lib/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/utils/build_provenance.py b/src/agentex/lib/utils/build_provenance.py new file mode 100644 index 000000000..447980263 --- /dev/null +++ b/src/agentex/lib/utils/build_provenance.py @@ -0,0 +1,189 @@ +"""Capture client-attested source identity without failing agent builds.""" + +from __future__ import annotations + +import os +import stat +import hashlib +import subprocess +from typing import Optional +from pathlib import Path +from datetime import datetime, timezone +from dataclasses import dataclass + +from agentex.lib.utils.logging import make_logger + +logger = make_logger(__name__) + +_GIT_TIMEOUT_S = 5 +_HASH_CHUNK_BYTES = 1 << 20 + + +@dataclass(frozen=True) +class BuildProvenance: + """Source identity for one build; unavailable fields degrade to ``None``.""" + + repo: Optional[str] = None + commit: Optional[str] = None + ref: Optional[str] = None + subpath: Optional[str] = None + working_tree_hash: Optional[str] = None + dirty: Optional[bool] = None + author_name: Optional[str] = None + author_email: Optional[str] = None + build_timestamp: Optional[str] = None + + def source_fields(self) -> dict[str, object]: + """The ``source_*`` form fields for the cloud-build upload (None omitted).""" + fields = { + "source_repo": self.repo, + "source_commit": self.commit, + "source_ref": self.ref, + "source_subpath": self.subpath, + "working_tree_hash": self.working_tree_hash, + "source_dirty": self.dirty, + } + return {key: value for key, value in fields.items() if value is not None} + + def build_info(self) -> dict[str, object]: + """Return provenance using the runtime registration metadata field names.""" + info = { + "repo": self.repo, + "commit_hash": self.commit, + "branch_name": self.ref, + "subpath": self.subpath, + "working_tree_hash": self.working_tree_hash, + "dirty": self.dirty, + "author_name": self.author_name, + "author_email": self.author_email, + "build_timestamp": self.build_timestamp, + } + return {key: value for key, value in info.items() if value is not None} + + +def _git(repo_root: Path, *args: str) -> Optional[str]: + """Run a git command under ``repo_root``; return stripped stdout or None.""" + try: + proc = subprocess.run( + ("git", "-C", str(repo_root), *args), + capture_output=True, + text=True, + timeout=_GIT_TIMEOUT_S, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return None + if proc.returncode != 0: + return None + return proc.stdout.strip() or None + + +def normalize_remote(url: Optional[str]) -> Optional[str]: + """Strip credentials and scheme from a remote, returning ``host/path``.""" + if not url: + return None + candidate = url.strip() + # 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) + else: + if "://" in candidate: + candidate = candidate.split("://", 1)[1] + candidate = candidate.split("@", 1)[-1] + if candidate.endswith(".git"): + candidate = candidate[: -len(".git")] + candidate = candidate.strip("/") + if not candidate: + return None + host, slash, path = candidate.partition("/") + return f"{host.lower()}{slash}{path}" + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with open(path, "rb") as handle: + while chunk := handle.read(_HASH_CHUNK_BYTES): + digest.update(chunk) + return digest.hexdigest() + + +def iter_context_files(root: Path) -> list[Path]: + """Return files and symlinks under ``root``, sorted by POSIX relative path.""" + return sorted( + (path for path in root.rglob("*") if path.is_symlink() or path.is_file()), + key=lambda path: path.relative_to(root).as_posix(), + ) + + +def working_tree_hash(root: Path) -> str: + """Hash sorted build inputs, normalized modes, and symlink target strings.""" + lines: list[str] = [] + for path in iter_context_files(root): + relpath = path.relative_to(root).as_posix() + if path.is_symlink(): + mode = "120000" + content_digest = hashlib.sha256(os.readlink(path).encode("utf-8")).hexdigest() + else: + executable = bool(path.stat().st_mode & stat.S_IXUSR) + mode = "100755" if executable else "100644" + content_digest = _sha256_file(path) + lines.append(f"{relpath}\x00{mode}\x00{content_digest}") + return hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest() + + +def _safe_working_tree_hash(root: Path) -> Optional[str]: + """Compute the context hash without allowing capture to fail a build.""" + try: + return working_tree_hash(root) + except Exception: + logger.warning("build-provenance: content hash failed; omitting", exc_info=True) + return None + + +def capture_build_provenance( + repo_path: Path, context_root: Path, content_root: Optional[Path] = None +) -> BuildProvenance: + """Capture git coordinates and the staged build-context hash.""" + timestamp = datetime.now(timezone.utc).isoformat() + hash_root = content_root if content_root is not None else context_root + tree_hash = _safe_working_tree_hash(hash_root) + + repo_root = _git(repo_path, "rev-parse", "--show-toplevel") + if repo_root is None: + # No git — the content hash is the only identity available. + logger.info("build-provenance: %s is not a git work tree; content hash only", repo_path) + return BuildProvenance(working_tree_hash=tree_hash, build_timestamp=timestamp) + + repo_root_path = Path(repo_root) + commit = _git(repo_root_path, "rev-parse", "HEAD") + # symbolic-ref fails on a detached HEAD (→ None); fall back to an exact tag. + ref = _git(repo_root_path, "symbolic-ref", "--short", "HEAD") or _git( + repo_root_path, "describe", "--tags", "--exact-match" + ) + remote = normalize_remote(_git(repo_root_path, "remote", "get-url", "origin")) + author_name = _git(repo_root_path, "log", "-1", "--format=%an") + author_email = _git(repo_root_path, "log", "-1", "--format=%ae") + + subpath: Optional[str] = None + try: + relative = context_root.resolve().relative_to(repo_root_path.resolve()).as_posix() + subpath = relative if relative != "." else None + except ValueError: + subpath = None + + status_args = ("status", "--porcelain") + if subpath is not None: + status_args += ("--", subpath) + dirty = _git(repo_root_path, *status_args) is not None + + return BuildProvenance( + repo=remote, + commit=commit, + ref=ref, + subpath=subpath, + working_tree_hash=tree_hash, + dirty=dirty, + author_name=author_name, + author_email=author_email, + build_timestamp=timestamp, + ) diff --git a/src/agentex/lib/utils/completions.py b/src/agentex/lib/utils/completions.py new file mode 100644 index 000000000..3cb1d7b4d --- /dev/null +++ b/src/agentex/lib/utils/completions.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import Any +from functools import reduce, singledispatch +from itertools import zip_longest + +from agentex.lib.types.llm_messages import ( + Delta, + Usage, + Choice, + ToolCall, + Completion, + ToolCallRequest, +) + + +@singledispatch +def _concat_chunks(_a: None, b: Any): + return b + + +@_concat_chunks.register +def _(a: Completion, b: Completion) -> Completion: + # Chunks can have unequal choices: with stream_options.include_usage the + # final chunk carries usage but no choices. Keep the unpaired side instead + # of truncating to the shorter list. + a.choices = [ + x if y is None else y if x is None else _concat_chunks(x, y) for x, y in zip_longest(a.choices, b.choices) + ] + a.usage = _concat_chunks(a.usage, b.usage) + + return a + + +@_concat_chunks.register +def _(a: Choice, b: Choice) -> Choice: + if hasattr(a, "index") and hasattr(b, "index"): + assert a.index == b.index + + if hasattr(a, "delta") and hasattr(b, "delta"): + a.delta = _concat_chunks(a.delta, b.delta) + + a.finish_reason = a.finish_reason or b.finish_reason + return a + + +@_concat_chunks.register +def _(a: Usage | None, b: Usage | None) -> Usage | None: + if a is not None and b is not None: + return Usage( + prompt_tokens=a.prompt_tokens + b.prompt_tokens, + completion_tokens=a.completion_tokens + b.completion_tokens, + total_tokens=a.total_tokens + b.total_tokens, + ) + else: + return a or b + + +@_concat_chunks.register +def _(a: Delta, b: Delta) -> Delta: + a.content = a.content + b.content if a.content and b.content else a.content or b.content + + if hasattr(a, "tool_calls") and hasattr(b, "tool_calls") and a.tool_calls and b.tool_calls: + # Group tool calls by index + grouped_tool_calls = {} + for tool_call in a.tool_calls + b.tool_calls: + if tool_call.index not in grouped_tool_calls: + grouped_tool_calls[tool_call.index] = tool_call + else: + grouped_tool_calls[tool_call.index] = _concat_chunks(grouped_tool_calls[tool_call.index], tool_call) + + a.tool_calls = list(grouped_tool_calls.values()) + elif hasattr(b, "tool_calls") and b.tool_calls: + a.tool_calls = b.tool_calls + + return a + + +@_concat_chunks.register +def _(a: ToolCallRequest, b: ToolCallRequest) -> ToolCallRequest: + # Preserve id from either a or b, with preference for a + id_val = a.id if a.id is not None else b.id + + # Use index from either a or b, with preference for a's index + index_val = a.index if hasattr(a, "index") and a.index is not None else b.index + + # Concatenate the function part + function_val = _concat_chunks(a.function, b.function) if a.function and b.function else a.function or b.function + + # Set all properties + a.id = id_val + a.index = index_val + a.function = function_val + + return a + + +@_concat_chunks.register +def _(a: ToolCall, b: ToolCall) -> ToolCall: + # Preserve name from either a or b, with preference for a + name_val = a.name or b.name + + # Concatenate arguments string + args_val = "" + if a.arguments is not None and b.arguments is not None: + args_val = a.arguments + b.arguments + else: + args_val = a.arguments or b.arguments + + # Set all properties + a.name = name_val + a.arguments = args_val + + return a + + +def concat_completion_chunks(chunks: list[Completion]) -> Completion: + """ + Accumulates all chunks returned from a streaming completion call into a `Completion` message. + This is useful when you stream responses from an LLM and want to keep track of the context (i.e. previous messages + current message). + + Args: + chunks: list of completion chunks returned from streamed completion + Returns: + Completion: same as type returned from non-streaming completion + + + + To implement `concat_completion_chunks` we first implement a binary `_concat_chunks` function for each + type. Using `singledispatch` to dispatch the call to the appropriate function based on the type of the first argument. + Each nested type is then concatenated. We can then use reduce to accumulate the entire stream into a single a + single `CompletionChunk`. Finally we convert the type to the appropriate non-streaming type `Completion` and return it. + """ + if not chunks: + raise ValueError("Cannot concatenate empty chunks list") + + chunks_copy = chunks.copy() + chunks_copy[0] = deepcopy(chunks_copy[0]) # _concat_chunks mutates first argument + accumulated_chunks = reduce(_concat_chunks, chunks_copy) + + data = accumulated_chunks.model_dump() + data["object"] = "chat.completion" + choices = data["choices"] + for choice in choices: + choice["message"] = choice.pop("delta") + + return Completion.model_validate(data) diff --git a/src/agentex/lib/utils/console.py b/src/agentex/lib/utils/console.py new file mode 100644 index 000000000..eab21efa8 --- /dev/null +++ b/src/agentex/lib/utils/console.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from rich import box +from rich.table import Table +from rich.console import Console + +console = Console() + + +def print_section(name: str, contents: list[str], subtitle: str | None = None): + console.print() + table = Table(box=box.SQUARE, caption=subtitle, show_header=False, expand=True) + table.title = name + table.add_column(name, style="dim", width=12) + table.add_row(*contents) + console.print(table) diff --git a/src/agentex/lib/utils/debug.py b/src/agentex/lib/utils/debug.py new file mode 100644 index 000000000..831199f9a --- /dev/null +++ b/src/agentex/lib/utils/debug.py @@ -0,0 +1,73 @@ +""" +Debug utilities for AgentEx development. + +Provides debugging setup functionality that can be used across different components. +""" + +import os + +from agentex.lib.utils.logging import make_logger + +logger = make_logger(__name__) + + +def setup_debug_if_enabled() -> None: + """ + Setup debugpy if debug mode is enabled via environment variables. + + This function checks for AgentEx debug environment variables and configures + debugpy accordingly. It's designed to be called early in worker startup. + + Environment Variables: + AGENTEX_DEBUG_ENABLED: Set to "true" to enable debug mode + AGENTEX_DEBUG_PORT: Port for debug server (default: 5678) + AGENTEX_DEBUG_TYPE: Type identifier for logging (default: "worker") + AGENTEX_DEBUG_WAIT_FOR_ATTACH: Set to "true" to wait for debugger attachment + + Raises: + Any exception from debugpy setup (will bubble up naturally) + """ + if os.getenv("AGENTEX_DEBUG_ENABLED") == "true": + # Imported lazily: debugpy is a development-only tool, so a normal + # worker startup must not require it to be installed. Importing it at + # module scope forced it onto every worker (it used to be satisfied + # transitively via ipykernel; that dep was dropped in agentex-sdk + # 0.11.5, surfacing this as "No module named 'debugpy'"). + import debugpy # type: ignore + + debug_port = int(os.getenv("AGENTEX_DEBUG_PORT", "5678")) + debug_type = os.getenv("AGENTEX_DEBUG_TYPE", "worker") + wait_for_attach = os.getenv("AGENTEX_DEBUG_WAIT_FOR_ATTACH", "false").lower() == "true" + + # Configure debugpy + debugpy.configure(subProcess=False) + debugpy.listen(debug_port) + + logger.info(f"🐛 [{debug_type.upper()}] Debug server listening on port {debug_port}") + + if wait_for_attach: + logger.info(f"⏳ [{debug_type.upper()}] Waiting for debugger to attach...") + debugpy.wait_for_client() + logger.info(f"✅ [{debug_type.upper()}] Debugger attached!") + else: + logger.info(f"📡 [{debug_type.upper()}] Ready for debugger attachment") + + +def is_debug_enabled() -> bool: + """ + Check if debug mode is currently enabled. + + Returns: + bool: True if AGENTEX_DEBUG_ENABLED is set to "true" + """ + return os.getenv("AGENTEX_DEBUG_ENABLED", "false").lower() == "true" + + +def get_debug_port() -> int: + """ + Get the debug port from environment variables. + + Returns: + int: Debug port (default: 5678) + """ + return int(os.getenv("AGENTEX_DEBUG_PORT", "5678")) diff --git a/src/agentex/lib/utils/dev_tools/__init__.py b/src/agentex/lib/utils/dev_tools/__init__.py new file mode 100644 index 000000000..38d7726a5 --- /dev/null +++ b/src/agentex/lib/utils/dev_tools/__init__.py @@ -0,0 +1,9 @@ +"""Development tools for AgentEx.""" + +from .async_messages import print_task_message, print_task_message_update, subscribe_to_async_task_messages + +__all__ = [ + "print_task_message", + "print_task_message_update", + "subscribe_to_async_task_messages", +] diff --git a/src/agentex/lib/utils/dev_tools/async_messages.py b/src/agentex/lib/utils/dev_tools/async_messages.py new file mode 100644 index 000000000..7c6275329 --- /dev/null +++ b/src/agentex/lib/utils/dev_tools/async_messages.py @@ -0,0 +1,423 @@ +""" +Development utility for subscribing to async task messages with streaming support. + +This module provides utilities to read existing messages from a task and subscribe +to new streaming messages, handling mid-stream connections gracefully. +""" + +import json +from typing import List, Optional +from datetime import datetime, timezone + +from yaspin import yaspin # type: ignore[import-untyped] +from rich.panel import Panel +from yaspin.core import Yaspin # type: ignore[import-untyped] +from rich.console import Console +from rich.markdown import Markdown + +from agentex import Agentex +from agentex.types import Task, TaskMessage, TextContent, ReasoningContent, ToolRequestContent, ToolResponseContent +from agentex.types.text_delta import TextDelta +from agentex.types.task_message_update import ( + TaskMessageUpdate, + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) + + +def print_task_message( + message: TaskMessage, + print_messages: bool = True, + rich_print: bool = True, +) -> None: + """ + Print a task message in a formatted way. + + Args: + message: The task message to print + print_messages: Whether to actually print the message (for debugging) + rich_print: Whether to use rich to print the message + """ + if not print_messages: + return + + # Skip empty messages + if isinstance(message.content, TextContent) and not message.content.content.strip(): + return + + # Skip empty reasoning messages + if isinstance(message.content, ReasoningContent): + has_summary = bool(message.content.summary) and any(s for s in message.content.summary if s) + has_content = bool(message.content.content) and any(c for c in message.content.content if c) if message.content.content is not None else False + if not has_summary and not has_content: + return + + timestamp = message.created_at.strftime("%m/%d/%Y %H:%M:%S") if message.created_at else "N/A" + + console = None + if rich_print: + console = Console(width=80) # Fit better in Jupyter cells + + if isinstance(message.content, TextContent): + content = message.content.content + content_type = "text" + elif isinstance(message.content, ToolRequestContent): + tool_name = message.content.name + tool_args = message.content.arguments + + # Format arguments as pretty JSON + try: + if isinstance(tool_args, str): + parsed_args = json.loads(tool_args) + formatted_args = json.dumps(parsed_args, indent=2) + else: + formatted_args = json.dumps(tool_args, indent=2) + content = f"🔧 **Tool Request: {tool_name}**\n\n**Arguments:**\n```json\n{formatted_args}\n```" + except (json.JSONDecodeError, TypeError): + content = f"🔧 **Tool Request: {tool_name}**\n\n**Arguments:**\n```json\n{tool_args}\n```" + + content_type = "tool_request" + elif isinstance(message.content, ToolResponseContent): + tool_name = message.content.name + tool_response = message.content.content + + # Try to parse and format JSON response nicely + try: + if isinstance(tool_response, str): + parsed_response = json.loads(tool_response) + formatted_json = json.dumps(parsed_response, indent=2) + content = f"✅ **Tool Response: {tool_name}**\n\n**Response:**\n```json\n{formatted_json}\n```" + else: + formatted_json = json.dumps(tool_response, indent=2) + content = f"✅ **Tool Response: {tool_name}**\n\n**Response:**\n```json\n{formatted_json}\n```" + except (json.JSONDecodeError, TypeError): + # If it's not valid JSON, display as text + if isinstance(tool_response, str): + # Try to extract text content if it's a JSON string with text field + try: + parsed = json.loads(tool_response) + if isinstance(parsed, dict) and "text" in parsed: + text_content = str(parsed["text"]) + content = f"✅ **Tool Response: {tool_name}**\n\n{text_content}" + else: + content = f"✅ **Tool Response: {tool_name}**\n\n{tool_response}" + except json.JSONDecodeError: + content = f"✅ **Tool Response: {tool_name}**\n\n{tool_response}" + else: + content = f"✅ **Tool Response: {tool_name}**\n\n{tool_response}" + + content_type = "tool_response" + elif isinstance(message.content, ReasoningContent): + # Format reasoning content + reasoning_parts = [] + + # Add summary if available + if message.content.summary: + # Join summaries with double newline for better formatting + summary_text = "\n\n".join(s for s in message.content.summary if s) + if summary_text: + reasoning_parts.append(summary_text) + + # Add full reasoning content if available + if message.content.content: + content_text = "\n\n".join(c for c in message.content.content if c) + if content_text: + reasoning_parts.append(content_text) + + # Format reasoning content (we already checked it's not empty at the top) + content = "🧠 **Reasoning**\n\n" + "\n\n".join(reasoning_parts) + content_type = "reasoning" + else: + content = f"{type(message.content).__name__}: {message.content}" + content_type = "other" + + if rich_print and console: + author_color = "bright_cyan" if message.content.author == "user" else "green" + + # Use different border styles and colors for different content types + if content_type == "tool_request": + border_style = "yellow" + elif content_type == "tool_response": + border_style = "bright_green" + elif content_type == "reasoning": + border_style = "bright_magenta" + author_color = "bright_magenta" # Also make the author text magenta + else: + border_style = author_color + + title = f"[bold {author_color}]{message.content.author.upper()}[/bold {author_color}] [{timestamp}]" + panel = Panel(Markdown(content), title=title, border_style=border_style, width=80) + console.print(panel) + else: + title = f"{message.content.author.upper()} [{timestamp}]" + if content_type == "reasoning": + title = f"🧠 REASONING [{timestamp}]" + print(f"{title}\n{content}\n") + + +def print_task_message_update( + task_message_update: TaskMessageUpdate, + print_messages: bool = True, + rich_print: bool = True, + show_deltas: bool = True, +) -> None: + """ + Print a task message update in a formatted way. + + This function handles different types of TaskMessageUpdate objects: + - StreamTaskMessageStart: Shows start indicator + - StreamTaskMessageDelta: Shows deltas in real-time (if show_deltas=True) + - StreamTaskMessageFull: Shows complete message content + - StreamTaskMessageDone: Shows completion indicator + + Args: + task_message_update: The TaskMessageUpdate object to print + print_messages: Whether to actually print the message (for debugging) + rich_print: Whether to use rich formatting + show_deltas: Whether to show delta updates in real-time + """ + if not print_messages: + return + + console = None + if rich_print: + console = Console(width=80) + + if isinstance(task_message_update, StreamTaskMessageStart): + if rich_print and console: + console.print("🚀 [cyan]Agent started responding...[/cyan]") + else: + print("🚀 Agent started responding...") + + elif isinstance(task_message_update, StreamTaskMessageDelta): + if show_deltas and task_message_update.delta: + if isinstance(task_message_update.delta, TextDelta): + print(task_message_update.delta.text_delta, end="", flush=True) + elif rich_print and console: + console.print(f"[yellow]Non-text delta: {type(task_message_update.delta).__name__}[/yellow]") + else: + print(f"Non-text delta: {type(task_message_update.delta).__name__}") + + elif isinstance(task_message_update, StreamTaskMessageFull): + if isinstance(task_message_update.content, TextContent): + timestamp = datetime.now().strftime("%m/%d/%Y %H:%M:%S") + + if rich_print and console: + author_color = "bright_cyan" if task_message_update.content.author == "user" else "green" + title = f"[bold {author_color}]{task_message_update.content.author.upper()}[/bold {author_color}] [{timestamp}]" + panel = Panel(Markdown(task_message_update.content.content), title=title, border_style=author_color, width=80) + console.print(panel) + else: + title = f"{task_message_update.content.author.upper()} [{timestamp}]" + print(f"\n{title}\n{task_message_update.content.content}\n") + else: + content_type = type(task_message_update.content).__name__ + if rich_print and console: + console.print(f"[yellow]Non-text content: {content_type}[/yellow]") + else: + print(f"Non-text content: {content_type}") + + else: # StreamTaskMessageDone + if rich_print and console: + console.print("\n✅ [green]Agent finished responding.[/green]") + else: + print("\n✅ Agent finished responding.") + + +def subscribe_to_async_task_messages( + client: Agentex, + task: Task, + only_after_timestamp: Optional[datetime] = None, + print_messages: bool = True, + rich_print: bool = True, + timeout: int = 10, +) -> List[TaskMessage]: + """ + Subscribe to async task messages and collect completed messages. + + This function: + 1. Reads all existing messages from the task + 2. Optionally filters messages after a timestamp + 3. Shows a loading message while listening + 4. Subscribes to task message events + 5. Fetches and displays complete messages when they finish + 6. Returns all messages collected during the session + + Features: + - Uses Rich library for beautiful formatting in Jupyter notebooks + - Agent messages are formatted as Markdown + - User and agent messages are displayed in colored panels with fixed width + - Optimized for Jupyter notebook display + + Args: + client: The Agentex client instance + task: The task to subscribe to + print_messages: Whether to print messages as they arrive + only_after_timestamp: Only include messages created after this timestamp. If None, all messages will be included. + rich_print: Whether to use rich to print the message + timeout: The timeout in seconds for the streaming connection. If the connection times out, the function will return with any messages collected so far. + Returns: + List of TaskMessage objects collected during the session + + Raises: + ValueError: If the task doesn't have a name (required for streaming) + """ + + messages_to_return: List[TaskMessage] = [] + + # Read existing messages + messages = [] + try: + # List all messages for this task - MessageListResponse is just a List[TaskMessage] + messages = client.messages.list(task_id=task.id) + + except Exception as e: + print(f"Error reading existing messages: {e}") + + # Filter and display existing messages + for message in messages: + if only_after_timestamp: + if message.created_at is not None: + # Handle timezone comparison - make both datetimes timezone-aware + message_time = message.created_at + if message_time.tzinfo is None: + # If message time is naive, assume it's in UTC + message_time = message_time.replace(tzinfo=timezone.utc) + + comparison_time = only_after_timestamp + if comparison_time.tzinfo is None: + # If comparison time is naive, assume it's in UTC + comparison_time = comparison_time.replace(tzinfo=timezone.utc) + + if message_time < comparison_time: + continue + else: + messages_to_return.append(message) + print_task_message(message, print_messages, rich_print) + else: + messages_to_return.append(message) + print_task_message(message, print_messages, rich_print) + + # Subscribe to server-side events using tasks.stream_events_by_name + # This is the proper way to get agent responses after sending an event in async agents + + # Ensure task has a name + if not task.name: + print("Error: Task must have a name to use stream_events_by_name") + raise ValueError("Task name is required") + + try: + # Use stream_events_by_name to subscribe to TaskMessageUpdate events for this task + # This doesn't require knowing the agent_id, just the task name + + # Track active streaming spinners per message index + active_spinners: dict[int, Yaspin] = {} # index -> yaspin spinner object + + with client.tasks.with_streaming_response.stream_events_by_name( + task_name=task.name, + timeout=timeout + ) as response: + + try: + for task_message_update_str in response.iter_text(): + try: + # Parse SSE format + if task_message_update_str.strip().startswith('data: '): + task_message_update_json = task_message_update_str.strip()[6:] # Remove 'data: ' prefix + task_message_update_data = json.loads(task_message_update_json) + + # Deserialize the discriminated union TaskMessageUpdate based on the "type" field + message_type = task_message_update_data.get("type", "unknown") + + # Handle different message types for streaming progress + if message_type == "start": + task_message_update = StreamTaskMessageStart.model_validate(task_message_update_data) + index = task_message_update.index or 0 + + # Start a yaspin spinner for this message + if print_messages and index not in active_spinners: + spinner = yaspin(text="🔄 Agent responding...") + spinner.start() + active_spinners[index] = spinner + + elif message_type == "delta": + task_message_update = StreamTaskMessageDelta.model_validate(task_message_update_data) + index = task_message_update.index or 0 + + # Spinner continues running (no update needed for HTML) or if spinner has not been created yet, create it + if print_messages and index not in active_spinners: + spinner = yaspin(text="🔄 Agent responding...") + spinner.start() + active_spinners[index] = spinner + + elif message_type == "full": + task_message_update = StreamTaskMessageFull.model_validate(task_message_update_data) + index = task_message_update.index or 0 + + # Stop spinner and show message + if index in active_spinners: + active_spinners[index].stop() + del active_spinners[index] + # Ensure clean line after spinner + if print_messages: + print() + + if task_message_update.parent_task_message and task_message_update.parent_task_message.id: + finished_message = client.messages.retrieve(task_message_update.parent_task_message.id) + messages_to_return.append(finished_message) + print_task_message(finished_message, print_messages, rich_print) + + elif message_type == "done": + task_message_update = StreamTaskMessageDone.model_validate(task_message_update_data) + index = task_message_update.index or 0 + + # Stop spinner and show message + if index in active_spinners: + active_spinners[index].stop() + del active_spinners[index] + # Ensure clean line after spinner + if print_messages: + print() + + if task_message_update.parent_task_message and task_message_update.parent_task_message.id: + finished_message = client.messages.retrieve(task_message_update.parent_task_message.id) + messages_to_return.append(finished_message) + print_task_message(finished_message, print_messages, rich_print) + + # Ignore "connected" message type + elif message_type == "connected": + pass + else: + if print_messages: + print(f"Unknown TaskMessageUpdate type: {message_type}") + + except json.JSONDecodeError: + # Skip invalid JSON or SSE metadata lines + if task_message_update_str.strip() and not task_message_update_str.startswith(':'): + if print_messages: + print(f"Skipping non-JSON: {task_message_update_str.strip()}") + continue + except Exception as e: + if print_messages: + print(f"Error processing TaskMessageUpdate: {e}") + print(f"Raw data: {task_message_update_str.strip()}") + continue + finally: + # Stop any remaining spinners when we're done + for spinner in active_spinners.values(): + spinner.stop() + active_spinners.clear() + + except Exception as e: + # Handle timeout gracefully + if "timeout" in str(e).lower() or "timed out" in str(e).lower(): + if print_messages: + print(f"Streaming timed out after {timeout} seconds - returning collected messages") + else: + if print_messages: + print(f"Error subscribing to events: {e}") + print("Make sure your agent is running and the task exists") + + return messages_to_return \ No newline at end of file diff --git a/src/agentex/lib/utils/io.py b/src/agentex/lib/utils/io.py new file mode 100644 index 000000000..f8dfcc463 --- /dev/null +++ b/src/agentex/lib/utils/io.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import Any + +import yaml +from yaml.scanner import ScannerError + + +class InvalidYAMLError(ValueError): + """ + Raised when trying to red a YAML file, but the file is not formatted correctly. + """ + + +def load_yaml_file(file_path: str) -> dict[str, Any]: + """ + Loads a YAML file from the specified path. + + :param file_path: The path of the YAML file to load. + :type file_path: str + :return: The contents of the YAML file. + :rtype: dict + """ + try: + with open(file_path) as file: + yaml_dict = yaml.safe_load(file) + return yaml_dict + except ScannerError as error: + raise InvalidYAMLError( + f"The following file is not in valid YAML format: {file_path}" + ) from error diff --git a/src/agentex/lib/utils/iterables.py b/src/agentex/lib/utils/iterables.py new file mode 100644 index 000000000..7119ddb6a --- /dev/null +++ b/src/agentex/lib/utils/iterables.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from typing import Any +from collections.abc import AsyncGenerator + + +async def async_enumerate( + aiterable: AsyncGenerator, start: int = 0 +) -> AsyncGenerator[tuple[int, Any], None]: + """ + Enumerate an async generator. + """ + i = start + async for item in aiterable: + yield i, item + i += 1 diff --git a/src/agentex/lib/utils/json_schema.py b/src/agentex/lib/utils/json_schema.py new file mode 100644 index 000000000..6c8fa5c37 --- /dev/null +++ b/src/agentex/lib/utils/json_schema.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Any + +import jsonref +from jsonschema import validate as schema_validation + + +def resolve_refs(schema: dict) -> dict: + """ + Resolve JSON references in a schema. + """ + resolved = jsonref.replace_refs(schema, proxies=False, lazy_load=False) + serializable = { + "type": resolved.get("type"), # type: ignore[union-attr] + "properties": resolved.get("properties"), # type: ignore[union-attr] + "required": list(resolved.get("required", [])), # type: ignore[union-attr] + "additionalProperties": resolved.get("additionalProperties", False), # type: ignore[union-attr] + } + return serializable + + +def validate_payload(json_schema: dict[str, Any], payload: dict[str, Any]) -> None: + """Validate the payload against the JSON schema.""" + schema_validation(instance=payload, schema=json_schema) diff --git a/src/agentex/lib/utils/logging.py b/src/agentex/lib/utils/logging.py new file mode 100644 index 000000000..a0d39331b --- /dev/null +++ b/src/agentex/lib/utils/logging.py @@ -0,0 +1,98 @@ +import os +import logging +import contextvars + +import ddtrace +import json_log_formatter +from rich.console import Console +from rich.logging import RichHandler + +_is_datadog_configured = bool(os.environ.get("DD_AGENT_HOST")) + +ctx_var_request_id = contextvars.ContextVar[str]("request_id") + +DEFAULT_LOG_LEVEL = logging.INFO + + +def resolve_log_level() -> int: + """Read the log level from ``LOG_LEVEL``, falling back to INFO. + + Read straight from the environment rather than through ``EnvVarKeys``, since + ``environment_variables`` imports this module and the reverse would be a cycle. + + ``getLevelName`` returns the string ``"Level FOO"`` for anything it does not + recognise, so the isinstance check is what stops a typo in ``LOG_LEVEL`` from + silently turning logging off. + """ + configured = os.getenv("LOG_LEVEL") + if not configured: + return DEFAULT_LOG_LEVEL + level = logging.getLevelName(configured.strip().upper()) + return level if isinstance(level, int) else DEFAULT_LOG_LEVEL + + +class CustomJSONFormatter(json_log_formatter.JSONFormatter): + def json_record(self, message: str, extra: dict, record: logging.LogRecord) -> dict: # type: ignore[override] + extra = super().json_record(message, extra, record) + extra["level"] = record.levelname + extra["name"] = record.name + extra["lineno"] = record.lineno + extra["pathname"] = record.pathname + extra["request_id"] = ctx_var_request_id.get(None) + if _is_datadog_configured: + extra["dd.trace_id"] = ddtrace.tracer.get_log_correlation_context().get("dd.trace_id", None) or getattr( # type: ignore[attr-defined] + record, "dd.trace_id", 0 + ) + extra["dd.span_id"] = ddtrace.tracer.get_log_correlation_context().get("dd.span_id", None) or getattr( # type: ignore[attr-defined] + record, "dd.span_id", 0 + ) + # add the env, service, and version configured for the tracer + # If tracing is not set up, then this should pull values from DD_ENV, DD_SERVICE, and DD_VERSION. + service_override = ddtrace.config.service or os.getenv("DD_SERVICE") + if service_override: + extra["dd.service"] = service_override + + env_override = ddtrace.config.env or os.getenv("DD_ENV") + if env_override: + extra["dd.env"] = env_override + + version_override = ddtrace.config.version or os.getenv("DD_VERSION") + if version_override: + extra["dd.version"] = version_override + + return extra + +def make_logger(name: str) -> logging.Logger: + """ + Creates a logger object with a RichHandler to print colored text. + :param name: The name of the module to create the logger for. + :return: A logger object. + """ + # Create a console object to print colored text + logger = logging.getLogger(name) + logger.setLevel(resolve_log_level()) + + environment = os.getenv("ENVIRONMENT") + if environment == "local": + console = Console() + # Add the RichHandler to the logger to print colored text + handler = RichHandler( + console=console, + show_level=False, + show_path=False, + show_time=False, + ) + logger.addHandler(handler) + return logger + + stream_handler = logging.StreamHandler() + if _is_datadog_configured: + stream_handler.setFormatter(CustomJSONFormatter()) + else: + stream_handler.setFormatter( + logging.Formatter("%(asctime)s %(levelname)s [%(name)s] [%(filename)s:%(lineno)d] - %(message)s") + ) + + logger.addHandler(stream_handler) + # Create a logger object with the name of the current module + return logger diff --git a/src/agentex/lib/utils/mcp.py b/src/agentex/lib/utils/mcp.py new file mode 100644 index 000000000..bebe9364b --- /dev/null +++ b/src/agentex/lib/utils/mcp.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typing import Any + +from mcp import StdioServerParameters + + +def redact_mcp_server_params( + mcp_server_params: list[StdioServerParameters], +) -> list[dict[str, Any]]: + """Redact MCP server params for logging.""" + return [ + { + **{k: v for k, v in server_param.model_dump().items() if k != "env"}, + "env": dict.fromkeys(server_param.env, "********") + if server_param.env + else None, + } + for server_param in mcp_server_params + ] diff --git a/src/agentex/lib/utils/metadata_filters.py b/src/agentex/lib/utils/metadata_filters.py new file mode 100644 index 000000000..22d8aeb59 --- /dev/null +++ b/src/agentex/lib/utils/metadata_filters.py @@ -0,0 +1,58 @@ +"""Helpers for the platform's JSON-encoded metadata filter query parameters. + +The containment filters on ``agents.list(agent_card_metadata=...)`` and +``tasks.list(task_metadata=...)`` carry their filter as a JSON-encoded object +inside a single query string value, so the generated clients type them as +``str``. Encoding by hand is easy to get subtly wrong -- Python's ``json`` +happily emits ``NaN``/``Infinity``, which the server rejects with a 400 -- so +these helpers do it once, here, in the hand-written layer where they survive +SDK regeneration. + + from agentex.lib.utils.metadata_filters import encode_metadata_filter + + client.agents.list( + agent_card_metadata=encode_metadata_filter({"permits_capable": True}), + ) + +The ``agent_card_metadata`` filter requires an Agentex server that includes +scaleapi/scale-agentex#411. Older servers ignore the unknown query parameter +and return the full unfiltered agent list rather than erroring, and the SDK's +startup backend-contract check does not guard against this. +""" + +from __future__ import annotations + +import json +from typing import Any, Mapping + +__all__ = ["encode_metadata_filter"] + + +def encode_metadata_filter(metadata: Mapping[str, Any]) -> str: + """Encode a metadata filter mapping into the wire form the platform expects. + + Args: + metadata: The key/value pairs the target's metadata object must contain. + Values may be any JSON type; matching is exact containment, so + ``{"permits_capable": True}`` matches a stored JSON ``true`` but not + the string ``"true"``. An empty mapping matches any target that has + a metadata object at all. + + Returns: + A compact JSON object string, with keys sorted so the same filter always + produces the same query value. + + Raises: + TypeError: If ``metadata`` is not a mapping, or contains a value that + isn't JSON-serializable. + ValueError: If a value is a non-finite float. ``NaN`` and ``Infinity`` + aren't valid JSON and the server rejects them with a 400, so fail + here with a clearer message instead. + """ + if not isinstance(metadata, Mapping): + raise TypeError(f"metadata must be a mapping, got {type(metadata).__name__}") + + try: + return json.dumps(metadata, allow_nan=False, separators=(",", ":"), sort_keys=True) + except ValueError as exc: + raise ValueError(f"metadata filter is not encodable as JSON: {exc}") from exc diff --git a/src/agentex/lib/utils/model_utils.py b/src/agentex/lib/utils/model_utils.py new file mode 100644 index 000000000..8826ba121 --- /dev/null +++ b/src/agentex/lib/utils/model_utils.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from typing import Any, TypeVar +from datetime import datetime +from collections.abc import Mapping, Iterable + +from pydantic import BaseModel as PydanticBaseModel, ConfigDict + +from agentex.lib.utils.io import load_yaml_file + +T = TypeVar("T", bound="BaseModel") + + +class BaseModel(PydanticBaseModel): + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + @classmethod + def from_yaml(cls: type[T], file_path: str) -> T: + """ + Returns an instance of this class by deserializing from a YAML file. + + :param file_path: The path to the YAML file. + :return: An instance of this class. + """ + yaml_dict = load_yaml_file(file_path=file_path) + class_object = cls.model_validate(yaml_dict) + return class_object + + def to_json(self, *args, **kwargs) -> str: + return self.model_dump_json(*args, **kwargs) + + def to_dict(self, *_args, **_kwargs) -> dict[str, Any]: + return recursive_model_dump(self) + + +def recursive_model_dump(obj: Any) -> Any: + if isinstance(obj, PydanticBaseModel): + # Get the model data as dict and recursively process each field + # This allows us to handle non-serializable objects like functions + try: + return obj.model_dump(mode="json") + except Exception: + # If model_dump fails (e.g., due to functions), manually process + model_dict = {} + for field_name in obj.__class__.model_fields: + field_value = getattr(obj, field_name) + model_dict[field_name] = recursive_model_dump(field_value) + return model_dict + elif isinstance(obj, datetime): + # Serialize datetime to ISO format string + return obj.isoformat() + elif callable(obj): + # Serialize functions and other callable objects + if hasattr(obj, "__name__"): + func_name = obj.__name__ + else: + func_name = str(obj) + + if hasattr(obj, "__module__"): + return f"" + else: + return f"" + elif isinstance(obj, Mapping): + # Recursively serialize dictionary values + return {k: recursive_model_dump(v) for k, v in obj.items()} + elif isinstance(obj, Iterable) and not isinstance(obj, str | bytes): + # Recursively serialize items in lists, tuples, sets, etc. + return [recursive_model_dump(item) for item in obj] + else: + # Return primitive types as-is + return obj diff --git a/src/agentex/lib/utils/parsing.py b/src/agentex/lib/utils/parsing.py new file mode 100644 index 000000000..ecb61206a --- /dev/null +++ b/src/agentex/lib/utils/parsing.py @@ -0,0 +1,15 @@ +from urllib.parse import urlsplit, urlunsplit + + +def remove_query_params(url): + split_url = urlsplit(url) + scheme, netloc, path, query, fragment = split_url + + if query: + query = '' + else: + amp_index = path.find('&') + if amp_index != -1: + path = path[:amp_index] + + return urlunsplit((scheme, netloc, path, query, fragment)) diff --git a/src/agentex/lib/utils/regex.py b/src/agentex/lib/utils/regex.py new file mode 100644 index 000000000..c760b10dd --- /dev/null +++ b/src/agentex/lib/utils/regex.py @@ -0,0 +1,6 @@ +import re + + +def camel_to_snake(camel_case_str: str) -> str: + # Substitute capital letters with an underscore followed by the lowercase letter + return re.sub(r'(? datetime | None: + # Returns Temporal's deterministic workflow clock when called from inside a + # workflow, otherwise None. Used to stamp messages with a monotonic + # `created_at` so two awaited messages.create calls from the same workflow + # cannot collide at the server. Outside a workflow (sync agents, plain + # async activities) the server's wall clock is fine. + if in_temporal_workflow(): + return workflow.now() + return None diff --git a/src/agentex/protocol/__init__.py b/src/agentex/protocol/__init__.py new file mode 100644 index 000000000..be9db981a --- /dev/null +++ b/src/agentex/protocol/__init__.py @@ -0,0 +1,16 @@ +"""Wire-protocol shapes for Agentex. + +The modules under `agentex.protocol.*` are the typed shapes for talking to +an Agentex agent over JSON-RPC (the ACP / Agent Communication Protocol) +without pulling in the heavy ADK runtime. They depend only on pydantic and +the Stainless-generated `agentex.types.*` surface, so they are safe to +import from a slim REST-only install. + +Hand-rolled JSON-RPC clients (e.g. the one in `egp-api-backend`) can switch +from constructing `{"jsonrpc": "2.0", "method": "...", "params": {...}}` +dicts by hand to constructing `JSONRPCRequest(method=RPCMethod.TASK_CREATE, +params=CreateTaskParams(...).model_dump())`. + +For back-compat, the same classes are re-exported from +`agentex.lib.types.{acp,json_rpc}` (the historical locations). +""" diff --git a/src/agentex/protocol/acp.py b/src/agentex/protocol/acp.py new file mode 100644 index 000000000..7e310cd89 --- /dev/null +++ b/src/agentex/protocol/acp.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any + +from pydantic import Field, BaseModel + +from agentex.types.task import Task +from agentex.types.agent import Agent +from agentex.types.event import Event +from agentex.types.task_message_content import TaskMessageContent + + +class RPCMethod(str, Enum): + """Available JSON-RPC methods for agent communication.""" + + EVENT_SEND = "event/send" + MESSAGE_SEND = "message/send" + TASK_CANCEL = "task/cancel" + TASK_CREATE = "task/create" + TASK_INTERRUPT = "task/interrupt" + + +class CreateTaskParams(BaseModel): + """Parameters for task/create method. + + Attributes: + agent: The agent that the task was sent to. + task: The task to be created. + params: The parameters for the task as inputted by the user. + request: Additional request context including headers forwarded to this agent. + """ + + agent: Agent = Field(..., description="The agent that the task was sent to") + task: Task = Field(..., description="The task to be created") + params: dict[str, Any] | None = Field( + None, + description="The parameters for the task as inputted by the user", + ) + request: dict[str, Any] | None = Field( + default=None, + description="Additional request context including headers forwarded to this agent", + ) + + +class SendMessageParams(BaseModel): + """Parameters for message/send method. + + Attributes: + agent: The agent that the message was sent to. + task: The task that the message was sent to. + content: The message that was sent to the agent. + stream: Whether to stream the message back to the agentex server from the agent. + request: Additional request context including headers forwarded to this agent. + """ + + agent: Agent = Field(..., description="The agent that the message was sent to") + task: Task = Field(..., description="The task that the message was sent to") + content: TaskMessageContent = Field( + ..., description="The message that was sent to the agent" + ) + stream: bool = Field( + False, + description="Whether to stream the message back to the agentex server from the agent", + ) + request: dict[str, Any] | None = Field( + default=None, + description="Additional request context including headers forwarded to this agent", + ) + + +class SendEventParams(BaseModel): + """Parameters for event/send method. + + Attributes: + agent: The agent that the event was sent to. + task: The task that the message was sent to. + event: The event that was sent to the agent. + request: Additional request context including headers forwarded to this agent. + """ + + agent: Agent = Field(..., description="The agent that the event was sent to") + task: Task = Field(..., description="The task that the message was sent to") + event: Event = Field(..., description="The event that was sent to the agent") + request: dict[str, Any] | None = Field( + default=None, + description="Additional request context including headers forwarded to this agent", + ) + + +class CancelTaskParams(BaseModel): + """Parameters for task/cancel method. + + Attributes: + agent: The agent that the task was sent to. + task: The task that was cancelled. + request: Additional request context including headers forwarded to this agent. + """ + + agent: Agent = Field(..., description="The agent that the task was sent to") + task: Task = Field(..., description="The task that was cancelled") + request: dict[str, Any] | None = Field( + default=None, + description="Additional request context including headers forwarded to this agent", + ) + + +class InterruptTaskParams(BaseModel): + """Parameters for task/interrupt method. + + Non-terminal counterpart to :class:`CancelTaskParams`. The control plane + forwards ``task/interrupt`` to the agent so it can stop the in-flight turn + while leaving the task continuable (status ``INTERRUPTED``, not a terminal + status). See the interrupt-and-queue design doc, sections 5-7. + + Attributes: + agent: The agent that the task was sent to. + task: The task that was interrupted. + request: Additional request context including headers forwarded to this agent. + """ + + agent: Agent = Field(..., description="The agent that the task was sent to") + task: Task = Field(..., description="The task that was interrupted") + request: dict[str, Any] | None = Field( + default=None, + description="Additional request context including headers forwarded to this agent", + ) + + +RPC_SYNC_METHODS = [ + RPCMethod.MESSAGE_SEND, +] + +PARAMS_MODEL_BY_METHOD: dict[RPCMethod, type[BaseModel]] = { + RPCMethod.EVENT_SEND: SendEventParams, + RPCMethod.TASK_CANCEL: CancelTaskParams, + RPCMethod.MESSAGE_SEND: SendMessageParams, + RPCMethod.TASK_CREATE: CreateTaskParams, + RPCMethod.TASK_INTERRUPT: InterruptTaskParams, +} diff --git a/src/agentex/protocol/json_rpc.py b/src/agentex/protocol/json_rpc.py new file mode 100644 index 000000000..be03a4936 --- /dev/null +++ b/src/agentex/protocol/json_rpc.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict + +# Preserve the config the previous `agentex.lib.utils.model_utils.BaseModel` +# applied — `from_attributes=True` lets callers `model_validate` from +# attribute-bearing objects (not just dicts); `populate_by_name=True` is a +# harmless default future-proofing for any field aliases. +_PROTOCOL_MODEL_CONFIG = ConfigDict(from_attributes=True, populate_by_name=True) + + +class JSONRPCError(BaseModel): + """JSON-RPC 2.0 Error + + Attributes: + code: The error code + message: The error message + data: The error data + """ + + model_config = _PROTOCOL_MODEL_CONFIG + + code: int + message: str + data: Any | None = None + + +class JSONRPCRequest(BaseModel): + """JSON-RPC 2.0 Request + + Attributes: + jsonrpc: The JSON-RPC version + method: The method to call + params: The parameters for the request + id: The ID of the request + """ + + model_config = _PROTOCOL_MODEL_CONFIG + + jsonrpc: Literal["2.0"] = "2.0" + method: str + params: dict[str, Any] + id: int | str | None = None + + +class JSONRPCResponse(BaseModel): + """JSON-RPC 2.0 Response + + Attributes: + jsonrpc: The JSON-RPC version + result: The result of the request + error: The error of the request + id: The ID of the request + """ + + model_config = _PROTOCOL_MODEL_CONFIG + + jsonrpc: Literal["2.0"] = "2.0" + result: dict[str, Any] | None = None + error: JSONRPCError | None = None + id: int | str | None = None diff --git a/src/agentex/py.typed b/src/agentex/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/resources/__init__.py b/src/agentex/resources/__init__.py new file mode 100644 index 000000000..43dbdbdb4 --- /dev/null +++ b/src/agentex/resources/__init__.py @@ -0,0 +1,145 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .spans import ( + SpansResource, + AsyncSpansResource, + SpansResourceWithRawResponse, + AsyncSpansResourceWithRawResponse, + SpansResourceWithStreamingResponse, + AsyncSpansResourceWithStreamingResponse, +) +from .tasks import ( + TasksResource, + AsyncTasksResource, + TasksResourceWithRawResponse, + AsyncTasksResourceWithRawResponse, + TasksResourceWithStreamingResponse, + AsyncTasksResourceWithStreamingResponse, +) +from .agents import ( + AgentsResource, + AsyncAgentsResource, + AgentsResourceWithRawResponse, + AsyncAgentsResourceWithRawResponse, + AgentsResourceWithStreamingResponse, + AsyncAgentsResourceWithStreamingResponse, +) +from .events import ( + EventsResource, + AsyncEventsResource, + EventsResourceWithRawResponse, + AsyncEventsResourceWithRawResponse, + EventsResourceWithStreamingResponse, + AsyncEventsResourceWithStreamingResponse, +) +from .states import ( + StatesResource, + AsyncStatesResource, + StatesResourceWithRawResponse, + AsyncStatesResourceWithRawResponse, + StatesResourceWithStreamingResponse, + AsyncStatesResourceWithStreamingResponse, +) +from .tracker import ( + TrackerResource, + AsyncTrackerResource, + TrackerResourceWithRawResponse, + AsyncTrackerResourceWithRawResponse, + TrackerResourceWithStreamingResponse, + AsyncTrackerResourceWithStreamingResponse, +) +from .messages import ( + MessagesResource, + AsyncMessagesResource, + MessagesResourceWithRawResponse, + AsyncMessagesResourceWithRawResponse, + MessagesResourceWithStreamingResponse, + AsyncMessagesResourceWithStreamingResponse, +) +from .webhooks import ( + WebhooksResource, + AsyncWebhooksResource, + WebhooksResourceWithRawResponse, + AsyncWebhooksResourceWithRawResponse, + WebhooksResourceWithStreamingResponse, + AsyncWebhooksResourceWithStreamingResponse, +) +from .checkpoints import ( + CheckpointsResource, + AsyncCheckpointsResource, + CheckpointsResourceWithRawResponse, + AsyncCheckpointsResourceWithRawResponse, + CheckpointsResourceWithStreamingResponse, + AsyncCheckpointsResourceWithStreamingResponse, +) +from .deployment_history import ( + DeploymentHistoryResource, + AsyncDeploymentHistoryResource, + DeploymentHistoryResourceWithRawResponse, + AsyncDeploymentHistoryResourceWithRawResponse, + DeploymentHistoryResourceWithStreamingResponse, + AsyncDeploymentHistoryResourceWithStreamingResponse, +) + +__all__ = [ + "AgentsResource", + "AsyncAgentsResource", + "AgentsResourceWithRawResponse", + "AsyncAgentsResourceWithRawResponse", + "AgentsResourceWithStreamingResponse", + "AsyncAgentsResourceWithStreamingResponse", + "TasksResource", + "AsyncTasksResource", + "TasksResourceWithRawResponse", + "AsyncTasksResourceWithRawResponse", + "TasksResourceWithStreamingResponse", + "AsyncTasksResourceWithStreamingResponse", + "MessagesResource", + "AsyncMessagesResource", + "MessagesResourceWithRawResponse", + "AsyncMessagesResourceWithRawResponse", + "MessagesResourceWithStreamingResponse", + "AsyncMessagesResourceWithStreamingResponse", + "SpansResource", + "AsyncSpansResource", + "SpansResourceWithRawResponse", + "AsyncSpansResourceWithRawResponse", + "SpansResourceWithStreamingResponse", + "AsyncSpansResourceWithStreamingResponse", + "StatesResource", + "AsyncStatesResource", + "StatesResourceWithRawResponse", + "AsyncStatesResourceWithRawResponse", + "StatesResourceWithStreamingResponse", + "AsyncStatesResourceWithStreamingResponse", + "EventsResource", + "AsyncEventsResource", + "EventsResourceWithRawResponse", + "AsyncEventsResourceWithRawResponse", + "EventsResourceWithStreamingResponse", + "AsyncEventsResourceWithStreamingResponse", + "TrackerResource", + "AsyncTrackerResource", + "TrackerResourceWithRawResponse", + "AsyncTrackerResourceWithRawResponse", + "TrackerResourceWithStreamingResponse", + "AsyncTrackerResourceWithStreamingResponse", + "DeploymentHistoryResource", + "AsyncDeploymentHistoryResource", + "DeploymentHistoryResourceWithRawResponse", + "AsyncDeploymentHistoryResourceWithRawResponse", + "DeploymentHistoryResourceWithStreamingResponse", + "AsyncDeploymentHistoryResourceWithStreamingResponse", + "CheckpointsResource", + "AsyncCheckpointsResource", + "CheckpointsResourceWithRawResponse", + "AsyncCheckpointsResourceWithRawResponse", + "CheckpointsResourceWithStreamingResponse", + "AsyncCheckpointsResourceWithStreamingResponse", + "WebhooksResource", + "AsyncWebhooksResource", + "WebhooksResourceWithRawResponse", + "AsyncWebhooksResourceWithRawResponse", + "WebhooksResourceWithStreamingResponse", + "AsyncWebhooksResourceWithStreamingResponse", +] diff --git a/src/agentex/resources/agents/__init__.py b/src/agentex/resources/agents/__init__.py new file mode 100644 index 000000000..3760c4db7 --- /dev/null +++ b/src/agentex/resources/agents/__init__.py @@ -0,0 +1,47 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .agents import ( + AgentsResource, + AsyncAgentsResource, + AgentsResourceWithRawResponse, + AsyncAgentsResourceWithRawResponse, + AgentsResourceWithStreamingResponse, + AsyncAgentsResourceWithStreamingResponse, +) +from .schedules import ( + SchedulesResource, + AsyncSchedulesResource, + SchedulesResourceWithRawResponse, + AsyncSchedulesResourceWithRawResponse, + SchedulesResourceWithStreamingResponse, + AsyncSchedulesResourceWithStreamingResponse, +) +from .deployments import ( + DeploymentsResource, + AsyncDeploymentsResource, + DeploymentsResourceWithRawResponse, + AsyncDeploymentsResourceWithRawResponse, + DeploymentsResourceWithStreamingResponse, + AsyncDeploymentsResourceWithStreamingResponse, +) + +__all__ = [ + "DeploymentsResource", + "AsyncDeploymentsResource", + "DeploymentsResourceWithRawResponse", + "AsyncDeploymentsResourceWithRawResponse", + "DeploymentsResourceWithStreamingResponse", + "AsyncDeploymentsResourceWithStreamingResponse", + "SchedulesResource", + "AsyncSchedulesResource", + "SchedulesResourceWithRawResponse", + "AsyncSchedulesResourceWithRawResponse", + "SchedulesResourceWithStreamingResponse", + "AsyncSchedulesResourceWithStreamingResponse", + "AgentsResource", + "AsyncAgentsResource", + "AgentsResourceWithRawResponse", + "AsyncAgentsResourceWithRawResponse", + "AgentsResourceWithStreamingResponse", + "AsyncAgentsResourceWithStreamingResponse", +] diff --git a/src/agentex/resources/agents/agents.py b/src/agentex/resources/agents/agents.py new file mode 100644 index 000000000..7500648bd --- /dev/null +++ b/src/agentex/resources/agents/agents.py @@ -0,0 +1,1545 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import json +from typing import Any, Dict, Union, Optional, Generator, AsyncGenerator +from typing_extensions import Literal + +import httpx +from pydantic import ValidationError + +from ...types import agent_rpc_params, agent_list_params, agent_rpc_by_name_params, agent_register_build_params +from ..._types import NOT_GIVEN, Body, Omit, Query, Headers, NotGiven, omit, not_given +from ..._utils import path_template, maybe_transform, async_maybe_transform +from ..._compat import cached_property +from .schedules import ( + SchedulesResource, + AsyncSchedulesResource, + SchedulesResourceWithRawResponse, + AsyncSchedulesResourceWithRawResponse, + SchedulesResourceWithStreamingResponse, + AsyncSchedulesResourceWithStreamingResponse, +) +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .deployments import ( + DeploymentsResource, + AsyncDeploymentsResource, + DeploymentsResourceWithRawResponse, + AsyncDeploymentsResourceWithRawResponse, + DeploymentsResourceWithStreamingResponse, + AsyncDeploymentsResourceWithStreamingResponse, +) +from ...types.agent import Agent +from ..._base_client import make_request_options +from ...types.agent_rpc_response import ( + AgentRpcResponse, + SendEventResponse, + CancelTaskResponse, + CreateTaskResponse, + SendMessageResponse, + SendMessageStreamResponse, +) +from ...types.agent_list_response import AgentListResponse +from ...types.shared.delete_response import DeleteResponse + +__all__ = ["AgentsResource", "AsyncAgentsResource"] + + +class AgentsResource(SyncAPIResource): + @cached_property + def deployments(self) -> DeploymentsResource: + return DeploymentsResource(self._client) + + @cached_property + def schedules(self) -> SchedulesResource: + return SchedulesResource(self._client) + + @cached_property + def with_raw_response(self) -> AgentsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return AgentsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AgentsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return AgentsResourceWithStreamingResponse(self) + + def retrieve( + self, + agent_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Agent: + """ + Get an agent by its unique ID. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + return self._get( + path_template("/agents/{agent_id}", agent_id=agent_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Agent, + ) + + def list( + self, + *, + agent_card_metadata: Optional[str] | Omit = omit, + limit: int | Omit = omit, + order_by: Optional[str] | Omit = omit, + order_direction: str | Omit = omit, + page_number: int | Omit = omit, + task_id: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AgentListResponse: + """ + List all registered agents, optionally filtered by query parameters. + + Args: + agent_card_metadata: JSON-encoded object used to filter agents on + `registration_metadata.agent_card.metadata` via JSONB containment. Example: + {"permits_capable": true}. Only matches cards published through the direct + registration path: registrations that carry a `deployment_id` write the card to + the deployment record instead of `registration_metadata`, so those agents never + match this filter. + + limit: Limit + + order_by: Field to order by + + order_direction: Order direction (asc or desc) + + page_number: Page number + + task_id: Task ID + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/agents", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "agent_card_metadata": agent_card_metadata, + "limit": limit, + "order_by": order_by, + "order_direction": order_direction, + "page_number": page_number, + "task_id": task_id, + }, + agent_list_params.AgentListParams, + ), + ), + cast_to=AgentListResponse, + ) + + def delete( + self, + agent_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeleteResponse: + """ + Delete an agent by its unique ID. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + return self._delete( + path_template("/agents/{agent_id}", agent_id=agent_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeleteResponse, + ) + + def delete_by_name( + self, + agent_name: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeleteResponse: + """ + Delete an agent by its unique name. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_name: + raise ValueError(f"Expected a non-empty value for `agent_name` but received {agent_name!r}") + return self._delete( + path_template("/agents/name/{agent_name}", agent_name=agent_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeleteResponse, + ) + + def register_build( + self, + *, + description: str, + name: str, + agent_input_type: Optional[Literal["text", "json"]] | Omit = omit, + registration_metadata: Optional[Dict[str, object]] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Agent: + """ + Register an agent at build time, before it is deployed, so it can be + permissioned and shared prior to deploy. Idempotent by name. + + Args: + description: The description of the agent. + + name: The unique name of the agent. + + agent_input_type: The type of input the agent expects. + + registration_metadata: The metadata for the agent's build registration. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/agents/register-build", + body=maybe_transform( + { + "description": description, + "name": name, + "agent_input_type": agent_input_type, + "registration_metadata": registration_metadata, + }, + agent_register_build_params.AgentRegisterBuildParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Agent, + ) + + def retrieve_by_name( + self, + agent_name: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Agent: + """ + Get an agent by its unique name. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_name: + raise ValueError(f"Expected a non-empty value for `agent_name` but received {agent_name!r}") + return self._get( + path_template("/agents/name/{agent_name}", agent_name=agent_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Agent, + ) + + def rpc( + self, + agent_id: str, + *, + method: Literal["event/send", "task/create", "message/send", "task/cancel", "task/interrupt"], + params: agent_rpc_params.Params, + id: Union[int, str, None] | Omit = omit, + jsonrpc: Literal["2.0"] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AgentRpcResponse: + """ + Handle JSON-RPC requests for an agent by its unique ID. + + Args: + params: The parameters for the agent RPC request + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + return self._post( + path_template("/agents/{agent_id}/rpc", agent_id=agent_id), + body=maybe_transform( + { + "method": method, + "params": params, + "id": id, + "jsonrpc": jsonrpc, + }, + agent_rpc_params.AgentRpcParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AgentRpcResponse, + ) + + def rpc_by_name( + self, + agent_name: str, + *, + method: Literal["event/send", "task/create", "message/send", "task/cancel", "task/interrupt"], + params: agent_rpc_by_name_params.Params, + id: Union[int, str, None] | Omit = omit, + jsonrpc: Literal["2.0"] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AgentRpcResponse: + """ + Handle JSON-RPC requests for an agent by its unique name. + + Args: + params: The parameters for the agent RPC request + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_name: + raise ValueError(f"Expected a non-empty value for `agent_name` but received {agent_name!r}") + return self._post( + path_template("/agents/name/{agent_name}/rpc", agent_name=agent_name), + body=maybe_transform( + { + "method": method, + "params": params, + "id": id, + "jsonrpc": jsonrpc, + }, + agent_rpc_by_name_params.AgentRpcByNameParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AgentRpcResponse, + ) + + def create_task( + self, + agent_id: str | None = None, + agent_name: str | None = None, + *, + params: agent_rpc_params.ParamsCreateTaskRequest, + id: Union[int, str, None] | NotGiven = NOT_GIVEN, + jsonrpc: Literal["2.0"] | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> CreateTaskResponse: + if agent_id is not None and agent_name is not None: + raise ValueError("Either agent_id or agent_name must be provided, but not both") + + if agent_id is not None: + raw_agent_rpc_response = self.rpc( + agent_id=agent_id, + method="task/create", + params=params, + id=id, + jsonrpc=jsonrpc, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + elif agent_name is not None: + raw_agent_rpc_response = self.rpc_by_name( + agent_name=agent_name, + method="task/create", + params=params, + id=id, + jsonrpc=jsonrpc, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + else: + raise ValueError("Either agent_id or agent_name must be provided") + + return CreateTaskResponse.model_validate(raw_agent_rpc_response, from_attributes=True) + + def cancel_task( + self, + agent_id: str | None = None, + agent_name: str | None = None, + *, + params: agent_rpc_params.ParamsCancelTaskRequest, + id: Union[int, str, None] | NotGiven = NOT_GIVEN, + jsonrpc: Literal["2.0"] | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> CancelTaskResponse: + if agent_id is not None and agent_name is not None: + raise ValueError("Either agent_id or agent_name must be provided, but not both") + + if agent_id is not None: + raw_agent_rpc_response = self.rpc( + agent_id=agent_id, + method="task/cancel", + params=params, + id=id, + jsonrpc=jsonrpc, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + elif agent_name is not None: + raw_agent_rpc_response = self.rpc_by_name( + agent_name=agent_name, + method="task/cancel", + params=params, + id=id, + jsonrpc=jsonrpc, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + else: + raise ValueError("Either agent_id or agent_name must be provided") + + return CancelTaskResponse.model_validate(raw_agent_rpc_response, from_attributes=True) + + def send_message( + self, + agent_id: str | None = None, + agent_name: str | None = None, + *, + params: agent_rpc_params.ParamsSendMessageRequest, + id: Union[int, str, None] | NotGiven = NOT_GIVEN, + jsonrpc: Literal["2.0"] | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SendMessageResponse: + if agent_id is not None and agent_name is not None: + raise ValueError("Either agent_id or agent_name must be provided, but not both") + + if "stream" in params and params["stream"] == True: + raise ValueError("If stream is set to True, use send_message_stream() instead") + + if agent_id is not None: + raw_agent_rpc_response = self.with_streaming_response.rpc( + agent_id=agent_id, + method="message/send", + params=params, + id=id, + jsonrpc=jsonrpc, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + elif agent_name is not None: + raw_agent_rpc_response = self.with_streaming_response.rpc_by_name( + agent_name=agent_name, + method="message/send", + params=params, + id=id, + jsonrpc=jsonrpc, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + else: + raise ValueError("Either agent_id or agent_name must be provided") + + task_messages: list[Any] = [] + response_meta: dict[str, Any] = {} + + with raw_agent_rpc_response as response: + for _line in response.iter_lines(): + if not _line: + continue + line = _line.strip() + if line.startswith("data:"): + line = line[len("data:"):].strip() + if not line: + continue + try: + chunk = json.loads(line) + if not response_meta: + response_meta = {"id": chunk.get("id"), "jsonrpc": chunk.get("jsonrpc")} + try: + return SendMessageResponse.model_validate(chunk) + except ValidationError: + pass + chunk_stream = SendMessageStreamResponse.model_validate(chunk, from_attributes=True) + result = chunk_stream.result + if result is not None and getattr(result, "type", None) == "full": + parent = getattr(result, "parent_task_message", None) + if parent is not None: + task_messages.append(parent) + except (json.JSONDecodeError, ValidationError): + continue + + return SendMessageResponse( + id=response_meta.get("id"), + jsonrpc=response_meta.get("jsonrpc"), + result=task_messages, + ) + + def send_message_stream( + self, + agent_id: str | None = None, + agent_name: str | None = None, + *, + params: agent_rpc_params.ParamsSendMessageRequest, + id: Union[int, str, None] | NotGiven = NOT_GIVEN, + jsonrpc: Literal["2.0"] | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Generator[SendMessageStreamResponse, None, None]: + if agent_id is not None and agent_name is not None: + raise ValueError("Either agent_id or agent_name must be provided, but not both") + + if "stream" in params and params["stream"] == False: + raise ValueError("If stream is set to False, use send_message() instead") + + params["stream"] = True + + if agent_id is not None: + raw_agent_rpc_response = self.with_streaming_response.rpc( + agent_id=agent_id, + method="message/send", + params=params, + id=id, + jsonrpc=jsonrpc, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + elif agent_name is not None: + raw_agent_rpc_response = self.with_streaming_response.rpc_by_name( + agent_name=agent_name, + method="message/send", + params=params, + id=id, + jsonrpc=jsonrpc, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + else: + raise ValueError("Either agent_id or agent_name must be provided") + + with raw_agent_rpc_response as response: + for _line in response.iter_lines(): + if not _line: + continue + line = _line.strip() + # Handle optional SSE-style prefix + if line.startswith("data:"): + line = line[len("data:"):].strip() + if not line: + continue + try: + chunk_rpc_response = SendMessageStreamResponse.model_validate( + json.loads(line), + from_attributes=True + ) + yield chunk_rpc_response + except (json.JSONDecodeError, ValidationError): + # Skip invalid JSON lines or lines that cannot be validated + continue + + def send_event( + self, + agent_id: str | None = None, + agent_name: str | None = None, + *, + params: agent_rpc_params.ParamsSendEventRequest, + id: Union[int, str, None] | NotGiven = NOT_GIVEN, + jsonrpc: Literal["2.0"] | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SendEventResponse: + if agent_id is not None and agent_name is not None: + raise ValueError("Either agent_id or agent_name must be provided, but not both") + + if agent_id is not None: + raw_agent_rpc_response = self.rpc( + agent_id=agent_id, + method="event/send", + params=params, + id=id, + jsonrpc=jsonrpc, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + elif agent_name is not None: + raw_agent_rpc_response = self.rpc_by_name( + agent_name=agent_name, + method="event/send", + params=params, + id=id, + jsonrpc=jsonrpc, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + else: + raise ValueError("Either agent_id or agent_name must be provided") + + return SendEventResponse.model_validate(raw_agent_rpc_response, from_attributes=True) + + +class AsyncAgentsResource(AsyncAPIResource): + @cached_property + def deployments(self) -> AsyncDeploymentsResource: + return AsyncDeploymentsResource(self._client) + + @cached_property + def schedules(self) -> AsyncSchedulesResource: + return AsyncSchedulesResource(self._client) + + @cached_property + def with_raw_response(self) -> AsyncAgentsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return AsyncAgentsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncAgentsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return AsyncAgentsResourceWithStreamingResponse(self) + + async def retrieve( + self, + agent_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Agent: + """ + Get an agent by its unique ID. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + return await self._get( + path_template("/agents/{agent_id}", agent_id=agent_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Agent, + ) + + async def list( + self, + *, + agent_card_metadata: Optional[str] | Omit = omit, + limit: int | Omit = omit, + order_by: Optional[str] | Omit = omit, + order_direction: str | Omit = omit, + page_number: int | Omit = omit, + task_id: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AgentListResponse: + """ + List all registered agents, optionally filtered by query parameters. + + Args: + agent_card_metadata: JSON-encoded object used to filter agents on + `registration_metadata.agent_card.metadata` via JSONB containment. Example: + {"permits_capable": true}. Only matches cards published through the direct + registration path: registrations that carry a `deployment_id` write the card to + the deployment record instead of `registration_metadata`, so those agents never + match this filter. + + limit: Limit + + order_by: Field to order by + + order_direction: Order direction (asc or desc) + + page_number: Page number + + task_id: Task ID + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/agents", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "agent_card_metadata": agent_card_metadata, + "limit": limit, + "order_by": order_by, + "order_direction": order_direction, + "page_number": page_number, + "task_id": task_id, + }, + agent_list_params.AgentListParams, + ), + ), + cast_to=AgentListResponse, + ) + + async def delete( + self, + agent_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeleteResponse: + """ + Delete an agent by its unique ID. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + return await self._delete( + path_template("/agents/{agent_id}", agent_id=agent_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeleteResponse, + ) + + async def delete_by_name( + self, + agent_name: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeleteResponse: + """ + Delete an agent by its unique name. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_name: + raise ValueError(f"Expected a non-empty value for `agent_name` but received {agent_name!r}") + return await self._delete( + path_template("/agents/name/{agent_name}", agent_name=agent_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeleteResponse, + ) + + async def register_build( + self, + *, + description: str, + name: str, + agent_input_type: Optional[Literal["text", "json"]] | Omit = omit, + registration_metadata: Optional[Dict[str, object]] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Agent: + """ + Register an agent at build time, before it is deployed, so it can be + permissioned and shared prior to deploy. Idempotent by name. + + Args: + description: The description of the agent. + + name: The unique name of the agent. + + agent_input_type: The type of input the agent expects. + + registration_metadata: The metadata for the agent's build registration. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/agents/register-build", + body=await async_maybe_transform( + { + "description": description, + "name": name, + "agent_input_type": agent_input_type, + "registration_metadata": registration_metadata, + }, + agent_register_build_params.AgentRegisterBuildParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Agent, + ) + + async def retrieve_by_name( + self, + agent_name: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Agent: + """ + Get an agent by its unique name. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_name: + raise ValueError(f"Expected a non-empty value for `agent_name` but received {agent_name!r}") + return await self._get( + path_template("/agents/name/{agent_name}", agent_name=agent_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Agent, + ) + + async def rpc( + self, + agent_id: str, + *, + method: Literal["event/send", "task/create", "message/send", "task/cancel", "task/interrupt"], + params: agent_rpc_params.Params, + id: Union[int, str, None] | Omit = omit, + jsonrpc: Literal["2.0"] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AgentRpcResponse: + """ + Handle JSON-RPC requests for an agent by its unique ID. + + Args: + params: The parameters for the agent RPC request + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + return await self._post( + path_template("/agents/{agent_id}/rpc", agent_id=agent_id), + body=await async_maybe_transform( + { + "method": method, + "params": params, + "id": id, + "jsonrpc": jsonrpc, + }, + agent_rpc_params.AgentRpcParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AgentRpcResponse, + ) + + async def rpc_by_name( + self, + agent_name: str, + *, + method: Literal["event/send", "task/create", "message/send", "task/cancel", "task/interrupt"], + params: agent_rpc_by_name_params.Params, + id: Union[int, str, None] | Omit = omit, + jsonrpc: Literal["2.0"] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AgentRpcResponse: + """ + Handle JSON-RPC requests for an agent by its unique name. + + Args: + params: The parameters for the agent RPC request + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_name: + raise ValueError(f"Expected a non-empty value for `agent_name` but received {agent_name!r}") + return await self._post( + path_template("/agents/name/{agent_name}/rpc", agent_name=agent_name), + body=await async_maybe_transform( + { + "method": method, + "params": params, + "id": id, + "jsonrpc": jsonrpc, + }, + agent_rpc_by_name_params.AgentRpcByNameParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AgentRpcResponse, + ) + + async def create_task( + self, + agent_id: str | None = None, + agent_name: str | None = None, + *, + params: agent_rpc_params.ParamsCreateTaskRequest, + id: Union[int, str, None] | NotGiven = NOT_GIVEN, + jsonrpc: Literal["2.0"] | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> CreateTaskResponse: + if agent_id is not None and agent_name is not None: + raise ValueError("Either agent_id or agent_name must be provided, but not both") + + if agent_id is not None: + raw_agent_rpc_response = await self.rpc( + agent_id=agent_id, + method="task/create", + params=params, + id=id, + jsonrpc=jsonrpc, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + elif agent_name is not None: + raw_agent_rpc_response = await self.rpc_by_name( + agent_name=agent_name, + method="task/create", + params=params, + id=id, + jsonrpc=jsonrpc, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + else: + raise ValueError("Either agent_id or agent_name must be provided") + + return CreateTaskResponse.model_validate(raw_agent_rpc_response, from_attributes=True) + + async def cancel_task( + self, + agent_id: str | None = None, + agent_name: str | None = None, + *, + params: agent_rpc_params.ParamsCancelTaskRequest, + id: Union[int, str, None] | NotGiven = NOT_GIVEN, + jsonrpc: Literal["2.0"] | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> CancelTaskResponse: + if agent_id is not None and agent_name is not None: + raise ValueError("Either agent_id or agent_name must be provided, but not both") + + if agent_id is not None: + raw_agent_rpc_response = await self.rpc( + agent_id=agent_id, + method="task/cancel", + params=params, + id=id, + jsonrpc=jsonrpc, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + elif agent_name is not None: + raw_agent_rpc_response = await self.rpc_by_name( + agent_name=agent_name, + method="task/cancel", + params=params, + id=id, + jsonrpc=jsonrpc, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + else: + raise ValueError("Either agent_id or agent_name must be provided") + + return CancelTaskResponse.model_validate(raw_agent_rpc_response, from_attributes=True) + + async def send_message( + self, + agent_id: str | None = None, + agent_name: str | None = None, + *, + params: agent_rpc_params.ParamsSendMessageRequest, + id: Union[int, str, None] | NotGiven = NOT_GIVEN, + jsonrpc: Literal["2.0"] | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SendMessageResponse: + if agent_id is not None and agent_name is not None: + raise ValueError("Either agent_id or agent_name must be provided, but not both") + + if "stream" in params and params["stream"] == True: + raise ValueError("If stream is set to True, use send_message_stream() instead") + + if agent_id is not None: + raw_agent_rpc_response = self.with_streaming_response.rpc( + agent_id=agent_id, + method="message/send", + params=params, + id=id, + jsonrpc=jsonrpc, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + elif agent_name is not None: + raw_agent_rpc_response = self.with_streaming_response.rpc_by_name( + agent_name=agent_name, + method="message/send", + params=params, + id=id, + jsonrpc=jsonrpc, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + else: + raise ValueError("Either agent_id or agent_name must be provided") + + task_messages: list[Any] = [] + response_meta: dict[str, Any] = {} + + async with raw_agent_rpc_response as response: + async for _line in response.iter_lines(): + if not _line: + continue + line = _line.strip() + if line.startswith("data:"): + line = line[len("data:"):].strip() + if not line: + continue + try: + chunk = json.loads(line) + if not response_meta: + response_meta = {"id": chunk.get("id"), "jsonrpc": chunk.get("jsonrpc")} + try: + return SendMessageResponse.model_validate(chunk) + except ValidationError: + pass + chunk_stream = SendMessageStreamResponse.model_validate(chunk, from_attributes=True) + result = chunk_stream.result + if result is not None and getattr(result, "type", None) == "full": + parent = getattr(result, "parent_task_message", None) + if parent is not None: + task_messages.append(parent) + except (json.JSONDecodeError, ValidationError): + continue + + return SendMessageResponse( + id=response_meta.get("id"), + jsonrpc=response_meta.get("jsonrpc"), + result=task_messages, + ) + + async def send_message_stream( + self, + agent_id: str | None = None, + agent_name: str | None = None, + *, + params: agent_rpc_params.ParamsSendMessageRequest, + id: Union[int, str, None] | NotGiven = NOT_GIVEN, + jsonrpc: Literal["2.0"] | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> AsyncGenerator[SendMessageStreamResponse, None]: + if agent_id is not None and agent_name is not None: + raise ValueError("Either agent_id or agent_name must be provided, but not both") + + if "stream" in params and params["stream"] == False: + raise ValueError("If stream is set to False, use send_message() instead") + + params["stream"] = True + + if agent_id is not None: + raw_agent_rpc_response = self.with_streaming_response.rpc( + agent_id=agent_id, + method="message/send", + params=params, + id=id, + jsonrpc=jsonrpc, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + elif agent_name is not None: + raw_agent_rpc_response = self.with_streaming_response.rpc_by_name( + agent_name=agent_name, + method="message/send", + params=params, + id=id, + jsonrpc=jsonrpc, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + else: + raise ValueError("Either agent_id or agent_name must be provided") + + async with raw_agent_rpc_response as response: + async for _line in response.iter_lines(): + if not _line: + continue + line = _line.strip() + # Handle optional SSE-style prefix + if line.startswith("data:"): + line = line[len("data:"):].strip() + if not line: + continue + try: + chunk_rpc_response = SendMessageStreamResponse.model_validate( + json.loads(line), + from_attributes=True + ) + yield chunk_rpc_response + except json.JSONDecodeError: + # Skip invalid JSON lines + continue + except ValidationError as e: + raise ValueError(f"Invalid SendMessageStreamResponse returned: {line}") from e + + async def send_event( + self, + agent_id: str | None = None, + agent_name: str | None = None, + *, + params: agent_rpc_params.ParamsSendEventRequest, + id: Union[int, str, None] | NotGiven = NOT_GIVEN, + jsonrpc: Literal["2.0"] | NotGiven = NOT_GIVEN, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SendEventResponse: + if agent_id is not None and agent_name is not None: + raise ValueError("Either agent_id or agent_name must be provided, but not both") + + if agent_id is not None: + raw_agent_rpc_response = await self.rpc( + agent_id=agent_id, + method="event/send", + params=params, + id=id, + jsonrpc=jsonrpc, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + elif agent_name is not None: + raw_agent_rpc_response = await self.rpc_by_name( + agent_name=agent_name, + method="event/send", + params=params, + id=id, + jsonrpc=jsonrpc, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + else: + raise ValueError("Either agent_id or agent_name must be provided") + + return SendEventResponse.model_validate(raw_agent_rpc_response, from_attributes=True) + +class AgentsResourceWithRawResponse: + def __init__(self, agents: AgentsResource) -> None: + self._agents = agents + + self.retrieve = to_raw_response_wrapper( + agents.retrieve, + ) + self.list = to_raw_response_wrapper( + agents.list, + ) + self.delete = to_raw_response_wrapper( + agents.delete, + ) + self.delete_by_name = to_raw_response_wrapper( + agents.delete_by_name, + ) + self.register_build = to_raw_response_wrapper( + agents.register_build, + ) + self.retrieve_by_name = to_raw_response_wrapper( + agents.retrieve_by_name, + ) + self.rpc = to_raw_response_wrapper( + agents.rpc, + ) + self.rpc_by_name = to_raw_response_wrapper( + agents.rpc_by_name, + ) + + @cached_property + def deployments(self) -> DeploymentsResourceWithRawResponse: + return DeploymentsResourceWithRawResponse(self._agents.deployments) + + @cached_property + def schedules(self) -> SchedulesResourceWithRawResponse: + return SchedulesResourceWithRawResponse(self._agents.schedules) + + +class AsyncAgentsResourceWithRawResponse: + def __init__(self, agents: AsyncAgentsResource) -> None: + self._agents = agents + + self.retrieve = async_to_raw_response_wrapper( + agents.retrieve, + ) + self.list = async_to_raw_response_wrapper( + agents.list, + ) + self.delete = async_to_raw_response_wrapper( + agents.delete, + ) + self.delete_by_name = async_to_raw_response_wrapper( + agents.delete_by_name, + ) + self.register_build = async_to_raw_response_wrapper( + agents.register_build, + ) + self.retrieve_by_name = async_to_raw_response_wrapper( + agents.retrieve_by_name, + ) + self.rpc = async_to_raw_response_wrapper( + agents.rpc, + ) + self.rpc_by_name = async_to_raw_response_wrapper( + agents.rpc_by_name, + ) + + @cached_property + def deployments(self) -> AsyncDeploymentsResourceWithRawResponse: + return AsyncDeploymentsResourceWithRawResponse(self._agents.deployments) + + @cached_property + def schedules(self) -> AsyncSchedulesResourceWithRawResponse: + return AsyncSchedulesResourceWithRawResponse(self._agents.schedules) + + +class AgentsResourceWithStreamingResponse: + def __init__(self, agents: AgentsResource) -> None: + self._agents = agents + + self.retrieve = to_streamed_response_wrapper( + agents.retrieve, + ) + self.list = to_streamed_response_wrapper( + agents.list, + ) + self.delete = to_streamed_response_wrapper( + agents.delete, + ) + self.delete_by_name = to_streamed_response_wrapper( + agents.delete_by_name, + ) + self.register_build = to_streamed_response_wrapper( + agents.register_build, + ) + self.retrieve_by_name = to_streamed_response_wrapper( + agents.retrieve_by_name, + ) + self.rpc = to_streamed_response_wrapper( + agents.rpc, + ) + self.rpc_by_name = to_streamed_response_wrapper( + agents.rpc_by_name, + ) + + @cached_property + def deployments(self) -> DeploymentsResourceWithStreamingResponse: + return DeploymentsResourceWithStreamingResponse(self._agents.deployments) + + @cached_property + def schedules(self) -> SchedulesResourceWithStreamingResponse: + return SchedulesResourceWithStreamingResponse(self._agents.schedules) + + +class AsyncAgentsResourceWithStreamingResponse: + def __init__(self, agents: AsyncAgentsResource) -> None: + self._agents = agents + + self.retrieve = async_to_streamed_response_wrapper( + agents.retrieve, + ) + self.list = async_to_streamed_response_wrapper( + agents.list, + ) + self.delete = async_to_streamed_response_wrapper( + agents.delete, + ) + self.delete_by_name = async_to_streamed_response_wrapper( + agents.delete_by_name, + ) + self.register_build = async_to_streamed_response_wrapper( + agents.register_build, + ) + self.retrieve_by_name = async_to_streamed_response_wrapper( + agents.retrieve_by_name, + ) + self.rpc = async_to_streamed_response_wrapper( + agents.rpc, + ) + self.rpc_by_name = async_to_streamed_response_wrapper( + agents.rpc_by_name, + ) + + @cached_property + def deployments(self) -> AsyncDeploymentsResourceWithStreamingResponse: + return AsyncDeploymentsResourceWithStreamingResponse(self._agents.deployments) + + @cached_property + def schedules(self) -> AsyncSchedulesResourceWithStreamingResponse: + return AsyncSchedulesResourceWithStreamingResponse(self._agents.schedules) diff --git a/src/agentex/resources/agents/deployments.py b/src/agentex/resources/agents/deployments.py new file mode 100644 index 000000000..6bb15e61d --- /dev/null +++ b/src/agentex/resources/agents/deployments.py @@ -0,0 +1,725 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Union, Optional +from typing_extensions import Literal + +import httpx + +from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from ..._utils import path_template, maybe_transform, async_maybe_transform +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ..._base_client import make_request_options +from ...types.agents import deployment_list_params, deployment_create_params, deployment_preview_rpc_params +from ...types.agent_rpc_response import AgentRpcResponse +from ...types.shared.delete_response import DeleteResponse +from ...types.agents.deployment_list_response import DeploymentListResponse +from ...types.agents.deployment_create_response import DeploymentCreateResponse +from ...types.agents.deployment_promote_response import DeploymentPromoteResponse +from ...types.agents.deployment_retrieve_response import DeploymentRetrieveResponse + +__all__ = ["DeploymentsResource", "AsyncDeploymentsResource"] + + +class DeploymentsResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> DeploymentsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return DeploymentsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> DeploymentsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return DeploymentsResourceWithStreamingResponse(self) + + def create( + self, + agent_id: str, + *, + docker_image: str, + helm_release_name: Optional[str] | Omit = omit, + registration_metadata: Optional[Dict[str, object]] | Omit = omit, + sgp_deploy_id: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeploymentCreateResponse: + """ + Create a new deployment record in PENDING status. + + Args: + docker_image: Full Docker image URI. + + helm_release_name: Helm release name. + + registration_metadata: Git/build metadata (commit_hash, branch_name, author_name, author_email, + build_timestamp). + + sgp_deploy_id: SGP deployment ID. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + return self._post( + path_template("/agents/{agent_id}/deployments", agent_id=agent_id), + body=maybe_transform( + { + "docker_image": docker_image, + "helm_release_name": helm_release_name, + "registration_metadata": registration_metadata, + "sgp_deploy_id": sgp_deploy_id, + }, + deployment_create_params.DeploymentCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeploymentCreateResponse, + ) + + def retrieve( + self, + deployment_id: str, + *, + agent_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeploymentRetrieveResponse: + """ + Get a specific deployment by ID. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not deployment_id: + raise ValueError(f"Expected a non-empty value for `deployment_id` but received {deployment_id!r}") + return self._get( + path_template( + "/agents/{agent_id}/deployments/{deployment_id}", agent_id=agent_id, deployment_id=deployment_id + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeploymentRetrieveResponse, + ) + + def list( + self, + agent_id: str, + *, + limit: int | Omit = omit, + order_by: Optional[str] | Omit = omit, + order_direction: str | Omit = omit, + page_number: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeploymentListResponse: + """ + List deployments for an agent, newest first. + + Args: + limit: Limit + + order_by: Field to order by + + order_direction: Order direction (asc or desc) + + page_number: Page number + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + return self._get( + path_template("/agents/{agent_id}/deployments", agent_id=agent_id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "limit": limit, + "order_by": order_by, + "order_direction": order_direction, + "page_number": page_number, + }, + deployment_list_params.DeploymentListParams, + ), + ), + cast_to=DeploymentListResponse, + ) + + def delete( + self, + deployment_id: str, + *, + agent_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeleteResponse: + """ + Delete a non-production deployment. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not deployment_id: + raise ValueError(f"Expected a non-empty value for `deployment_id` but received {deployment_id!r}") + return self._delete( + path_template( + "/agents/{agent_id}/deployments/{deployment_id}", agent_id=agent_id, deployment_id=deployment_id + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeleteResponse, + ) + + def preview_rpc( + self, + deployment_id: str, + *, + agent_id: str, + method: Literal["event/send", "task/create", "message/send", "task/cancel", "task/interrupt"], + params: deployment_preview_rpc_params.Params, + id: Union[int, str, None] | Omit = omit, + jsonrpc: Literal["2.0"] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AgentRpcResponse: + """ + Send an RPC request to a specific deployment (for preview testing). + + Args: + params: The parameters for the agent RPC request + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not deployment_id: + raise ValueError(f"Expected a non-empty value for `deployment_id` but received {deployment_id!r}") + return self._post( + path_template( + "/agents/{agent_id}/deployments/{deployment_id}/rpc", agent_id=agent_id, deployment_id=deployment_id + ), + body=maybe_transform( + { + "method": method, + "params": params, + "id": id, + "jsonrpc": jsonrpc, + }, + deployment_preview_rpc_params.DeploymentPreviewRpcParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AgentRpcResponse, + ) + + def promote( + self, + deployment_id: str, + *, + agent_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeploymentPromoteResponse: + """ + Promote a deployment to production with atomic cutover. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not deployment_id: + raise ValueError(f"Expected a non-empty value for `deployment_id` but received {deployment_id!r}") + return self._post( + path_template( + "/agents/{agent_id}/deployments/{deployment_id}/promote", agent_id=agent_id, deployment_id=deployment_id + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeploymentPromoteResponse, + ) + + +class AsyncDeploymentsResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncDeploymentsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return AsyncDeploymentsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncDeploymentsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return AsyncDeploymentsResourceWithStreamingResponse(self) + + async def create( + self, + agent_id: str, + *, + docker_image: str, + helm_release_name: Optional[str] | Omit = omit, + registration_metadata: Optional[Dict[str, object]] | Omit = omit, + sgp_deploy_id: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeploymentCreateResponse: + """ + Create a new deployment record in PENDING status. + + Args: + docker_image: Full Docker image URI. + + helm_release_name: Helm release name. + + registration_metadata: Git/build metadata (commit_hash, branch_name, author_name, author_email, + build_timestamp). + + sgp_deploy_id: SGP deployment ID. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + return await self._post( + path_template("/agents/{agent_id}/deployments", agent_id=agent_id), + body=await async_maybe_transform( + { + "docker_image": docker_image, + "helm_release_name": helm_release_name, + "registration_metadata": registration_metadata, + "sgp_deploy_id": sgp_deploy_id, + }, + deployment_create_params.DeploymentCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeploymentCreateResponse, + ) + + async def retrieve( + self, + deployment_id: str, + *, + agent_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeploymentRetrieveResponse: + """ + Get a specific deployment by ID. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not deployment_id: + raise ValueError(f"Expected a non-empty value for `deployment_id` but received {deployment_id!r}") + return await self._get( + path_template( + "/agents/{agent_id}/deployments/{deployment_id}", agent_id=agent_id, deployment_id=deployment_id + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeploymentRetrieveResponse, + ) + + async def list( + self, + agent_id: str, + *, + limit: int | Omit = omit, + order_by: Optional[str] | Omit = omit, + order_direction: str | Omit = omit, + page_number: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeploymentListResponse: + """ + List deployments for an agent, newest first. + + Args: + limit: Limit + + order_by: Field to order by + + order_direction: Order direction (asc or desc) + + page_number: Page number + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + return await self._get( + path_template("/agents/{agent_id}/deployments", agent_id=agent_id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "limit": limit, + "order_by": order_by, + "order_direction": order_direction, + "page_number": page_number, + }, + deployment_list_params.DeploymentListParams, + ), + ), + cast_to=DeploymentListResponse, + ) + + async def delete( + self, + deployment_id: str, + *, + agent_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeleteResponse: + """ + Delete a non-production deployment. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not deployment_id: + raise ValueError(f"Expected a non-empty value for `deployment_id` but received {deployment_id!r}") + return await self._delete( + path_template( + "/agents/{agent_id}/deployments/{deployment_id}", agent_id=agent_id, deployment_id=deployment_id + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeleteResponse, + ) + + async def preview_rpc( + self, + deployment_id: str, + *, + agent_id: str, + method: Literal["event/send", "task/create", "message/send", "task/cancel", "task/interrupt"], + params: deployment_preview_rpc_params.Params, + id: Union[int, str, None] | Omit = omit, + jsonrpc: Literal["2.0"] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AgentRpcResponse: + """ + Send an RPC request to a specific deployment (for preview testing). + + Args: + params: The parameters for the agent RPC request + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not deployment_id: + raise ValueError(f"Expected a non-empty value for `deployment_id` but received {deployment_id!r}") + return await self._post( + path_template( + "/agents/{agent_id}/deployments/{deployment_id}/rpc", agent_id=agent_id, deployment_id=deployment_id + ), + body=await async_maybe_transform( + { + "method": method, + "params": params, + "id": id, + "jsonrpc": jsonrpc, + }, + deployment_preview_rpc_params.DeploymentPreviewRpcParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AgentRpcResponse, + ) + + async def promote( + self, + deployment_id: str, + *, + agent_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeploymentPromoteResponse: + """ + Promote a deployment to production with atomic cutover. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not deployment_id: + raise ValueError(f"Expected a non-empty value for `deployment_id` but received {deployment_id!r}") + return await self._post( + path_template( + "/agents/{agent_id}/deployments/{deployment_id}/promote", agent_id=agent_id, deployment_id=deployment_id + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeploymentPromoteResponse, + ) + + +class DeploymentsResourceWithRawResponse: + def __init__(self, deployments: DeploymentsResource) -> None: + self._deployments = deployments + + self.create = to_raw_response_wrapper( + deployments.create, + ) + self.retrieve = to_raw_response_wrapper( + deployments.retrieve, + ) + self.list = to_raw_response_wrapper( + deployments.list, + ) + self.delete = to_raw_response_wrapper( + deployments.delete, + ) + self.preview_rpc = to_raw_response_wrapper( + deployments.preview_rpc, + ) + self.promote = to_raw_response_wrapper( + deployments.promote, + ) + + +class AsyncDeploymentsResourceWithRawResponse: + def __init__(self, deployments: AsyncDeploymentsResource) -> None: + self._deployments = deployments + + self.create = async_to_raw_response_wrapper( + deployments.create, + ) + self.retrieve = async_to_raw_response_wrapper( + deployments.retrieve, + ) + self.list = async_to_raw_response_wrapper( + deployments.list, + ) + self.delete = async_to_raw_response_wrapper( + deployments.delete, + ) + self.preview_rpc = async_to_raw_response_wrapper( + deployments.preview_rpc, + ) + self.promote = async_to_raw_response_wrapper( + deployments.promote, + ) + + +class DeploymentsResourceWithStreamingResponse: + def __init__(self, deployments: DeploymentsResource) -> None: + self._deployments = deployments + + self.create = to_streamed_response_wrapper( + deployments.create, + ) + self.retrieve = to_streamed_response_wrapper( + deployments.retrieve, + ) + self.list = to_streamed_response_wrapper( + deployments.list, + ) + self.delete = to_streamed_response_wrapper( + deployments.delete, + ) + self.preview_rpc = to_streamed_response_wrapper( + deployments.preview_rpc, + ) + self.promote = to_streamed_response_wrapper( + deployments.promote, + ) + + +class AsyncDeploymentsResourceWithStreamingResponse: + def __init__(self, deployments: AsyncDeploymentsResource) -> None: + self._deployments = deployments + + self.create = async_to_streamed_response_wrapper( + deployments.create, + ) + self.retrieve = async_to_streamed_response_wrapper( + deployments.retrieve, + ) + self.list = async_to_streamed_response_wrapper( + deployments.list, + ) + self.delete = async_to_streamed_response_wrapper( + deployments.delete, + ) + self.preview_rpc = async_to_streamed_response_wrapper( + deployments.preview_rpc, + ) + self.promote = async_to_streamed_response_wrapper( + deployments.promote, + ) diff --git a/src/agentex/resources/agents/schedules.py b/src/agentex/resources/agents/schedules.py new file mode 100644 index 000000000..1750c513b --- /dev/null +++ b/src/agentex/resources/agents/schedules.py @@ -0,0 +1,1845 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Union, Optional +from datetime import datetime + +import httpx + +from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from ..._utils import path_template, maybe_transform, async_maybe_transform +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ..._base_client import make_request_options +from ...types.agents import ( + schedule_list_params, + schedule_skip_params, + schedule_pause_params, + schedule_create_params, + schedule_resume_params, + schedule_unskip_params, + schedule_update_params, + schedule_pause_by_name_params, + schedule_resume_by_name_params, + schedule_update_by_name_params, +) +from ...types.shared.delete_response import DeleteResponse +from ...types.agents.schedule_list_response import ScheduleListResponse +from ...types.agents.schedule_skip_response import ScheduleSkipResponse +from ...types.agents.schedule_pause_response import SchedulePauseResponse +from ...types.agents.schedule_create_response import ScheduleCreateResponse +from ...types.agents.schedule_resume_response import ScheduleResumeResponse +from ...types.agents.schedule_unskip_response import ScheduleUnskipResponse +from ...types.agents.schedule_update_response import ScheduleUpdateResponse +from ...types.agents.schedule_trigger_response import ScheduleTriggerResponse +from ...types.agents.schedule_retrieve_response import ScheduleRetrieveResponse +from ...types.agents.schedule_pause_by_name_response import SchedulePauseByNameResponse +from ...types.agents.schedule_resume_by_name_response import ScheduleResumeByNameResponse +from ...types.agents.schedule_update_by_name_response import ScheduleUpdateByNameResponse +from ...types.agents.schedule_trigger_by_name_response import ScheduleTriggerByNameResponse +from ...types.agents.schedule_retrieve_by_name_response import ScheduleRetrieveByNameResponse + +__all__ = ["SchedulesResource", "AsyncSchedulesResource"] + + +class SchedulesResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> SchedulesResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return SchedulesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> SchedulesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return SchedulesResourceWithStreamingResponse(self) + + def create( + self, + agent_id: str, + *, + initial_input: schedule_create_params.InitialInput, + name: str, + cron_expression: Optional[str] | Omit = omit, + description: Optional[str] | Omit = omit, + end_at: Union[str, datetime, None] | Omit = omit, + interval_seconds: Optional[int] | Omit = omit, + paused: bool | Omit = omit, + start_at: Union[str, datetime, None] | Omit = omit, + task_metadata: Optional[Dict[str, object]] | Omit = omit, + task_params: Optional[Dict[str, object]] | Omit = omit, + timezone: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ScheduleCreateResponse: + """ + Create a recurring schedule that starts a fresh agent run on each fire. + + Args: + initial_input: The first input delivered to each created task. + + name: Human-readable name, unique among active schedules for the agent. + + cron_expression: Cron expression for the cadence (e.g. '0 17 \\** \\** MON-FRI'). Mutually exclusive + with interval_seconds. + + description: Optional description of what this schedule does. + + end_at: When the schedule should stop being active. + + interval_seconds: Interval cadence in seconds. Mutually exclusive with cron_expression. + + paused: Whether to create the schedule in a paused state. + + start_at: When the schedule should start being active. + + task_metadata: Metadata copied onto each created task at fire time. + + task_params: Resolved config forwarded as task `params` at fire time. + + timezone: IANA timezone the cron expression is evaluated in (e.g. 'America/New_York'). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + return self._post( + path_template("/agents/{agent_id}/schedules", agent_id=agent_id), + body=maybe_transform( + { + "initial_input": initial_input, + "name": name, + "cron_expression": cron_expression, + "description": description, + "end_at": end_at, + "interval_seconds": interval_seconds, + "paused": paused, + "start_at": start_at, + "task_metadata": task_metadata, + "task_params": task_params, + "timezone": timezone, + }, + schedule_create_params.ScheduleCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ScheduleCreateResponse, + ) + + def retrieve( + self, + schedule_id: str, + *, + agent_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ScheduleRetrieveResponse: + """ + Get a run schedule by its id. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not schedule_id: + raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}") + return self._get( + path_template("/agents/{agent_id}/schedules/{schedule_id}", agent_id=agent_id, schedule_id=schedule_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ScheduleRetrieveResponse, + ) + + def update( + self, + schedule_id: str, + *, + agent_id: str, + cron_expression: Optional[str] | Omit = omit, + description: Optional[str] | Omit = omit, + end_at: Union[str, datetime, None] | Omit = omit, + initial_input: Optional[schedule_update_params.InitialInput] | Omit = omit, + interval_seconds: Optional[int] | Omit = omit, + name: Optional[str] | Omit = omit, + paused: Optional[bool] | Omit = omit, + start_at: Union[str, datetime, None] | Omit = omit, + task_metadata: Optional[Dict[str, object]] | Omit = omit, + task_params: Optional[Dict[str, object]] | Omit = omit, + timezone: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ScheduleUpdateResponse: + """ + Partially update a run schedule's definition (cadence, window, input, etc.). + + Args: + cron_expression: New cron cadence. Mutually exclusive with interval_seconds. + + description: Optional description of what this schedule does. + + end_at: When the schedule should stop being active. + + initial_input: The first input delivered to each freshly created scheduled task. + + interval_seconds: New interval cadence in seconds. Mutually exclusive with cron_expression. + + name: Human-readable name, unique among active schedules for the agent. + + paused: Pause/resume the schedule as part of the update. + + start_at: When the schedule should start being active. + + task_metadata: Metadata copied onto each created task at fire time. + + task_params: Resolved config forwarded as task `params` at fire time. + + timezone: IANA timezone the cron expression is evaluated in. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not schedule_id: + raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}") + return self._patch( + path_template("/agents/{agent_id}/schedules/{schedule_id}", agent_id=agent_id, schedule_id=schedule_id), + body=maybe_transform( + { + "cron_expression": cron_expression, + "description": description, + "end_at": end_at, + "initial_input": initial_input, + "interval_seconds": interval_seconds, + "name": name, + "paused": paused, + "start_at": start_at, + "task_metadata": task_metadata, + "task_params": task_params, + "timezone": timezone, + }, + schedule_update_params.ScheduleUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ScheduleUpdateResponse, + ) + + def list( + self, + agent_id: str, + *, + include_live: bool | Omit = omit, + limit: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ScheduleListResponse: + """ + List run schedules for an agent. + + Args: + include_live: Include live Temporal state and upcoming action times. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + return self._get( + path_template("/agents/{agent_id}/schedules", agent_id=agent_id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "include_live": include_live, + "limit": limit, + }, + schedule_list_params.ScheduleListParams, + ), + ), + cast_to=ScheduleListResponse, + ) + + def delete( + self, + schedule_id: str, + *, + agent_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeleteResponse: + """ + Delete a run schedule permanently. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not schedule_id: + raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}") + return self._delete( + path_template("/agents/{agent_id}/schedules/{schedule_id}", agent_id=agent_id, schedule_id=schedule_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeleteResponse, + ) + + def delete_by_name( + self, + name: str, + *, + agent_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeleteResponse: + """ + Delete a run schedule by its active name. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not name: + raise ValueError(f"Expected a non-empty value for `name` but received {name!r}") + return self._delete( + path_template("/agents/{agent_id}/schedules/name/{name}", agent_id=agent_id, name=name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeleteResponse, + ) + + def pause( + self, + schedule_id: str, + *, + agent_id: str, + note: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SchedulePauseResponse: + """ + Pause a run schedule so it stops firing. + + Args: + note: Optional note explaining the pause. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not schedule_id: + raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}") + return self._post( + path_template( + "/agents/{agent_id}/schedules/{schedule_id}/pause", agent_id=agent_id, schedule_id=schedule_id + ), + body=maybe_transform({"note": note}, schedule_pause_params.SchedulePauseParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=SchedulePauseResponse, + ) + + def pause_by_name( + self, + name: str, + *, + agent_id: str, + note: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SchedulePauseByNameResponse: + """ + Pause a run schedule by its active name. + + Args: + note: Optional note explaining the pause. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not name: + raise ValueError(f"Expected a non-empty value for `name` but received {name!r}") + return self._post( + path_template("/agents/{agent_id}/schedules/name/{name}/pause", agent_id=agent_id, name=name), + body=maybe_transform({"note": note}, schedule_pause_by_name_params.SchedulePauseByNameParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=SchedulePauseByNameResponse, + ) + + def resume( + self, + schedule_id: str, + *, + agent_id: str, + note: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ScheduleResumeResponse: + """ + Resume a paused run schedule so it fires again. + + Args: + note: Optional note explaining the resume. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not schedule_id: + raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}") + return self._post( + path_template( + "/agents/{agent_id}/schedules/{schedule_id}/resume", agent_id=agent_id, schedule_id=schedule_id + ), + body=maybe_transform({"note": note}, schedule_resume_params.ScheduleResumeParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ScheduleResumeResponse, + ) + + def resume_by_name( + self, + name: str, + *, + agent_id: str, + note: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ScheduleResumeByNameResponse: + """ + Resume a paused run schedule by its active name. + + Args: + note: Optional note explaining the resume. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not name: + raise ValueError(f"Expected a non-empty value for `name` but received {name!r}") + return self._post( + path_template("/agents/{agent_id}/schedules/name/{name}/resume", agent_id=agent_id, name=name), + body=maybe_transform({"note": note}, schedule_resume_by_name_params.ScheduleResumeByNameParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ScheduleResumeByNameResponse, + ) + + def retrieve_by_name( + self, + name: str, + *, + agent_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ScheduleRetrieveByNameResponse: + """ + Get a run schedule by its active name. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not name: + raise ValueError(f"Expected a non-empty value for `name` but received {name!r}") + return self._get( + path_template("/agents/{agent_id}/schedules/name/{name}", agent_id=agent_id, name=name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ScheduleRetrieveByNameResponse, + ) + + def skip( + self, + schedule_id: str, + *, + agent_id: str, + scheduled_time: Union[str, datetime], + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ScheduleSkipResponse: + """ + Skip a recurring fire of the schedule. + + Args: + scheduled_time: Specific scheduled fire time to skip. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not schedule_id: + raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}") + return self._post( + path_template( + "/agents/{agent_id}/schedules/{schedule_id}/skip", agent_id=agent_id, schedule_id=schedule_id + ), + body=maybe_transform({"scheduled_time": scheduled_time}, schedule_skip_params.ScheduleSkipParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ScheduleSkipResponse, + ) + + def trigger( + self, + schedule_id: str, + *, + agent_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ScheduleTriggerResponse: + """ + Trigger an immediate, out-of-band run of the schedule (in addition to its + cadence). + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not schedule_id: + raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}") + return self._post( + path_template( + "/agents/{agent_id}/schedules/{schedule_id}/trigger", agent_id=agent_id, schedule_id=schedule_id + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ScheduleTriggerResponse, + ) + + def trigger_by_name( + self, + name: str, + *, + agent_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ScheduleTriggerByNameResponse: + """ + Trigger an immediate, out-of-band run of the schedule by its active name. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not name: + raise ValueError(f"Expected a non-empty value for `name` but received {name!r}") + return self._post( + path_template("/agents/{agent_id}/schedules/name/{name}/trigger", agent_id=agent_id, name=name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ScheduleTriggerByNameResponse, + ) + + def unskip( + self, + schedule_id: str, + *, + agent_id: str, + scheduled_time: Union[str, datetime], + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ScheduleUnskipResponse: + """ + Remove a skip for a recurring fire of the schedule. + + Args: + scheduled_time: Specific scheduled fire time to unskip. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not schedule_id: + raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}") + return self._post( + path_template( + "/agents/{agent_id}/schedules/{schedule_id}/unskip", agent_id=agent_id, schedule_id=schedule_id + ), + body=maybe_transform({"scheduled_time": scheduled_time}, schedule_unskip_params.ScheduleUnskipParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ScheduleUnskipResponse, + ) + + def update_by_name( + self, + path_name: str, + *, + agent_id: str, + cron_expression: Optional[str] | Omit = omit, + description: Optional[str] | Omit = omit, + end_at: Union[str, datetime, None] | Omit = omit, + initial_input: Optional[schedule_update_by_name_params.InitialInput] | Omit = omit, + interval_seconds: Optional[int] | Omit = omit, + body_name: Optional[str] | Omit = omit, + paused: Optional[bool] | Omit = omit, + start_at: Union[str, datetime, None] | Omit = omit, + task_metadata: Optional[Dict[str, object]] | Omit = omit, + task_params: Optional[Dict[str, object]] | Omit = omit, + timezone: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ScheduleUpdateByNameResponse: + """ + Partially update a run schedule's definition by its active name. + + Args: + cron_expression: New cron cadence. Mutually exclusive with interval_seconds. + + description: Optional description of what this schedule does. + + end_at: When the schedule should stop being active. + + initial_input: The first input delivered to each freshly created scheduled task. + + interval_seconds: New interval cadence in seconds. Mutually exclusive with cron_expression. + + body_name: Human-readable name, unique among active schedules for the agent. + + paused: Pause/resume the schedule as part of the update. + + start_at: When the schedule should start being active. + + task_metadata: Metadata copied onto each created task at fire time. + + task_params: Resolved config forwarded as task `params` at fire time. + + timezone: IANA timezone the cron expression is evaluated in. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not path_name: + raise ValueError(f"Expected a non-empty value for `path_name` but received {path_name!r}") + return self._patch( + path_template("/agents/{agent_id}/schedules/name/{path_name}", agent_id=agent_id, path_name=path_name), + body=maybe_transform( + { + "cron_expression": cron_expression, + "description": description, + "end_at": end_at, + "initial_input": initial_input, + "interval_seconds": interval_seconds, + "body_name": body_name, + "paused": paused, + "start_at": start_at, + "task_metadata": task_metadata, + "task_params": task_params, + "timezone": timezone, + }, + schedule_update_by_name_params.ScheduleUpdateByNameParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ScheduleUpdateByNameResponse, + ) + + +class AsyncSchedulesResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncSchedulesResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return AsyncSchedulesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncSchedulesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return AsyncSchedulesResourceWithStreamingResponse(self) + + async def create( + self, + agent_id: str, + *, + initial_input: schedule_create_params.InitialInput, + name: str, + cron_expression: Optional[str] | Omit = omit, + description: Optional[str] | Omit = omit, + end_at: Union[str, datetime, None] | Omit = omit, + interval_seconds: Optional[int] | Omit = omit, + paused: bool | Omit = omit, + start_at: Union[str, datetime, None] | Omit = omit, + task_metadata: Optional[Dict[str, object]] | Omit = omit, + task_params: Optional[Dict[str, object]] | Omit = omit, + timezone: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ScheduleCreateResponse: + """ + Create a recurring schedule that starts a fresh agent run on each fire. + + Args: + initial_input: The first input delivered to each created task. + + name: Human-readable name, unique among active schedules for the agent. + + cron_expression: Cron expression for the cadence (e.g. '0 17 \\** \\** MON-FRI'). Mutually exclusive + with interval_seconds. + + description: Optional description of what this schedule does. + + end_at: When the schedule should stop being active. + + interval_seconds: Interval cadence in seconds. Mutually exclusive with cron_expression. + + paused: Whether to create the schedule in a paused state. + + start_at: When the schedule should start being active. + + task_metadata: Metadata copied onto each created task at fire time. + + task_params: Resolved config forwarded as task `params` at fire time. + + timezone: IANA timezone the cron expression is evaluated in (e.g. 'America/New_York'). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + return await self._post( + path_template("/agents/{agent_id}/schedules", agent_id=agent_id), + body=await async_maybe_transform( + { + "initial_input": initial_input, + "name": name, + "cron_expression": cron_expression, + "description": description, + "end_at": end_at, + "interval_seconds": interval_seconds, + "paused": paused, + "start_at": start_at, + "task_metadata": task_metadata, + "task_params": task_params, + "timezone": timezone, + }, + schedule_create_params.ScheduleCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ScheduleCreateResponse, + ) + + async def retrieve( + self, + schedule_id: str, + *, + agent_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ScheduleRetrieveResponse: + """ + Get a run schedule by its id. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not schedule_id: + raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}") + return await self._get( + path_template("/agents/{agent_id}/schedules/{schedule_id}", agent_id=agent_id, schedule_id=schedule_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ScheduleRetrieveResponse, + ) + + async def update( + self, + schedule_id: str, + *, + agent_id: str, + cron_expression: Optional[str] | Omit = omit, + description: Optional[str] | Omit = omit, + end_at: Union[str, datetime, None] | Omit = omit, + initial_input: Optional[schedule_update_params.InitialInput] | Omit = omit, + interval_seconds: Optional[int] | Omit = omit, + name: Optional[str] | Omit = omit, + paused: Optional[bool] | Omit = omit, + start_at: Union[str, datetime, None] | Omit = omit, + task_metadata: Optional[Dict[str, object]] | Omit = omit, + task_params: Optional[Dict[str, object]] | Omit = omit, + timezone: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ScheduleUpdateResponse: + """ + Partially update a run schedule's definition (cadence, window, input, etc.). + + Args: + cron_expression: New cron cadence. Mutually exclusive with interval_seconds. + + description: Optional description of what this schedule does. + + end_at: When the schedule should stop being active. + + initial_input: The first input delivered to each freshly created scheduled task. + + interval_seconds: New interval cadence in seconds. Mutually exclusive with cron_expression. + + name: Human-readable name, unique among active schedules for the agent. + + paused: Pause/resume the schedule as part of the update. + + start_at: When the schedule should start being active. + + task_metadata: Metadata copied onto each created task at fire time. + + task_params: Resolved config forwarded as task `params` at fire time. + + timezone: IANA timezone the cron expression is evaluated in. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not schedule_id: + raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}") + return await self._patch( + path_template("/agents/{agent_id}/schedules/{schedule_id}", agent_id=agent_id, schedule_id=schedule_id), + body=await async_maybe_transform( + { + "cron_expression": cron_expression, + "description": description, + "end_at": end_at, + "initial_input": initial_input, + "interval_seconds": interval_seconds, + "name": name, + "paused": paused, + "start_at": start_at, + "task_metadata": task_metadata, + "task_params": task_params, + "timezone": timezone, + }, + schedule_update_params.ScheduleUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ScheduleUpdateResponse, + ) + + async def list( + self, + agent_id: str, + *, + include_live: bool | Omit = omit, + limit: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ScheduleListResponse: + """ + List run schedules for an agent. + + Args: + include_live: Include live Temporal state and upcoming action times. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + return await self._get( + path_template("/agents/{agent_id}/schedules", agent_id=agent_id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "include_live": include_live, + "limit": limit, + }, + schedule_list_params.ScheduleListParams, + ), + ), + cast_to=ScheduleListResponse, + ) + + async def delete( + self, + schedule_id: str, + *, + agent_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeleteResponse: + """ + Delete a run schedule permanently. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not schedule_id: + raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}") + return await self._delete( + path_template("/agents/{agent_id}/schedules/{schedule_id}", agent_id=agent_id, schedule_id=schedule_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeleteResponse, + ) + + async def delete_by_name( + self, + name: str, + *, + agent_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeleteResponse: + """ + Delete a run schedule by its active name. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not name: + raise ValueError(f"Expected a non-empty value for `name` but received {name!r}") + return await self._delete( + path_template("/agents/{agent_id}/schedules/name/{name}", agent_id=agent_id, name=name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeleteResponse, + ) + + async def pause( + self, + schedule_id: str, + *, + agent_id: str, + note: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SchedulePauseResponse: + """ + Pause a run schedule so it stops firing. + + Args: + note: Optional note explaining the pause. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not schedule_id: + raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}") + return await self._post( + path_template( + "/agents/{agent_id}/schedules/{schedule_id}/pause", agent_id=agent_id, schedule_id=schedule_id + ), + body=await async_maybe_transform({"note": note}, schedule_pause_params.SchedulePauseParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=SchedulePauseResponse, + ) + + async def pause_by_name( + self, + name: str, + *, + agent_id: str, + note: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SchedulePauseByNameResponse: + """ + Pause a run schedule by its active name. + + Args: + note: Optional note explaining the pause. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not name: + raise ValueError(f"Expected a non-empty value for `name` but received {name!r}") + return await self._post( + path_template("/agents/{agent_id}/schedules/name/{name}/pause", agent_id=agent_id, name=name), + body=await async_maybe_transform({"note": note}, schedule_pause_by_name_params.SchedulePauseByNameParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=SchedulePauseByNameResponse, + ) + + async def resume( + self, + schedule_id: str, + *, + agent_id: str, + note: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ScheduleResumeResponse: + """ + Resume a paused run schedule so it fires again. + + Args: + note: Optional note explaining the resume. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not schedule_id: + raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}") + return await self._post( + path_template( + "/agents/{agent_id}/schedules/{schedule_id}/resume", agent_id=agent_id, schedule_id=schedule_id + ), + body=await async_maybe_transform({"note": note}, schedule_resume_params.ScheduleResumeParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ScheduleResumeResponse, + ) + + async def resume_by_name( + self, + name: str, + *, + agent_id: str, + note: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ScheduleResumeByNameResponse: + """ + Resume a paused run schedule by its active name. + + Args: + note: Optional note explaining the resume. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not name: + raise ValueError(f"Expected a non-empty value for `name` but received {name!r}") + return await self._post( + path_template("/agents/{agent_id}/schedules/name/{name}/resume", agent_id=agent_id, name=name), + body=await async_maybe_transform({"note": note}, schedule_resume_by_name_params.ScheduleResumeByNameParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ScheduleResumeByNameResponse, + ) + + async def retrieve_by_name( + self, + name: str, + *, + agent_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ScheduleRetrieveByNameResponse: + """ + Get a run schedule by its active name. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not name: + raise ValueError(f"Expected a non-empty value for `name` but received {name!r}") + return await self._get( + path_template("/agents/{agent_id}/schedules/name/{name}", agent_id=agent_id, name=name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ScheduleRetrieveByNameResponse, + ) + + async def skip( + self, + schedule_id: str, + *, + agent_id: str, + scheduled_time: Union[str, datetime], + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ScheduleSkipResponse: + """ + Skip a recurring fire of the schedule. + + Args: + scheduled_time: Specific scheduled fire time to skip. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not schedule_id: + raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}") + return await self._post( + path_template( + "/agents/{agent_id}/schedules/{schedule_id}/skip", agent_id=agent_id, schedule_id=schedule_id + ), + body=await async_maybe_transform( + {"scheduled_time": scheduled_time}, schedule_skip_params.ScheduleSkipParams + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ScheduleSkipResponse, + ) + + async def trigger( + self, + schedule_id: str, + *, + agent_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ScheduleTriggerResponse: + """ + Trigger an immediate, out-of-band run of the schedule (in addition to its + cadence). + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not schedule_id: + raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}") + return await self._post( + path_template( + "/agents/{agent_id}/schedules/{schedule_id}/trigger", agent_id=agent_id, schedule_id=schedule_id + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ScheduleTriggerResponse, + ) + + async def trigger_by_name( + self, + name: str, + *, + agent_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ScheduleTriggerByNameResponse: + """ + Trigger an immediate, out-of-band run of the schedule by its active name. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not name: + raise ValueError(f"Expected a non-empty value for `name` but received {name!r}") + return await self._post( + path_template("/agents/{agent_id}/schedules/name/{name}/trigger", agent_id=agent_id, name=name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ScheduleTriggerByNameResponse, + ) + + async def unskip( + self, + schedule_id: str, + *, + agent_id: str, + scheduled_time: Union[str, datetime], + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ScheduleUnskipResponse: + """ + Remove a skip for a recurring fire of the schedule. + + Args: + scheduled_time: Specific scheduled fire time to unskip. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not schedule_id: + raise ValueError(f"Expected a non-empty value for `schedule_id` but received {schedule_id!r}") + return await self._post( + path_template( + "/agents/{agent_id}/schedules/{schedule_id}/unskip", agent_id=agent_id, schedule_id=schedule_id + ), + body=await async_maybe_transform( + {"scheduled_time": scheduled_time}, schedule_unskip_params.ScheduleUnskipParams + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ScheduleUnskipResponse, + ) + + async def update_by_name( + self, + path_name: str, + *, + agent_id: str, + cron_expression: Optional[str] | Omit = omit, + description: Optional[str] | Omit = omit, + end_at: Union[str, datetime, None] | Omit = omit, + initial_input: Optional[schedule_update_by_name_params.InitialInput] | Omit = omit, + interval_seconds: Optional[int] | Omit = omit, + body_name: Optional[str] | Omit = omit, + paused: Optional[bool] | Omit = omit, + start_at: Union[str, datetime, None] | Omit = omit, + task_metadata: Optional[Dict[str, object]] | Omit = omit, + task_params: Optional[Dict[str, object]] | Omit = omit, + timezone: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ScheduleUpdateByNameResponse: + """ + Partially update a run schedule's definition by its active name. + + Args: + cron_expression: New cron cadence. Mutually exclusive with interval_seconds. + + description: Optional description of what this schedule does. + + end_at: When the schedule should stop being active. + + initial_input: The first input delivered to each freshly created scheduled task. + + interval_seconds: New interval cadence in seconds. Mutually exclusive with cron_expression. + + body_name: Human-readable name, unique among active schedules for the agent. + + paused: Pause/resume the schedule as part of the update. + + start_at: When the schedule should start being active. + + task_metadata: Metadata copied onto each created task at fire time. + + task_params: Resolved config forwarded as task `params` at fire time. + + timezone: IANA timezone the cron expression is evaluated in. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not agent_id: + raise ValueError(f"Expected a non-empty value for `agent_id` but received {agent_id!r}") + if not path_name: + raise ValueError(f"Expected a non-empty value for `path_name` but received {path_name!r}") + return await self._patch( + path_template("/agents/{agent_id}/schedules/name/{path_name}", agent_id=agent_id, path_name=path_name), + body=await async_maybe_transform( + { + "cron_expression": cron_expression, + "description": description, + "end_at": end_at, + "initial_input": initial_input, + "interval_seconds": interval_seconds, + "body_name": body_name, + "paused": paused, + "start_at": start_at, + "task_metadata": task_metadata, + "task_params": task_params, + "timezone": timezone, + }, + schedule_update_by_name_params.ScheduleUpdateByNameParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ScheduleUpdateByNameResponse, + ) + + +class SchedulesResourceWithRawResponse: + def __init__(self, schedules: SchedulesResource) -> None: + self._schedules = schedules + + self.create = to_raw_response_wrapper( + schedules.create, + ) + self.retrieve = to_raw_response_wrapper( + schedules.retrieve, + ) + self.update = to_raw_response_wrapper( + schedules.update, + ) + self.list = to_raw_response_wrapper( + schedules.list, + ) + self.delete = to_raw_response_wrapper( + schedules.delete, + ) + self.delete_by_name = to_raw_response_wrapper( + schedules.delete_by_name, + ) + self.pause = to_raw_response_wrapper( + schedules.pause, + ) + self.pause_by_name = to_raw_response_wrapper( + schedules.pause_by_name, + ) + self.resume = to_raw_response_wrapper( + schedules.resume, + ) + self.resume_by_name = to_raw_response_wrapper( + schedules.resume_by_name, + ) + self.retrieve_by_name = to_raw_response_wrapper( + schedules.retrieve_by_name, + ) + self.skip = to_raw_response_wrapper( + schedules.skip, + ) + self.trigger = to_raw_response_wrapper( + schedules.trigger, + ) + self.trigger_by_name = to_raw_response_wrapper( + schedules.trigger_by_name, + ) + self.unskip = to_raw_response_wrapper( + schedules.unskip, + ) + self.update_by_name = to_raw_response_wrapper( + schedules.update_by_name, + ) + + +class AsyncSchedulesResourceWithRawResponse: + def __init__(self, schedules: AsyncSchedulesResource) -> None: + self._schedules = schedules + + self.create = async_to_raw_response_wrapper( + schedules.create, + ) + self.retrieve = async_to_raw_response_wrapper( + schedules.retrieve, + ) + self.update = async_to_raw_response_wrapper( + schedules.update, + ) + self.list = async_to_raw_response_wrapper( + schedules.list, + ) + self.delete = async_to_raw_response_wrapper( + schedules.delete, + ) + self.delete_by_name = async_to_raw_response_wrapper( + schedules.delete_by_name, + ) + self.pause = async_to_raw_response_wrapper( + schedules.pause, + ) + self.pause_by_name = async_to_raw_response_wrapper( + schedules.pause_by_name, + ) + self.resume = async_to_raw_response_wrapper( + schedules.resume, + ) + self.resume_by_name = async_to_raw_response_wrapper( + schedules.resume_by_name, + ) + self.retrieve_by_name = async_to_raw_response_wrapper( + schedules.retrieve_by_name, + ) + self.skip = async_to_raw_response_wrapper( + schedules.skip, + ) + self.trigger = async_to_raw_response_wrapper( + schedules.trigger, + ) + self.trigger_by_name = async_to_raw_response_wrapper( + schedules.trigger_by_name, + ) + self.unskip = async_to_raw_response_wrapper( + schedules.unskip, + ) + self.update_by_name = async_to_raw_response_wrapper( + schedules.update_by_name, + ) + + +class SchedulesResourceWithStreamingResponse: + def __init__(self, schedules: SchedulesResource) -> None: + self._schedules = schedules + + self.create = to_streamed_response_wrapper( + schedules.create, + ) + self.retrieve = to_streamed_response_wrapper( + schedules.retrieve, + ) + self.update = to_streamed_response_wrapper( + schedules.update, + ) + self.list = to_streamed_response_wrapper( + schedules.list, + ) + self.delete = to_streamed_response_wrapper( + schedules.delete, + ) + self.delete_by_name = to_streamed_response_wrapper( + schedules.delete_by_name, + ) + self.pause = to_streamed_response_wrapper( + schedules.pause, + ) + self.pause_by_name = to_streamed_response_wrapper( + schedules.pause_by_name, + ) + self.resume = to_streamed_response_wrapper( + schedules.resume, + ) + self.resume_by_name = to_streamed_response_wrapper( + schedules.resume_by_name, + ) + self.retrieve_by_name = to_streamed_response_wrapper( + schedules.retrieve_by_name, + ) + self.skip = to_streamed_response_wrapper( + schedules.skip, + ) + self.trigger = to_streamed_response_wrapper( + schedules.trigger, + ) + self.trigger_by_name = to_streamed_response_wrapper( + schedules.trigger_by_name, + ) + self.unskip = to_streamed_response_wrapper( + schedules.unskip, + ) + self.update_by_name = to_streamed_response_wrapper( + schedules.update_by_name, + ) + + +class AsyncSchedulesResourceWithStreamingResponse: + def __init__(self, schedules: AsyncSchedulesResource) -> None: + self._schedules = schedules + + self.create = async_to_streamed_response_wrapper( + schedules.create, + ) + self.retrieve = async_to_streamed_response_wrapper( + schedules.retrieve, + ) + self.update = async_to_streamed_response_wrapper( + schedules.update, + ) + self.list = async_to_streamed_response_wrapper( + schedules.list, + ) + self.delete = async_to_streamed_response_wrapper( + schedules.delete, + ) + self.delete_by_name = async_to_streamed_response_wrapper( + schedules.delete_by_name, + ) + self.pause = async_to_streamed_response_wrapper( + schedules.pause, + ) + self.pause_by_name = async_to_streamed_response_wrapper( + schedules.pause_by_name, + ) + self.resume = async_to_streamed_response_wrapper( + schedules.resume, + ) + self.resume_by_name = async_to_streamed_response_wrapper( + schedules.resume_by_name, + ) + self.retrieve_by_name = async_to_streamed_response_wrapper( + schedules.retrieve_by_name, + ) + self.skip = async_to_streamed_response_wrapper( + schedules.skip, + ) + self.trigger = async_to_streamed_response_wrapper( + schedules.trigger, + ) + self.trigger_by_name = async_to_streamed_response_wrapper( + schedules.trigger_by_name, + ) + self.unskip = async_to_streamed_response_wrapper( + schedules.unskip, + ) + self.update_by_name = async_to_streamed_response_wrapper( + schedules.update_by_name, + ) diff --git a/src/agentex/resources/checkpoints.py b/src/agentex/resources/checkpoints.py new file mode 100644 index 000000000..87e0f0c8b --- /dev/null +++ b/src/agentex/resources/checkpoints.py @@ -0,0 +1,589 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Iterable, Optional + +import httpx + +from ..types import ( + checkpoint_put_params, + checkpoint_list_params, + checkpoint_get_tuple_params, + checkpoint_put_writes_params, + checkpoint_delete_thread_params, +) +from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.checkpoint_put_response import CheckpointPutResponse +from ..types.checkpoint_list_response import CheckpointListResponse +from ..types.checkpoint_get_tuple_response import CheckpointGetTupleResponse + +__all__ = ["CheckpointsResource", "AsyncCheckpointsResource"] + + +class CheckpointsResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> CheckpointsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return CheckpointsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> CheckpointsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return CheckpointsResourceWithStreamingResponse(self) + + def list( + self, + *, + thread_id: str, + before_checkpoint_id: Optional[str] | Omit = omit, + checkpoint_ns: Optional[str] | Omit = omit, + filter_metadata: Optional[Dict[str, object]] | Omit = omit, + limit: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> CheckpointListResponse: + """ + List Checkpoints + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/checkpoints/list", + body=maybe_transform( + { + "thread_id": thread_id, + "before_checkpoint_id": before_checkpoint_id, + "checkpoint_ns": checkpoint_ns, + "filter_metadata": filter_metadata, + "limit": limit, + }, + checkpoint_list_params.CheckpointListParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CheckpointListResponse, + ) + + def delete_thread( + self, + *, + thread_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + """ + Delete Thread + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._post( + "/checkpoints/delete-thread", + body=maybe_transform( + {"thread_id": thread_id}, checkpoint_delete_thread_params.CheckpointDeleteThreadParams + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + def get_tuple( + self, + *, + thread_id: str, + checkpoint_id: Optional[str] | Omit = omit, + checkpoint_ns: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Optional[CheckpointGetTupleResponse]: + """ + Get Checkpoint Tuple + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/checkpoints/get-tuple", + body=maybe_transform( + { + "thread_id": thread_id, + "checkpoint_id": checkpoint_id, + "checkpoint_ns": checkpoint_ns, + }, + checkpoint_get_tuple_params.CheckpointGetTupleParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CheckpointGetTupleResponse, + ) + + def put( + self, + *, + checkpoint: Dict[str, object], + checkpoint_id: str, + thread_id: str, + blobs: Iterable[checkpoint_put_params.Blob] | Omit = omit, + checkpoint_ns: str | Omit = omit, + metadata: Dict[str, object] | Omit = omit, + parent_checkpoint_id: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> CheckpointPutResponse: + """ + Put Checkpoint + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/checkpoints/put", + body=maybe_transform( + { + "checkpoint": checkpoint, + "checkpoint_id": checkpoint_id, + "thread_id": thread_id, + "blobs": blobs, + "checkpoint_ns": checkpoint_ns, + "metadata": metadata, + "parent_checkpoint_id": parent_checkpoint_id, + }, + checkpoint_put_params.CheckpointPutParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CheckpointPutResponse, + ) + + def put_writes( + self, + *, + checkpoint_id: str, + thread_id: str, + writes: Iterable[checkpoint_put_writes_params.Write], + checkpoint_ns: str | Omit = omit, + upsert: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + """ + Put Writes + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._post( + "/checkpoints/put-writes", + body=maybe_transform( + { + "checkpoint_id": checkpoint_id, + "thread_id": thread_id, + "writes": writes, + "checkpoint_ns": checkpoint_ns, + "upsert": upsert, + }, + checkpoint_put_writes_params.CheckpointPutWritesParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + +class AsyncCheckpointsResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncCheckpointsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return AsyncCheckpointsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncCheckpointsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return AsyncCheckpointsResourceWithStreamingResponse(self) + + async def list( + self, + *, + thread_id: str, + before_checkpoint_id: Optional[str] | Omit = omit, + checkpoint_ns: Optional[str] | Omit = omit, + filter_metadata: Optional[Dict[str, object]] | Omit = omit, + limit: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> CheckpointListResponse: + """ + List Checkpoints + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/checkpoints/list", + body=await async_maybe_transform( + { + "thread_id": thread_id, + "before_checkpoint_id": before_checkpoint_id, + "checkpoint_ns": checkpoint_ns, + "filter_metadata": filter_metadata, + "limit": limit, + }, + checkpoint_list_params.CheckpointListParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CheckpointListResponse, + ) + + async def delete_thread( + self, + *, + thread_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + """ + Delete Thread + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._post( + "/checkpoints/delete-thread", + body=await async_maybe_transform( + {"thread_id": thread_id}, checkpoint_delete_thread_params.CheckpointDeleteThreadParams + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + async def get_tuple( + self, + *, + thread_id: str, + checkpoint_id: Optional[str] | Omit = omit, + checkpoint_ns: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Optional[CheckpointGetTupleResponse]: + """ + Get Checkpoint Tuple + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/checkpoints/get-tuple", + body=await async_maybe_transform( + { + "thread_id": thread_id, + "checkpoint_id": checkpoint_id, + "checkpoint_ns": checkpoint_ns, + }, + checkpoint_get_tuple_params.CheckpointGetTupleParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CheckpointGetTupleResponse, + ) + + async def put( + self, + *, + checkpoint: Dict[str, object], + checkpoint_id: str, + thread_id: str, + blobs: Iterable[checkpoint_put_params.Blob] | Omit = omit, + checkpoint_ns: str | Omit = omit, + metadata: Dict[str, object] | Omit = omit, + parent_checkpoint_id: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> CheckpointPutResponse: + """ + Put Checkpoint + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/checkpoints/put", + body=await async_maybe_transform( + { + "checkpoint": checkpoint, + "checkpoint_id": checkpoint_id, + "thread_id": thread_id, + "blobs": blobs, + "checkpoint_ns": checkpoint_ns, + "metadata": metadata, + "parent_checkpoint_id": parent_checkpoint_id, + }, + checkpoint_put_params.CheckpointPutParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CheckpointPutResponse, + ) + + async def put_writes( + self, + *, + checkpoint_id: str, + thread_id: str, + writes: Iterable[checkpoint_put_writes_params.Write], + checkpoint_ns: str | Omit = omit, + upsert: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + """ + Put Writes + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._post( + "/checkpoints/put-writes", + body=await async_maybe_transform( + { + "checkpoint_id": checkpoint_id, + "thread_id": thread_id, + "writes": writes, + "checkpoint_ns": checkpoint_ns, + "upsert": upsert, + }, + checkpoint_put_writes_params.CheckpointPutWritesParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + +class CheckpointsResourceWithRawResponse: + def __init__(self, checkpoints: CheckpointsResource) -> None: + self._checkpoints = checkpoints + + self.list = to_raw_response_wrapper( + checkpoints.list, + ) + self.delete_thread = to_raw_response_wrapper( + checkpoints.delete_thread, + ) + self.get_tuple = to_raw_response_wrapper( + checkpoints.get_tuple, + ) + self.put = to_raw_response_wrapper( + checkpoints.put, + ) + self.put_writes = to_raw_response_wrapper( + checkpoints.put_writes, + ) + + +class AsyncCheckpointsResourceWithRawResponse: + def __init__(self, checkpoints: AsyncCheckpointsResource) -> None: + self._checkpoints = checkpoints + + self.list = async_to_raw_response_wrapper( + checkpoints.list, + ) + self.delete_thread = async_to_raw_response_wrapper( + checkpoints.delete_thread, + ) + self.get_tuple = async_to_raw_response_wrapper( + checkpoints.get_tuple, + ) + self.put = async_to_raw_response_wrapper( + checkpoints.put, + ) + self.put_writes = async_to_raw_response_wrapper( + checkpoints.put_writes, + ) + + +class CheckpointsResourceWithStreamingResponse: + def __init__(self, checkpoints: CheckpointsResource) -> None: + self._checkpoints = checkpoints + + self.list = to_streamed_response_wrapper( + checkpoints.list, + ) + self.delete_thread = to_streamed_response_wrapper( + checkpoints.delete_thread, + ) + self.get_tuple = to_streamed_response_wrapper( + checkpoints.get_tuple, + ) + self.put = to_streamed_response_wrapper( + checkpoints.put, + ) + self.put_writes = to_streamed_response_wrapper( + checkpoints.put_writes, + ) + + +class AsyncCheckpointsResourceWithStreamingResponse: + def __init__(self, checkpoints: AsyncCheckpointsResource) -> None: + self._checkpoints = checkpoints + + self.list = async_to_streamed_response_wrapper( + checkpoints.list, + ) + self.delete_thread = async_to_streamed_response_wrapper( + checkpoints.delete_thread, + ) + self.get_tuple = async_to_streamed_response_wrapper( + checkpoints.get_tuple, + ) + self.put = async_to_streamed_response_wrapper( + checkpoints.put, + ) + self.put_writes = async_to_streamed_response_wrapper( + checkpoints.put_writes, + ) diff --git a/src/agentex/resources/deployment_history.py b/src/agentex/resources/deployment_history.py new file mode 100644 index 000000000..9149ffff8 --- /dev/null +++ b/src/agentex/resources/deployment_history.py @@ -0,0 +1,280 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional + +import httpx + +from ..types import deployment_history_list_params +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from .._utils import path_template, maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.deployment_history import DeploymentHistory +from ..types.deployment_history_list_response import DeploymentHistoryListResponse + +__all__ = ["DeploymentHistoryResource", "AsyncDeploymentHistoryResource"] + + +class DeploymentHistoryResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> DeploymentHistoryResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return DeploymentHistoryResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> DeploymentHistoryResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return DeploymentHistoryResourceWithStreamingResponse(self) + + def retrieve( + self, + deployment_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeploymentHistory: + """ + Get a deployment record by its unique ID. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not deployment_id: + raise ValueError(f"Expected a non-empty value for `deployment_id` but received {deployment_id!r}") + return self._get( + path_template("/deployment-history/{deployment_id}", deployment_id=deployment_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeploymentHistory, + ) + + def list( + self, + *, + agent_id: Optional[str] | Omit = omit, + agent_name: Optional[str] | Omit = omit, + limit: int | Omit = omit, + order_by: Optional[str] | Omit = omit, + order_direction: str | Omit = omit, + page_number: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeploymentHistoryListResponse: + """ + List deployment history for an agent. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/deployment-history", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "agent_id": agent_id, + "agent_name": agent_name, + "limit": limit, + "order_by": order_by, + "order_direction": order_direction, + "page_number": page_number, + }, + deployment_history_list_params.DeploymentHistoryListParams, + ), + ), + cast_to=DeploymentHistoryListResponse, + ) + + +class AsyncDeploymentHistoryResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncDeploymentHistoryResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return AsyncDeploymentHistoryResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncDeploymentHistoryResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return AsyncDeploymentHistoryResourceWithStreamingResponse(self) + + async def retrieve( + self, + deployment_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeploymentHistory: + """ + Get a deployment record by its unique ID. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not deployment_id: + raise ValueError(f"Expected a non-empty value for `deployment_id` but received {deployment_id!r}") + return await self._get( + path_template("/deployment-history/{deployment_id}", deployment_id=deployment_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeploymentHistory, + ) + + async def list( + self, + *, + agent_id: Optional[str] | Omit = omit, + agent_name: Optional[str] | Omit = omit, + limit: int | Omit = omit, + order_by: Optional[str] | Omit = omit, + order_direction: str | Omit = omit, + page_number: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeploymentHistoryListResponse: + """ + List deployment history for an agent. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/deployment-history", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "agent_id": agent_id, + "agent_name": agent_name, + "limit": limit, + "order_by": order_by, + "order_direction": order_direction, + "page_number": page_number, + }, + deployment_history_list_params.DeploymentHistoryListParams, + ), + ), + cast_to=DeploymentHistoryListResponse, + ) + + +class DeploymentHistoryResourceWithRawResponse: + def __init__(self, deployment_history: DeploymentHistoryResource) -> None: + self._deployment_history = deployment_history + + self.retrieve = to_raw_response_wrapper( + deployment_history.retrieve, + ) + self.list = to_raw_response_wrapper( + deployment_history.list, + ) + + +class AsyncDeploymentHistoryResourceWithRawResponse: + def __init__(self, deployment_history: AsyncDeploymentHistoryResource) -> None: + self._deployment_history = deployment_history + + self.retrieve = async_to_raw_response_wrapper( + deployment_history.retrieve, + ) + self.list = async_to_raw_response_wrapper( + deployment_history.list, + ) + + +class DeploymentHistoryResourceWithStreamingResponse: + def __init__(self, deployment_history: DeploymentHistoryResource) -> None: + self._deployment_history = deployment_history + + self.retrieve = to_streamed_response_wrapper( + deployment_history.retrieve, + ) + self.list = to_streamed_response_wrapper( + deployment_history.list, + ) + + +class AsyncDeploymentHistoryResourceWithStreamingResponse: + def __init__(self, deployment_history: AsyncDeploymentHistoryResource) -> None: + self._deployment_history = deployment_history + + self.retrieve = async_to_streamed_response_wrapper( + deployment_history.retrieve, + ) + self.list = async_to_streamed_response_wrapper( + deployment_history.list, + ) diff --git a/src/agentex/resources/events.py b/src/agentex/resources/events.py new file mode 100644 index 000000000..b0111abb4 --- /dev/null +++ b/src/agentex/resources/events.py @@ -0,0 +1,294 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional + +import httpx + +from ..types import event_list_params +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from .._utils import path_template, maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ..types.event import Event +from .._base_client import make_request_options +from ..types.event_list_response import EventListResponse + +__all__ = ["EventsResource", "AsyncEventsResource"] + + +class EventsResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> EventsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return EventsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> EventsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return EventsResourceWithStreamingResponse(self) + + def retrieve( + self, + event_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Event: + """ + Get Event + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not event_id: + raise ValueError(f"Expected a non-empty value for `event_id` but received {event_id!r}") + return self._get( + path_template("/events/{event_id}", event_id=event_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Event, + ) + + def list( + self, + *, + agent_id: str, + task_id: str, + last_processed_event_id: Optional[str] | Omit = omit, + limit: Optional[int] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> EventListResponse: + """ + List events for a specific task and agent. + + Optionally filter for events after a specific sequence ID. Results are ordered + by sequence_id. + + Args: + agent_id: The agent ID to filter events by + + task_id: The task ID to filter events by + + last_processed_event_id: Optional event ID to get events after this ID + + limit: Optional limit on number of results + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/events", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "agent_id": agent_id, + "task_id": task_id, + "last_processed_event_id": last_processed_event_id, + "limit": limit, + }, + event_list_params.EventListParams, + ), + ), + cast_to=EventListResponse, + ) + + +class AsyncEventsResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncEventsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return AsyncEventsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncEventsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return AsyncEventsResourceWithStreamingResponse(self) + + async def retrieve( + self, + event_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Event: + """ + Get Event + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not event_id: + raise ValueError(f"Expected a non-empty value for `event_id` but received {event_id!r}") + return await self._get( + path_template("/events/{event_id}", event_id=event_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Event, + ) + + async def list( + self, + *, + agent_id: str, + task_id: str, + last_processed_event_id: Optional[str] | Omit = omit, + limit: Optional[int] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> EventListResponse: + """ + List events for a specific task and agent. + + Optionally filter for events after a specific sequence ID. Results are ordered + by sequence_id. + + Args: + agent_id: The agent ID to filter events by + + task_id: The task ID to filter events by + + last_processed_event_id: Optional event ID to get events after this ID + + limit: Optional limit on number of results + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/events", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "agent_id": agent_id, + "task_id": task_id, + "last_processed_event_id": last_processed_event_id, + "limit": limit, + }, + event_list_params.EventListParams, + ), + ), + cast_to=EventListResponse, + ) + + +class EventsResourceWithRawResponse: + def __init__(self, events: EventsResource) -> None: + self._events = events + + self.retrieve = to_raw_response_wrapper( + events.retrieve, + ) + self.list = to_raw_response_wrapper( + events.list, + ) + + +class AsyncEventsResourceWithRawResponse: + def __init__(self, events: AsyncEventsResource) -> None: + self._events = events + + self.retrieve = async_to_raw_response_wrapper( + events.retrieve, + ) + self.list = async_to_raw_response_wrapper( + events.list, + ) + + +class EventsResourceWithStreamingResponse: + def __init__(self, events: EventsResource) -> None: + self._events = events + + self.retrieve = to_streamed_response_wrapper( + events.retrieve, + ) + self.list = to_streamed_response_wrapper( + events.list, + ) + + +class AsyncEventsResourceWithStreamingResponse: + def __init__(self, events: AsyncEventsResource) -> None: + self._events = events + + self.retrieve = async_to_streamed_response_wrapper( + events.retrieve, + ) + self.list = async_to_streamed_response_wrapper( + events.list, + ) diff --git a/src/agentex/resources/messages/__init__.py b/src/agentex/resources/messages/__init__.py new file mode 100644 index 000000000..389b8cc15 --- /dev/null +++ b/src/agentex/resources/messages/__init__.py @@ -0,0 +1,33 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .batch import ( + BatchResource, + AsyncBatchResource, + BatchResourceWithRawResponse, + AsyncBatchResourceWithRawResponse, + BatchResourceWithStreamingResponse, + AsyncBatchResourceWithStreamingResponse, +) +from .messages import ( + MessagesResource, + AsyncMessagesResource, + MessagesResourceWithRawResponse, + AsyncMessagesResourceWithRawResponse, + MessagesResourceWithStreamingResponse, + AsyncMessagesResourceWithStreamingResponse, +) + +__all__ = [ + "BatchResource", + "AsyncBatchResource", + "BatchResourceWithRawResponse", + "AsyncBatchResourceWithRawResponse", + "BatchResourceWithStreamingResponse", + "AsyncBatchResourceWithStreamingResponse", + "MessagesResource", + "AsyncMessagesResource", + "MessagesResourceWithRawResponse", + "AsyncMessagesResourceWithRawResponse", + "MessagesResourceWithStreamingResponse", + "AsyncMessagesResourceWithStreamingResponse", +] diff --git a/src/agentex/resources/messages/batch.py b/src/agentex/resources/messages/batch.py new file mode 100644 index 000000000..92d64eaba --- /dev/null +++ b/src/agentex/resources/messages/batch.py @@ -0,0 +1,286 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Union, Iterable +from datetime import datetime + +import httpx + +from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from ..._utils import maybe_transform, async_maybe_transform +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ..._base_client import make_request_options +from ...types.messages import batch_create_params, batch_update_params +from ...types.task_message_content_param import TaskMessageContentParam +from ...types.messages.batch_create_response import BatchCreateResponse +from ...types.messages.batch_update_response import BatchUpdateResponse + +__all__ = ["BatchResource", "AsyncBatchResource"] + + +class BatchResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> BatchResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return BatchResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> BatchResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return BatchResourceWithStreamingResponse(self) + + def create( + self, + *, + contents: Iterable[TaskMessageContentParam], + task_id: str, + created_at: Union[str, datetime, None] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BatchCreateResponse: + """Batch Create Messages + + Args: + created_at: Optional base timestamp. + + Each message in the batch is stamped with base + i + milliseconds to guarantee unique, monotonic ordering. If omitted, the server + stamps datetime.now(UTC) at insert time. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/messages/batch", + body=maybe_transform( + { + "contents": contents, + "task_id": task_id, + "created_at": created_at, + }, + batch_create_params.BatchCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BatchCreateResponse, + ) + + def update( + self, + *, + task_id: str, + updates: Dict[str, TaskMessageContentParam], + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BatchUpdateResponse: + """ + Batch Update Messages + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._put( + "/messages/batch", + body=maybe_transform( + { + "task_id": task_id, + "updates": updates, + }, + batch_update_params.BatchUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BatchUpdateResponse, + ) + + +class AsyncBatchResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncBatchResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return AsyncBatchResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncBatchResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return AsyncBatchResourceWithStreamingResponse(self) + + async def create( + self, + *, + contents: Iterable[TaskMessageContentParam], + task_id: str, + created_at: Union[str, datetime, None] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BatchCreateResponse: + """Batch Create Messages + + Args: + created_at: Optional base timestamp. + + Each message in the batch is stamped with base + i + milliseconds to guarantee unique, monotonic ordering. If omitted, the server + stamps datetime.now(UTC) at insert time. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/messages/batch", + body=await async_maybe_transform( + { + "contents": contents, + "task_id": task_id, + "created_at": created_at, + }, + batch_create_params.BatchCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BatchCreateResponse, + ) + + async def update( + self, + *, + task_id: str, + updates: Dict[str, TaskMessageContentParam], + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BatchUpdateResponse: + """ + Batch Update Messages + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._put( + "/messages/batch", + body=await async_maybe_transform( + { + "task_id": task_id, + "updates": updates, + }, + batch_update_params.BatchUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BatchUpdateResponse, + ) + + +class BatchResourceWithRawResponse: + def __init__(self, batch: BatchResource) -> None: + self._batch = batch + + self.create = to_raw_response_wrapper( + batch.create, + ) + self.update = to_raw_response_wrapper( + batch.update, + ) + + +class AsyncBatchResourceWithRawResponse: + def __init__(self, batch: AsyncBatchResource) -> None: + self._batch = batch + + self.create = async_to_raw_response_wrapper( + batch.create, + ) + self.update = async_to_raw_response_wrapper( + batch.update, + ) + + +class BatchResourceWithStreamingResponse: + def __init__(self, batch: BatchResource) -> None: + self._batch = batch + + self.create = to_streamed_response_wrapper( + batch.create, + ) + self.update = to_streamed_response_wrapper( + batch.update, + ) + + +class AsyncBatchResourceWithStreamingResponse: + def __init__(self, batch: AsyncBatchResource) -> None: + self._batch = batch + + self.create = async_to_streamed_response_wrapper( + batch.create, + ) + self.update = async_to_streamed_response_wrapper( + batch.update, + ) diff --git a/src/agentex/resources/messages/messages.py b/src/agentex/resources/messages/messages.py new file mode 100644 index 000000000..ec8f8d19f --- /dev/null +++ b/src/agentex/resources/messages/messages.py @@ -0,0 +1,2739 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union, Optional +from datetime import datetime +from typing_extensions import Literal + +import httpx + +from .batch import ( + BatchResource, + AsyncBatchResource, + BatchResourceWithRawResponse, + AsyncBatchResourceWithRawResponse, + BatchResourceWithStreamingResponse, + AsyncBatchResourceWithStreamingResponse, +) +from ...types import ( + message_list_params, + message_create_params, + message_update_params, + message_list_paginated_params, +) +from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from ..._utils import path_template, maybe_transform, async_maybe_transform +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ..._base_client import make_request_options +from ...types.task_message import TaskMessage +from ...types.message_list_response import MessageListResponse +from ...types.task_message_content_param import TaskMessageContentParam +from ...types.message_list_paginated_response import MessageListPaginatedResponse + +__all__ = ["MessagesResource", "AsyncMessagesResource"] + + +class MessagesResource(SyncAPIResource): + @cached_property + def batch(self) -> BatchResource: + return BatchResource(self._client) + + @cached_property + def with_raw_response(self) -> MessagesResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return MessagesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> MessagesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return MessagesResourceWithStreamingResponse(self) + + def create( + self, + *, + content: TaskMessageContentParam, + task_id: str, + created_at: Union[str, datetime, None] | Omit = omit, + streaming_status: Optional[Literal["IN_PROGRESS", "DONE"]] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> TaskMessage: + """Create Message + + Args: + created_at: Optional timestamp for the message. + + Workflow callers should pass workflow.now() + (Temporal's deterministic monotonic clock) so that two awaited messages.create + calls from the same workflow are guaranteed to have monotonic timestamps + regardless of HTTP scheduling at the server. If omitted, the server's wall clock + at insert time is used. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/messages", + body=maybe_transform( + { + "content": content, + "task_id": task_id, + "created_at": created_at, + "streaming_status": streaming_status, + }, + message_create_params.MessageCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=TaskMessage, + ) + + def retrieve( + self, + message_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> TaskMessage: + """ + Get Message + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not message_id: + raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}") + return self._get( + path_template("/messages/{message_id}", message_id=message_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=TaskMessage, + ) + + def update( + self, + message_id: str, + *, + content: TaskMessageContentParam, + task_id: str, + streaming_status: Optional[Literal["IN_PROGRESS", "DONE"]] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> TaskMessage: + """ + Update Message + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not message_id: + raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}") + return self._put( + path_template("/messages/{message_id}", message_id=message_id), + body=maybe_transform( + { + "content": content, + "task_id": task_id, + "streaming_status": streaming_status, + }, + message_update_params.MessageUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=TaskMessage, + ) + + def list( + self, + *, + task_id: str, + filters: Optional[str] | Omit = omit, + limit: int | Omit = omit, + order_by: Optional[str] | Omit = omit, + order_direction: str | Omit = omit, + page_number: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> MessageListResponse: + """ + List messages for a task with offset-based pagination. + + For cursor-based pagination with infinite scroll support, use + /messages/paginated. + + Args: + task_id: The task ID + + filters: JSON-encoded array of TaskMessageEntityFilter objects. + + Schema: { + "$defs": { + "DataContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "data", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `data`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The contents of the data message.", + "title": "Data" + } + }, + "title": "DataContentEntityOptional", + "type": "object" + }, + "FileAttachmentEntity": { + "description": "Represents a file attachment in messages.", + "properties": { + "file_id": { + "description": "The unique ID of the attached file", + "title": "File Id", + "type": "string" + }, + "name": { + "description": "The name of the file", + "title": "Name", + "type": "string" + }, + "size": { + "description": "The size of the file in bytes", + "title": "Size", + "type": "integer" + }, + "type": { + "description": "The MIME type or content type of the file", + "title": "Type", + "type": "string" + } + }, + "required": [ + "file_id", + "name", + "size", + "type" + ], + "title": "FileAttachmentEntity", + "type": "object" + }, + "MessageAuthor": { + "enum": [ + "user", + "agent" + ], + "title": "MessageAuthor", + "type": "string" + }, + "MessageStyle": { + "enum": [ + "static", + "active" + ], + "title": "MessageStyle", + "type": "string" + }, + "ReasoningContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "reasoning", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `reasoning`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "summary": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "A list of short reasoning summaries", + "title": "Summary" + }, + "content": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The reasoning content or chain-of-thought text", + "title": "Content" + } + }, + "title": "ReasoningContentEntityOptional", + "type": "object" + }, + "TextContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "text", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `text`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "format": { + "anyOf": [ + { + "$ref": + "#/$defs/TextFormat" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The format of the message. This is used by the client to determine how to display the message." + }, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The contents of the text message.", + "title": "Content" + }, + "attachments": { + "anyOf": [ + { + "items": { + "$ref": + "#/$defs/FileAttachmentEntity" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional list of file attachments with structured metadata.", + "title": "Attachments" + } + }, + "title": "TextContentEntityOptional", + "type": "object" + }, + "TextFormat": { + "enum": [ + "markdown", + "plain", + "code" + ], + "title": "TextFormat", + "type": "string" + }, + "ToolRequestContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "tool_request", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `tool_request`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "tool_call_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The ID of the tool call that is being requested.", + "title": "Tool Call Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The name of the tool that is being requested.", + "title": "Name" + }, + "arguments": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The arguments to the tool.", + "title": "Arguments" + } + }, + "title": "ToolRequestContentEntityOptional", + "type": "object" + }, + "ToolResponseContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "tool_response", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `tool_response`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "tool_call_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The ID of the tool call that is being responded to.", + "title": "Tool Call Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The name of the tool that is being responded to.", + "title": "Name" + }, + "content": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The result of the tool.", + "title": "Content" + }, + "is_error": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Whether the tool call resulted in an error. `None` when the harness does not report a status.", + "title": "Is Error" + } + }, + "title": "ToolResponseContentEntityOptional", + "type": "object" + } + }, + "description": "Filter model for TaskMessage - all fields optional for flexible filtering.\n\nThe `exclude` field determines whether this filter is inclusionary or exclusionary.\nWhen multiple filters are provided:\n- Inclusionary filters (exclude=False) are OR'd together\n- Exclusionary filters (exclude=True) are OR'd together and negated with $nor\n- The two groups are AND'd: (include1 OR include2) AND NOT (exclude1 OR exclude2)", + "properties": { + "content": { + "anyOf": [ + { + "$ref": + "#/$defs/ToolRequestContentEntityOptional" + }, + { + "$ref": + "#/$defs/DataContentEntityOptional" + }, + { + "$ref": + "#/$defs/TextContentEntityOptional" + }, + { + "$ref": + "#/$defs/ToolResponseContentEntityOptional" + }, + { + "$ref": + "#/$defs/ReasoningContentEntityOptional" }, { "type": "null" } ], "default": + null, "description": "Filter by message content", "title": "Content" }, + "streaming_status": { "anyOf": [ { "enum": [ "IN_PROGRESS", "DONE" ], "type": + "string" }, { "type": "null" } ], "default": null, "description": "Filter by + streaming status", "title": "Streaming Status" }, "exclude": { "default": false, + "description": "If true, this filter excludes matching messages", "title": + "Exclude", "type": "boolean" } }, "title": "TaskMessageEntityFilter", "type": + "object" } + + Each filter can include: + + - `content`: Filter by message content (type, author, data fields) + - `streaming_status`: Filter by status ("IN_PROGRESS" or "DONE") + - `exclude`: If true, excludes matching messages (default: false) + + Multiple filters are combined: inclusionary filters (exclude=false) are OR'd + together, exclusionary filters (exclude=true) are OR'd and negated, then both + groups are AND'd. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/messages", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "task_id": task_id, + "filters": filters, + "limit": limit, + "order_by": order_by, + "order_direction": order_direction, + "page_number": page_number, + }, + message_list_params.MessageListParams, + ), + ), + cast_to=MessageListResponse, + ) + + def list_paginated( + self, + *, + task_id: str, + cursor: Optional[str] | Omit = omit, + direction: Literal["older", "newer"] | Omit = omit, + filters: Optional[str] | Omit = omit, + limit: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> MessageListPaginatedResponse: + """ + List messages for a task with cursor-based pagination. + + This endpoint is designed for infinite scroll UIs where new messages may arrive + while paginating through older ones. + + Args: task_id: The task ID to filter messages by limit: Maximum number of + messages to return (default: 50) cursor: Opaque cursor string for pagination. + Pass the `next_cursor` from a previous response to get the next page. direction: + Pagination direction - "older" to get older messages (default), "newer" to get + newer messages. + + Returns: PaginatedMessagesResponse with: - data: List of messages (newest first + when direction="older") - next_cursor: Cursor for fetching the next page (null + if no more pages) - has_more: Whether there are more messages to fetch + + Example: First request: GET /messages/paginated?task_id=xxx&limit=50 Next page: + GET /messages/paginated?task_id=xxx&limit=50&cursor= + + Args: + task_id: The task ID + + filters: JSON-encoded array of TaskMessageEntityFilter objects. + + Schema: { + "$defs": { + "DataContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "data", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `data`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The contents of the data message.", + "title": "Data" + } + }, + "title": "DataContentEntityOptional", + "type": "object" + }, + "FileAttachmentEntity": { + "description": "Represents a file attachment in messages.", + "properties": { + "file_id": { + "description": "The unique ID of the attached file", + "title": "File Id", + "type": "string" + }, + "name": { + "description": "The name of the file", + "title": "Name", + "type": "string" + }, + "size": { + "description": "The size of the file in bytes", + "title": "Size", + "type": "integer" + }, + "type": { + "description": "The MIME type or content type of the file", + "title": "Type", + "type": "string" + } + }, + "required": [ + "file_id", + "name", + "size", + "type" + ], + "title": "FileAttachmentEntity", + "type": "object" + }, + "MessageAuthor": { + "enum": [ + "user", + "agent" + ], + "title": "MessageAuthor", + "type": "string" + }, + "MessageStyle": { + "enum": [ + "static", + "active" + ], + "title": "MessageStyle", + "type": "string" + }, + "ReasoningContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "reasoning", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `reasoning`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "summary": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "A list of short reasoning summaries", + "title": "Summary" + }, + "content": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The reasoning content or chain-of-thought text", + "title": "Content" + } + }, + "title": "ReasoningContentEntityOptional", + "type": "object" + }, + "TextContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "text", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `text`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "format": { + "anyOf": [ + { + "$ref": + "#/$defs/TextFormat" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The format of the message. This is used by the client to determine how to display the message." + }, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The contents of the text message.", + "title": "Content" + }, + "attachments": { + "anyOf": [ + { + "items": { + "$ref": + "#/$defs/FileAttachmentEntity" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional list of file attachments with structured metadata.", + "title": "Attachments" + } + }, + "title": "TextContentEntityOptional", + "type": "object" + }, + "TextFormat": { + "enum": [ + "markdown", + "plain", + "code" + ], + "title": "TextFormat", + "type": "string" + }, + "ToolRequestContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "tool_request", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `tool_request`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "tool_call_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The ID of the tool call that is being requested.", + "title": "Tool Call Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The name of the tool that is being requested.", + "title": "Name" + }, + "arguments": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The arguments to the tool.", + "title": "Arguments" + } + }, + "title": "ToolRequestContentEntityOptional", + "type": "object" + }, + "ToolResponseContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "tool_response", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `tool_response`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "tool_call_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The ID of the tool call that is being responded to.", + "title": "Tool Call Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The name of the tool that is being responded to.", + "title": "Name" + }, + "content": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The result of the tool.", + "title": "Content" + }, + "is_error": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Whether the tool call resulted in an error. `None` when the harness does not report a status.", + "title": "Is Error" + } + }, + "title": "ToolResponseContentEntityOptional", + "type": "object" + } + }, + "description": "Filter model for TaskMessage - all fields optional for flexible filtering.\n\nThe `exclude` field determines whether this filter is inclusionary or exclusionary.\nWhen multiple filters are provided:\n- Inclusionary filters (exclude=False) are OR'd together\n- Exclusionary filters (exclude=True) are OR'd together and negated with $nor\n- The two groups are AND'd: (include1 OR include2) AND NOT (exclude1 OR exclude2)", + "properties": { + "content": { + "anyOf": [ + { + "$ref": + "#/$defs/ToolRequestContentEntityOptional" + }, + { + "$ref": + "#/$defs/DataContentEntityOptional" + }, + { + "$ref": + "#/$defs/TextContentEntityOptional" + }, + { + "$ref": + "#/$defs/ToolResponseContentEntityOptional" + }, + { + "$ref": + "#/$defs/ReasoningContentEntityOptional" }, { "type": "null" } ], "default": + null, "description": "Filter by message content", "title": "Content" }, + "streaming_status": { "anyOf": [ { "enum": [ "IN_PROGRESS", "DONE" ], "type": + "string" }, { "type": "null" } ], "default": null, "description": "Filter by + streaming status", "title": "Streaming Status" }, "exclude": { "default": false, + "description": "If true, this filter excludes matching messages", "title": + "Exclude", "type": "boolean" } }, "title": "TaskMessageEntityFilter", "type": + "object" } + + Each filter can include: + + - `content`: Filter by message content (type, author, data fields) + - `streaming_status`: Filter by status ("IN_PROGRESS" or "DONE") + - `exclude`: If true, excludes matching messages (default: false) + + Multiple filters are combined: inclusionary filters (exclude=false) are OR'd + together, exclusionary filters (exclude=true) are OR'd and negated, then both + groups are AND'd. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/messages/paginated", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "task_id": task_id, + "cursor": cursor, + "direction": direction, + "filters": filters, + "limit": limit, + }, + message_list_paginated_params.MessageListPaginatedParams, + ), + ), + cast_to=MessageListPaginatedResponse, + ) + + +class AsyncMessagesResource(AsyncAPIResource): + @cached_property + def batch(self) -> AsyncBatchResource: + return AsyncBatchResource(self._client) + + @cached_property + def with_raw_response(self) -> AsyncMessagesResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return AsyncMessagesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncMessagesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return AsyncMessagesResourceWithStreamingResponse(self) + + async def create( + self, + *, + content: TaskMessageContentParam, + task_id: str, + created_at: Union[str, datetime, None] | Omit = omit, + streaming_status: Optional[Literal["IN_PROGRESS", "DONE"]] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> TaskMessage: + """Create Message + + Args: + created_at: Optional timestamp for the message. + + Workflow callers should pass workflow.now() + (Temporal's deterministic monotonic clock) so that two awaited messages.create + calls from the same workflow are guaranteed to have monotonic timestamps + regardless of HTTP scheduling at the server. If omitted, the server's wall clock + at insert time is used. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/messages", + body=await async_maybe_transform( + { + "content": content, + "task_id": task_id, + "created_at": created_at, + "streaming_status": streaming_status, + }, + message_create_params.MessageCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=TaskMessage, + ) + + async def retrieve( + self, + message_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> TaskMessage: + """ + Get Message + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not message_id: + raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}") + return await self._get( + path_template("/messages/{message_id}", message_id=message_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=TaskMessage, + ) + + async def update( + self, + message_id: str, + *, + content: TaskMessageContentParam, + task_id: str, + streaming_status: Optional[Literal["IN_PROGRESS", "DONE"]] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> TaskMessage: + """ + Update Message + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not message_id: + raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}") + return await self._put( + path_template("/messages/{message_id}", message_id=message_id), + body=await async_maybe_transform( + { + "content": content, + "task_id": task_id, + "streaming_status": streaming_status, + }, + message_update_params.MessageUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=TaskMessage, + ) + + async def list( + self, + *, + task_id: str, + filters: Optional[str] | Omit = omit, + limit: int | Omit = omit, + order_by: Optional[str] | Omit = omit, + order_direction: str | Omit = omit, + page_number: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> MessageListResponse: + """ + List messages for a task with offset-based pagination. + + For cursor-based pagination with infinite scroll support, use + /messages/paginated. + + Args: + task_id: The task ID + + filters: JSON-encoded array of TaskMessageEntityFilter objects. + + Schema: { + "$defs": { + "DataContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "data", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `data`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The contents of the data message.", + "title": "Data" + } + }, + "title": "DataContentEntityOptional", + "type": "object" + }, + "FileAttachmentEntity": { + "description": "Represents a file attachment in messages.", + "properties": { + "file_id": { + "description": "The unique ID of the attached file", + "title": "File Id", + "type": "string" + }, + "name": { + "description": "The name of the file", + "title": "Name", + "type": "string" + }, + "size": { + "description": "The size of the file in bytes", + "title": "Size", + "type": "integer" + }, + "type": { + "description": "The MIME type or content type of the file", + "title": "Type", + "type": "string" + } + }, + "required": [ + "file_id", + "name", + "size", + "type" + ], + "title": "FileAttachmentEntity", + "type": "object" + }, + "MessageAuthor": { + "enum": [ + "user", + "agent" + ], + "title": "MessageAuthor", + "type": "string" + }, + "MessageStyle": { + "enum": [ + "static", + "active" + ], + "title": "MessageStyle", + "type": "string" + }, + "ReasoningContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "reasoning", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `reasoning`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "summary": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "A list of short reasoning summaries", + "title": "Summary" + }, + "content": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The reasoning content or chain-of-thought text", + "title": "Content" + } + }, + "title": "ReasoningContentEntityOptional", + "type": "object" + }, + "TextContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "text", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `text`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "format": { + "anyOf": [ + { + "$ref": + "#/$defs/TextFormat" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The format of the message. This is used by the client to determine how to display the message." + }, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The contents of the text message.", + "title": "Content" + }, + "attachments": { + "anyOf": [ + { + "items": { + "$ref": + "#/$defs/FileAttachmentEntity" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional list of file attachments with structured metadata.", + "title": "Attachments" + } + }, + "title": "TextContentEntityOptional", + "type": "object" + }, + "TextFormat": { + "enum": [ + "markdown", + "plain", + "code" + ], + "title": "TextFormat", + "type": "string" + }, + "ToolRequestContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "tool_request", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `tool_request`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "tool_call_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The ID of the tool call that is being requested.", + "title": "Tool Call Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The name of the tool that is being requested.", + "title": "Name" + }, + "arguments": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The arguments to the tool.", + "title": "Arguments" + } + }, + "title": "ToolRequestContentEntityOptional", + "type": "object" + }, + "ToolResponseContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "tool_response", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `tool_response`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "tool_call_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The ID of the tool call that is being responded to.", + "title": "Tool Call Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The name of the tool that is being responded to.", + "title": "Name" + }, + "content": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The result of the tool.", + "title": "Content" + }, + "is_error": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Whether the tool call resulted in an error. `None` when the harness does not report a status.", + "title": "Is Error" + } + }, + "title": "ToolResponseContentEntityOptional", + "type": "object" + } + }, + "description": "Filter model for TaskMessage - all fields optional for flexible filtering.\n\nThe `exclude` field determines whether this filter is inclusionary or exclusionary.\nWhen multiple filters are provided:\n- Inclusionary filters (exclude=False) are OR'd together\n- Exclusionary filters (exclude=True) are OR'd together and negated with $nor\n- The two groups are AND'd: (include1 OR include2) AND NOT (exclude1 OR exclude2)", + "properties": { + "content": { + "anyOf": [ + { + "$ref": + "#/$defs/ToolRequestContentEntityOptional" + }, + { + "$ref": + "#/$defs/DataContentEntityOptional" + }, + { + "$ref": + "#/$defs/TextContentEntityOptional" + }, + { + "$ref": + "#/$defs/ToolResponseContentEntityOptional" + }, + { + "$ref": + "#/$defs/ReasoningContentEntityOptional" }, { "type": "null" } ], "default": + null, "description": "Filter by message content", "title": "Content" }, + "streaming_status": { "anyOf": [ { "enum": [ "IN_PROGRESS", "DONE" ], "type": + "string" }, { "type": "null" } ], "default": null, "description": "Filter by + streaming status", "title": "Streaming Status" }, "exclude": { "default": false, + "description": "If true, this filter excludes matching messages", "title": + "Exclude", "type": "boolean" } }, "title": "TaskMessageEntityFilter", "type": + "object" } + + Each filter can include: + + - `content`: Filter by message content (type, author, data fields) + - `streaming_status`: Filter by status ("IN_PROGRESS" or "DONE") + - `exclude`: If true, excludes matching messages (default: false) + + Multiple filters are combined: inclusionary filters (exclude=false) are OR'd + together, exclusionary filters (exclude=true) are OR'd and negated, then both + groups are AND'd. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/messages", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "task_id": task_id, + "filters": filters, + "limit": limit, + "order_by": order_by, + "order_direction": order_direction, + "page_number": page_number, + }, + message_list_params.MessageListParams, + ), + ), + cast_to=MessageListResponse, + ) + + async def list_paginated( + self, + *, + task_id: str, + cursor: Optional[str] | Omit = omit, + direction: Literal["older", "newer"] | Omit = omit, + filters: Optional[str] | Omit = omit, + limit: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> MessageListPaginatedResponse: + """ + List messages for a task with cursor-based pagination. + + This endpoint is designed for infinite scroll UIs where new messages may arrive + while paginating through older ones. + + Args: task_id: The task ID to filter messages by limit: Maximum number of + messages to return (default: 50) cursor: Opaque cursor string for pagination. + Pass the `next_cursor` from a previous response to get the next page. direction: + Pagination direction - "older" to get older messages (default), "newer" to get + newer messages. + + Returns: PaginatedMessagesResponse with: - data: List of messages (newest first + when direction="older") - next_cursor: Cursor for fetching the next page (null + if no more pages) - has_more: Whether there are more messages to fetch + + Example: First request: GET /messages/paginated?task_id=xxx&limit=50 Next page: + GET /messages/paginated?task_id=xxx&limit=50&cursor= + + Args: + task_id: The task ID + + filters: JSON-encoded array of TaskMessageEntityFilter objects. + + Schema: { + "$defs": { + "DataContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "data", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `data`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The contents of the data message.", + "title": "Data" + } + }, + "title": "DataContentEntityOptional", + "type": "object" + }, + "FileAttachmentEntity": { + "description": "Represents a file attachment in messages.", + "properties": { + "file_id": { + "description": "The unique ID of the attached file", + "title": "File Id", + "type": "string" + }, + "name": { + "description": "The name of the file", + "title": "Name", + "type": "string" + }, + "size": { + "description": "The size of the file in bytes", + "title": "Size", + "type": "integer" + }, + "type": { + "description": "The MIME type or content type of the file", + "title": "Type", + "type": "string" + } + }, + "required": [ + "file_id", + "name", + "size", + "type" + ], + "title": "FileAttachmentEntity", + "type": "object" + }, + "MessageAuthor": { + "enum": [ + "user", + "agent" + ], + "title": "MessageAuthor", + "type": "string" + }, + "MessageStyle": { + "enum": [ + "static", + "active" + ], + "title": "MessageStyle", + "type": "string" + }, + "ReasoningContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "reasoning", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `reasoning`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "summary": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "A list of short reasoning summaries", + "title": "Summary" + }, + "content": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The reasoning content or chain-of-thought text", + "title": "Content" + } + }, + "title": "ReasoningContentEntityOptional", + "type": "object" + }, + "TextContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "text", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `text`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "format": { + "anyOf": [ + { + "$ref": + "#/$defs/TextFormat" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The format of the message. This is used by the client to determine how to display the message." + }, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The contents of the text message.", + "title": "Content" + }, + "attachments": { + "anyOf": [ + { + "items": { + "$ref": + "#/$defs/FileAttachmentEntity" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional list of file attachments with structured metadata.", + "title": "Attachments" + } + }, + "title": "TextContentEntityOptional", + "type": "object" + }, + "TextFormat": { + "enum": [ + "markdown", + "plain", + "code" + ], + "title": "TextFormat", + "type": "string" + }, + "ToolRequestContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "tool_request", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `tool_request`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "tool_call_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The ID of the tool call that is being requested.", + "title": "Tool Call Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The name of the tool that is being requested.", + "title": "Name" + }, + "arguments": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The arguments to the tool.", + "title": "Arguments" + } + }, + "title": "ToolRequestContentEntityOptional", + "type": "object" + }, + "ToolResponseContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "tool_response", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `tool_response`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "tool_call_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The ID of the tool call that is being responded to.", + "title": "Tool Call Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The name of the tool that is being responded to.", + "title": "Name" + }, + "content": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The result of the tool.", + "title": "Content" + }, + "is_error": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Whether the tool call resulted in an error. `None` when the harness does not report a status.", + "title": "Is Error" + } + }, + "title": "ToolResponseContentEntityOptional", + "type": "object" + } + }, + "description": "Filter model for TaskMessage - all fields optional for flexible filtering.\n\nThe `exclude` field determines whether this filter is inclusionary or exclusionary.\nWhen multiple filters are provided:\n- Inclusionary filters (exclude=False) are OR'd together\n- Exclusionary filters (exclude=True) are OR'd together and negated with $nor\n- The two groups are AND'd: (include1 OR include2) AND NOT (exclude1 OR exclude2)", + "properties": { + "content": { + "anyOf": [ + { + "$ref": + "#/$defs/ToolRequestContentEntityOptional" + }, + { + "$ref": + "#/$defs/DataContentEntityOptional" + }, + { + "$ref": + "#/$defs/TextContentEntityOptional" + }, + { + "$ref": + "#/$defs/ToolResponseContentEntityOptional" + }, + { + "$ref": + "#/$defs/ReasoningContentEntityOptional" }, { "type": "null" } ], "default": + null, "description": "Filter by message content", "title": "Content" }, + "streaming_status": { "anyOf": [ { "enum": [ "IN_PROGRESS", "DONE" ], "type": + "string" }, { "type": "null" } ], "default": null, "description": "Filter by + streaming status", "title": "Streaming Status" }, "exclude": { "default": false, + "description": "If true, this filter excludes matching messages", "title": + "Exclude", "type": "boolean" } }, "title": "TaskMessageEntityFilter", "type": + "object" } + + Each filter can include: + + - `content`: Filter by message content (type, author, data fields) + - `streaming_status`: Filter by status ("IN_PROGRESS" or "DONE") + - `exclude`: If true, excludes matching messages (default: false) + + Multiple filters are combined: inclusionary filters (exclude=false) are OR'd + together, exclusionary filters (exclude=true) are OR'd and negated, then both + groups are AND'd. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/messages/paginated", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "task_id": task_id, + "cursor": cursor, + "direction": direction, + "filters": filters, + "limit": limit, + }, + message_list_paginated_params.MessageListPaginatedParams, + ), + ), + cast_to=MessageListPaginatedResponse, + ) + + +class MessagesResourceWithRawResponse: + def __init__(self, messages: MessagesResource) -> None: + self._messages = messages + + self.create = to_raw_response_wrapper( + messages.create, + ) + self.retrieve = to_raw_response_wrapper( + messages.retrieve, + ) + self.update = to_raw_response_wrapper( + messages.update, + ) + self.list = to_raw_response_wrapper( + messages.list, + ) + self.list_paginated = to_raw_response_wrapper( + messages.list_paginated, + ) + + @cached_property + def batch(self) -> BatchResourceWithRawResponse: + return BatchResourceWithRawResponse(self._messages.batch) + + +class AsyncMessagesResourceWithRawResponse: + def __init__(self, messages: AsyncMessagesResource) -> None: + self._messages = messages + + self.create = async_to_raw_response_wrapper( + messages.create, + ) + self.retrieve = async_to_raw_response_wrapper( + messages.retrieve, + ) + self.update = async_to_raw_response_wrapper( + messages.update, + ) + self.list = async_to_raw_response_wrapper( + messages.list, + ) + self.list_paginated = async_to_raw_response_wrapper( + messages.list_paginated, + ) + + @cached_property + def batch(self) -> AsyncBatchResourceWithRawResponse: + return AsyncBatchResourceWithRawResponse(self._messages.batch) + + +class MessagesResourceWithStreamingResponse: + def __init__(self, messages: MessagesResource) -> None: + self._messages = messages + + self.create = to_streamed_response_wrapper( + messages.create, + ) + self.retrieve = to_streamed_response_wrapper( + messages.retrieve, + ) + self.update = to_streamed_response_wrapper( + messages.update, + ) + self.list = to_streamed_response_wrapper( + messages.list, + ) + self.list_paginated = to_streamed_response_wrapper( + messages.list_paginated, + ) + + @cached_property + def batch(self) -> BatchResourceWithStreamingResponse: + return BatchResourceWithStreamingResponse(self._messages.batch) + + +class AsyncMessagesResourceWithStreamingResponse: + def __init__(self, messages: AsyncMessagesResource) -> None: + self._messages = messages + + self.create = async_to_streamed_response_wrapper( + messages.create, + ) + self.retrieve = async_to_streamed_response_wrapper( + messages.retrieve, + ) + self.update = async_to_streamed_response_wrapper( + messages.update, + ) + self.list = async_to_streamed_response_wrapper( + messages.list, + ) + self.list_paginated = async_to_streamed_response_wrapper( + messages.list_paginated, + ) + + @cached_property + def batch(self) -> AsyncBatchResourceWithStreamingResponse: + return AsyncBatchResourceWithStreamingResponse(self._messages.batch) diff --git a/src/agentex/resources/spans.py b/src/agentex/resources/spans.py new file mode 100644 index 000000000..ecd692644 --- /dev/null +++ b/src/agentex/resources/spans.py @@ -0,0 +1,603 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Union, Iterable, Optional +from datetime import datetime + +import httpx + +from ..types import span_list_params, span_create_params, span_update_params +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from .._utils import path_template, maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ..types.span import Span +from .._base_client import make_request_options +from ..types.span_list_response import SpanListResponse + +__all__ = ["SpansResource", "AsyncSpansResource"] + + +class SpansResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> SpansResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return SpansResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> SpansResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return SpansResourceWithStreamingResponse(self) + + def create( + self, + *, + name: str, + start_time: Union[str, datetime], + trace_id: str, + id: Optional[str] | Omit = omit, + data: Union[Dict[str, object], Iterable[Dict[str, object]], None] | Omit = omit, + end_time: Union[str, datetime, None] | Omit = omit, + input: Union[Dict[str, object], Iterable[Dict[str, object]], None] | Omit = omit, + output: Union[Dict[str, object], Iterable[Dict[str, object]], None] | Omit = omit, + parent_id: Optional[str] | Omit = omit, + task_id: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Span: + """ + Create a new span with the provided parameters + + Args: + name: Name that describes what operation this span represents + + start_time: The time the span started + + trace_id: Unique identifier for the trace this span belongs to + + id: Unique identifier for the span. If not provided, an ID will be generated. + + data: Any additional metadata or context for the span + + end_time: The time the span ended + + input: Input parameters or data for the operation + + output: Output data resulting from the operation + + parent_id: ID of the parent span if this is a child span in a trace + + task_id: ID of the task this span belongs to + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/spans", + body=maybe_transform( + { + "name": name, + "start_time": start_time, + "trace_id": trace_id, + "id": id, + "data": data, + "end_time": end_time, + "input": input, + "output": output, + "parent_id": parent_id, + "task_id": task_id, + }, + span_create_params.SpanCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Span, + ) + + def retrieve( + self, + span_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Span: + """ + Get a span by ID + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not span_id: + raise ValueError(f"Expected a non-empty value for `span_id` but received {span_id!r}") + return self._get( + path_template("/spans/{span_id}", span_id=span_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Span, + ) + + def update( + self, + span_id: str, + *, + data: Union[Dict[str, object], Iterable[Dict[str, object]], None] | Omit = omit, + end_time: Union[str, datetime, None] | Omit = omit, + input: Union[Dict[str, object], Iterable[Dict[str, object]], None] | Omit = omit, + name: Optional[str] | Omit = omit, + output: Union[Dict[str, object], Iterable[Dict[str, object]], None] | Omit = omit, + parent_id: Optional[str] | Omit = omit, + start_time: Union[str, datetime, None] | Omit = omit, + task_id: Optional[str] | Omit = omit, + trace_id: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Span: + """ + Update a span with the provided output data and mark it as complete + + Args: + data: Any additional metadata or context for the span + + end_time: The time the span ended + + input: Input parameters or data for the operation + + name: Name that describes what operation this span represents + + output: Output data resulting from the operation + + parent_id: ID of the parent span if this is a child span in a trace + + start_time: The time the span started + + task_id: ID of the task this span belongs to + + trace_id: Unique identifier for the trace this span belongs to + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not span_id: + raise ValueError(f"Expected a non-empty value for `span_id` but received {span_id!r}") + return self._patch( + path_template("/spans/{span_id}", span_id=span_id), + body=maybe_transform( + { + "data": data, + "end_time": end_time, + "input": input, + "name": name, + "output": output, + "parent_id": parent_id, + "start_time": start_time, + "task_id": task_id, + "trace_id": trace_id, + }, + span_update_params.SpanUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Span, + ) + + def list( + self, + *, + limit: int | Omit = omit, + order_by: Optional[str] | Omit = omit, + order_direction: str | Omit = omit, + page_number: int | Omit = omit, + task_id: Optional[str] | Omit = omit, + trace_id: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SpanListResponse: + """ + List spans, optionally filtered by trace_id and/or task_id + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/spans", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "limit": limit, + "order_by": order_by, + "order_direction": order_direction, + "page_number": page_number, + "task_id": task_id, + "trace_id": trace_id, + }, + span_list_params.SpanListParams, + ), + ), + cast_to=SpanListResponse, + ) + + +class AsyncSpansResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncSpansResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return AsyncSpansResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncSpansResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return AsyncSpansResourceWithStreamingResponse(self) + + async def create( + self, + *, + name: str, + start_time: Union[str, datetime], + trace_id: str, + id: Optional[str] | Omit = omit, + data: Union[Dict[str, object], Iterable[Dict[str, object]], None] | Omit = omit, + end_time: Union[str, datetime, None] | Omit = omit, + input: Union[Dict[str, object], Iterable[Dict[str, object]], None] | Omit = omit, + output: Union[Dict[str, object], Iterable[Dict[str, object]], None] | Omit = omit, + parent_id: Optional[str] | Omit = omit, + task_id: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Span: + """ + Create a new span with the provided parameters + + Args: + name: Name that describes what operation this span represents + + start_time: The time the span started + + trace_id: Unique identifier for the trace this span belongs to + + id: Unique identifier for the span. If not provided, an ID will be generated. + + data: Any additional metadata or context for the span + + end_time: The time the span ended + + input: Input parameters or data for the operation + + output: Output data resulting from the operation + + parent_id: ID of the parent span if this is a child span in a trace + + task_id: ID of the task this span belongs to + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/spans", + body=await async_maybe_transform( + { + "name": name, + "start_time": start_time, + "trace_id": trace_id, + "id": id, + "data": data, + "end_time": end_time, + "input": input, + "output": output, + "parent_id": parent_id, + "task_id": task_id, + }, + span_create_params.SpanCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Span, + ) + + async def retrieve( + self, + span_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Span: + """ + Get a span by ID + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not span_id: + raise ValueError(f"Expected a non-empty value for `span_id` but received {span_id!r}") + return await self._get( + path_template("/spans/{span_id}", span_id=span_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Span, + ) + + async def update( + self, + span_id: str, + *, + data: Union[Dict[str, object], Iterable[Dict[str, object]], None] | Omit = omit, + end_time: Union[str, datetime, None] | Omit = omit, + input: Union[Dict[str, object], Iterable[Dict[str, object]], None] | Omit = omit, + name: Optional[str] | Omit = omit, + output: Union[Dict[str, object], Iterable[Dict[str, object]], None] | Omit = omit, + parent_id: Optional[str] | Omit = omit, + start_time: Union[str, datetime, None] | Omit = omit, + task_id: Optional[str] | Omit = omit, + trace_id: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Span: + """ + Update a span with the provided output data and mark it as complete + + Args: + data: Any additional metadata or context for the span + + end_time: The time the span ended + + input: Input parameters or data for the operation + + name: Name that describes what operation this span represents + + output: Output data resulting from the operation + + parent_id: ID of the parent span if this is a child span in a trace + + start_time: The time the span started + + task_id: ID of the task this span belongs to + + trace_id: Unique identifier for the trace this span belongs to + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not span_id: + raise ValueError(f"Expected a non-empty value for `span_id` but received {span_id!r}") + return await self._patch( + path_template("/spans/{span_id}", span_id=span_id), + body=await async_maybe_transform( + { + "data": data, + "end_time": end_time, + "input": input, + "name": name, + "output": output, + "parent_id": parent_id, + "start_time": start_time, + "task_id": task_id, + "trace_id": trace_id, + }, + span_update_params.SpanUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Span, + ) + + async def list( + self, + *, + limit: int | Omit = omit, + order_by: Optional[str] | Omit = omit, + order_direction: str | Omit = omit, + page_number: int | Omit = omit, + task_id: Optional[str] | Omit = omit, + trace_id: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SpanListResponse: + """ + List spans, optionally filtered by trace_id and/or task_id + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/spans", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "limit": limit, + "order_by": order_by, + "order_direction": order_direction, + "page_number": page_number, + "task_id": task_id, + "trace_id": trace_id, + }, + span_list_params.SpanListParams, + ), + ), + cast_to=SpanListResponse, + ) + + +class SpansResourceWithRawResponse: + def __init__(self, spans: SpansResource) -> None: + self._spans = spans + + self.create = to_raw_response_wrapper( + spans.create, + ) + self.retrieve = to_raw_response_wrapper( + spans.retrieve, + ) + self.update = to_raw_response_wrapper( + spans.update, + ) + self.list = to_raw_response_wrapper( + spans.list, + ) + + +class AsyncSpansResourceWithRawResponse: + def __init__(self, spans: AsyncSpansResource) -> None: + self._spans = spans + + self.create = async_to_raw_response_wrapper( + spans.create, + ) + self.retrieve = async_to_raw_response_wrapper( + spans.retrieve, + ) + self.update = async_to_raw_response_wrapper( + spans.update, + ) + self.list = async_to_raw_response_wrapper( + spans.list, + ) + + +class SpansResourceWithStreamingResponse: + def __init__(self, spans: SpansResource) -> None: + self._spans = spans + + self.create = to_streamed_response_wrapper( + spans.create, + ) + self.retrieve = to_streamed_response_wrapper( + spans.retrieve, + ) + self.update = to_streamed_response_wrapper( + spans.update, + ) + self.list = to_streamed_response_wrapper( + spans.list, + ) + + +class AsyncSpansResourceWithStreamingResponse: + def __init__(self, spans: AsyncSpansResource) -> None: + self._spans = spans + + self.create = async_to_streamed_response_wrapper( + spans.create, + ) + self.retrieve = async_to_streamed_response_wrapper( + spans.retrieve, + ) + self.update = async_to_streamed_response_wrapper( + spans.update, + ) + self.list = async_to_streamed_response_wrapper( + spans.list, + ) diff --git a/src/agentex/resources/states.py b/src/agentex/resources/states.py new file mode 100644 index 000000000..52fecbd0c --- /dev/null +++ b/src/agentex/resources/states.py @@ -0,0 +1,558 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Optional + +import httpx + +from ..types import state_list_params, state_create_params, state_update_params +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from .._utils import path_template, maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ..types.state import State +from .._base_client import make_request_options +from ..types.state_list_response import StateListResponse + +__all__ = ["StatesResource", "AsyncStatesResource"] + + +class StatesResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> StatesResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return StatesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> StatesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return StatesResourceWithStreamingResponse(self) + + def create( + self, + *, + agent_id: str, + state: Dict[str, object], + task_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> State: + """ + Create Task State + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/states", + body=maybe_transform( + { + "agent_id": agent_id, + "state": state, + "task_id": task_id, + }, + state_create_params.StateCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=State, + ) + + def retrieve( + self, + state_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> State: + """ + Get a state by its unique state ID. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not state_id: + raise ValueError(f"Expected a non-empty value for `state_id` but received {state_id!r}") + return self._get( + path_template("/states/{state_id}", state_id=state_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=State, + ) + + def update( + self, + state_id: str, + *, + state: Dict[str, object], + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> State: + """ + Update Task State + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not state_id: + raise ValueError(f"Expected a non-empty value for `state_id` but received {state_id!r}") + return self._put( + path_template("/states/{state_id}", state_id=state_id), + body=maybe_transform({"state": state}, state_update_params.StateUpdateParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=State, + ) + + def list( + self, + *, + agent_id: Optional[str] | Omit = omit, + limit: int | Omit = omit, + order_by: Optional[str] | Omit = omit, + order_direction: str | Omit = omit, + page_number: int | Omit = omit, + task_id: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> StateListResponse: + """ + List all states, optionally filtered by query parameters. + + Args: + agent_id: Agent ID + + limit: Limit + + order_by: Field to order by + + order_direction: Order direction (asc or desc) + + page_number: Page number + + task_id: Task ID + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/states", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "agent_id": agent_id, + "limit": limit, + "order_by": order_by, + "order_direction": order_direction, + "page_number": page_number, + "task_id": task_id, + }, + state_list_params.StateListParams, + ), + ), + cast_to=StateListResponse, + ) + + def delete( + self, + state_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> State: + """ + Delete Task State + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not state_id: + raise ValueError(f"Expected a non-empty value for `state_id` but received {state_id!r}") + return self._delete( + path_template("/states/{state_id}", state_id=state_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=State, + ) + + +class AsyncStatesResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncStatesResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return AsyncStatesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncStatesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return AsyncStatesResourceWithStreamingResponse(self) + + async def create( + self, + *, + agent_id: str, + state: Dict[str, object], + task_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> State: + """ + Create Task State + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/states", + body=await async_maybe_transform( + { + "agent_id": agent_id, + "state": state, + "task_id": task_id, + }, + state_create_params.StateCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=State, + ) + + async def retrieve( + self, + state_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> State: + """ + Get a state by its unique state ID. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not state_id: + raise ValueError(f"Expected a non-empty value for `state_id` but received {state_id!r}") + return await self._get( + path_template("/states/{state_id}", state_id=state_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=State, + ) + + async def update( + self, + state_id: str, + *, + state: Dict[str, object], + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> State: + """ + Update Task State + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not state_id: + raise ValueError(f"Expected a non-empty value for `state_id` but received {state_id!r}") + return await self._put( + path_template("/states/{state_id}", state_id=state_id), + body=await async_maybe_transform({"state": state}, state_update_params.StateUpdateParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=State, + ) + + async def list( + self, + *, + agent_id: Optional[str] | Omit = omit, + limit: int | Omit = omit, + order_by: Optional[str] | Omit = omit, + order_direction: str | Omit = omit, + page_number: int | Omit = omit, + task_id: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> StateListResponse: + """ + List all states, optionally filtered by query parameters. + + Args: + agent_id: Agent ID + + limit: Limit + + order_by: Field to order by + + order_direction: Order direction (asc or desc) + + page_number: Page number + + task_id: Task ID + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/states", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "agent_id": agent_id, + "limit": limit, + "order_by": order_by, + "order_direction": order_direction, + "page_number": page_number, + "task_id": task_id, + }, + state_list_params.StateListParams, + ), + ), + cast_to=StateListResponse, + ) + + async def delete( + self, + state_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> State: + """ + Delete Task State + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not state_id: + raise ValueError(f"Expected a non-empty value for `state_id` but received {state_id!r}") + return await self._delete( + path_template("/states/{state_id}", state_id=state_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=State, + ) + + +class StatesResourceWithRawResponse: + def __init__(self, states: StatesResource) -> None: + self._states = states + + self.create = to_raw_response_wrapper( + states.create, + ) + self.retrieve = to_raw_response_wrapper( + states.retrieve, + ) + self.update = to_raw_response_wrapper( + states.update, + ) + self.list = to_raw_response_wrapper( + states.list, + ) + self.delete = to_raw_response_wrapper( + states.delete, + ) + + +class AsyncStatesResourceWithRawResponse: + def __init__(self, states: AsyncStatesResource) -> None: + self._states = states + + self.create = async_to_raw_response_wrapper( + states.create, + ) + self.retrieve = async_to_raw_response_wrapper( + states.retrieve, + ) + self.update = async_to_raw_response_wrapper( + states.update, + ) + self.list = async_to_raw_response_wrapper( + states.list, + ) + self.delete = async_to_raw_response_wrapper( + states.delete, + ) + + +class StatesResourceWithStreamingResponse: + def __init__(self, states: StatesResource) -> None: + self._states = states + + self.create = to_streamed_response_wrapper( + states.create, + ) + self.retrieve = to_streamed_response_wrapper( + states.retrieve, + ) + self.update = to_streamed_response_wrapper( + states.update, + ) + self.list = to_streamed_response_wrapper( + states.list, + ) + self.delete = to_streamed_response_wrapper( + states.delete, + ) + + +class AsyncStatesResourceWithStreamingResponse: + def __init__(self, states: AsyncStatesResource) -> None: + self._states = states + + self.create = async_to_streamed_response_wrapper( + states.create, + ) + self.retrieve = async_to_streamed_response_wrapper( + states.retrieve, + ) + self.update = async_to_streamed_response_wrapper( + states.update, + ) + self.list = async_to_streamed_response_wrapper( + states.list, + ) + self.delete = async_to_streamed_response_wrapper( + states.delete, + ) diff --git a/src/agentex/resources/tasks.py b/src/agentex/resources/tasks.py new file mode 100644 index 000000000..4ddafa738 --- /dev/null +++ b/src/agentex/resources/tasks.py @@ -0,0 +1,1530 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, List, Optional +from typing_extensions import Literal + +import httpx + +from ..types import ( + task_fail_params, + task_list_params, + task_cancel_params, + task_timeout_params, + task_complete_params, + task_retrieve_params, + task_interrupt_params, + task_terminate_params, + task_update_by_id_params, + task_update_by_name_params, + task_retrieve_by_name_params, +) +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from .._utils import path_template, maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._streaming import Stream, AsyncStream +from ..types.task import Task +from .._base_client import make_request_options +from ..types.task_list_response import TaskListResponse +from ..types.shared.delete_response import DeleteResponse +from ..types.task_retrieve_response import TaskRetrieveResponse +from ..types.task_query_workflow_response import TaskQueryWorkflowResponse +from ..types.task_retrieve_by_name_response import TaskRetrieveByNameResponse + +__all__ = ["TasksResource", "AsyncTasksResource"] + + +class TasksResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> TasksResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return TasksResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> TasksResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return TasksResourceWithStreamingResponse(self) + + def retrieve( + self, + task_id: str, + *, + relationships: List[Literal["agents"]] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> TaskRetrieveResponse: + """ + Get a task by its unique ID. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_id: + raise ValueError(f"Expected a non-empty value for `task_id` but received {task_id!r}") + return self._get( + path_template("/tasks/{task_id}", task_id=task_id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"relationships": relationships}, task_retrieve_params.TaskRetrieveParams), + ), + cast_to=TaskRetrieveResponse, + ) + + def list( + self, + *, + agent_id: Optional[str] | Omit = omit, + agent_name: Optional[str] | Omit = omit, + limit: int | Omit = omit, + order_by: Optional[str] | Omit = omit, + order_direction: str | Omit = omit, + page_number: int | Omit = omit, + relationships: List[Literal["agents"]] | Omit = omit, + status: Optional[ + Literal["CANCELED", "COMPLETED", "FAILED", "RUNNING", "INTERRUPTED", "TERMINATED", "TIMED_OUT", "DELETED"] + ] + | Omit = omit, + task_metadata: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> TaskListResponse: + """List tasks. + + Returns a lean summary per task and omits `params`; fetch GET + /tasks/{task_id} for the full record including `params`. + + Args: + status: Filter tasks by status (e.g. RUNNING, COMPLETED). + + task_metadata: + JSON-encoded object used to filter tasks via JSONB containment. Example: + {"created_by_user_id": "abc-123"}. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/tasks", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "agent_id": agent_id, + "agent_name": agent_name, + "limit": limit, + "order_by": order_by, + "order_direction": order_direction, + "page_number": page_number, + "relationships": relationships, + "status": status, + "task_metadata": task_metadata, + }, + task_list_params.TaskListParams, + ), + ), + cast_to=TaskListResponse, + ) + + def delete( + self, + task_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeleteResponse: + """ + Delete a task by its unique ID. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_id: + raise ValueError(f"Expected a non-empty value for `task_id` but received {task_id!r}") + return self._delete( + path_template("/tasks/{task_id}", task_id=task_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeleteResponse, + ) + + def cancel( + self, + task_id: str, + *, + reason: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Task: + """ + Mark a running task as canceled. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_id: + raise ValueError(f"Expected a non-empty value for `task_id` but received {task_id!r}") + return self._post( + path_template("/tasks/{task_id}/cancel", task_id=task_id), + body=maybe_transform({"reason": reason}, task_cancel_params.TaskCancelParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Task, + ) + + def complete( + self, + task_id: str, + *, + reason: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Task: + """ + Mark a running task as completed. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_id: + raise ValueError(f"Expected a non-empty value for `task_id` but received {task_id!r}") + return self._post( + path_template("/tasks/{task_id}/complete", task_id=task_id), + body=maybe_transform({"reason": reason}, task_complete_params.TaskCompleteParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Task, + ) + + def delete_by_name( + self, + task_name: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeleteResponse: + """ + Delete a task by its unique name. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_name: + raise ValueError(f"Expected a non-empty value for `task_name` but received {task_name!r}") + return self._delete( + path_template("/tasks/name/{task_name}", task_name=task_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeleteResponse, + ) + + def fail( + self, + task_id: str, + *, + reason: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Task: + """ + Mark a running task as failed. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_id: + raise ValueError(f"Expected a non-empty value for `task_id` but received {task_id!r}") + return self._post( + path_template("/tasks/{task_id}/fail", task_id=task_id), + body=maybe_transform({"reason": reason}, task_fail_params.TaskFailParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Task, + ) + + def interrupt( + self, + task_id: str, + *, + reason: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Task: + """Stop the in-flight turn without terminating the task. + + Transitions a running task + to the non-terminal INTERRUPTED status; the task stays continuable and the next + message or event resumes it. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_id: + raise ValueError(f"Expected a non-empty value for `task_id` but received {task_id!r}") + return self._post( + path_template("/tasks/{task_id}/interrupt", task_id=task_id), + body=maybe_transform({"reason": reason}, task_interrupt_params.TaskInterruptParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Task, + ) + + def query_workflow( + self, + query_name: str, + *, + task_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> TaskQueryWorkflowResponse: + """ + Query a Temporal workflow associated with a task for its current state. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_id: + raise ValueError(f"Expected a non-empty value for `task_id` but received {task_id!r}") + if not query_name: + raise ValueError(f"Expected a non-empty value for `query_name` but received {query_name!r}") + return self._get( + path_template("/tasks/{task_id}/query/{query_name}", task_id=task_id, query_name=query_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=TaskQueryWorkflowResponse, + ) + + def retrieve_by_name( + self, + task_name: str, + *, + relationships: List[Literal["agents"]] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> TaskRetrieveByNameResponse: + """ + Get a task by its unique name. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_name: + raise ValueError(f"Expected a non-empty value for `task_name` but received {task_name!r}") + return self._get( + path_template("/tasks/name/{task_name}", task_name=task_name), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + {"relationships": relationships}, task_retrieve_by_name_params.TaskRetrieveByNameParams + ), + ), + cast_to=TaskRetrieveByNameResponse, + ) + + def stream_events( + self, + task_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Stream[object]: + """ + Stream events for a task by its unique ID. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_id: + raise ValueError(f"Expected a non-empty value for `task_id` but received {task_id!r}") + return self._get( + path_template("/tasks/{task_id}/stream", task_id=task_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=object, + stream=True, + stream_cls=Stream[object], + ) + + def stream_events_by_name( + self, + task_name: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Stream[object]: + """ + Stream events for a task by its unique name. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_name: + raise ValueError(f"Expected a non-empty value for `task_name` but received {task_name!r}") + return self._get( + path_template("/tasks/name/{task_name}/stream", task_name=task_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=object, + stream=True, + stream_cls=Stream[object], + ) + + def terminate( + self, + task_id: str, + *, + reason: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Task: + """ + Mark a running task as terminated. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_id: + raise ValueError(f"Expected a non-empty value for `task_id` but received {task_id!r}") + return self._post( + path_template("/tasks/{task_id}/terminate", task_id=task_id), + body=maybe_transform({"reason": reason}, task_terminate_params.TaskTerminateParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Task, + ) + + def timeout( + self, + task_id: str, + *, + reason: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Task: + """ + Mark a running task as timed out. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_id: + raise ValueError(f"Expected a non-empty value for `task_id` but received {task_id!r}") + return self._post( + path_template("/tasks/{task_id}/timeout", task_id=task_id), + body=maybe_transform({"reason": reason}, task_timeout_params.TaskTimeoutParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Task, + ) + + def update_by_id( + self, + task_id: str, + *, + merge_params: Optional[Dict[str, object]] | Omit = omit, + task_metadata: Optional[Dict[str, object]] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Task: + """ + Update mutable fields for a task by its unique ID. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_id: + raise ValueError(f"Expected a non-empty value for `task_id` but received {task_id!r}") + return self._put( + path_template("/tasks/{task_id}", task_id=task_id), + body=maybe_transform( + { + "merge_params": merge_params, + "task_metadata": task_metadata, + }, + task_update_by_id_params.TaskUpdateByIDParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Task, + ) + + def update_by_name( + self, + task_name: str, + *, + merge_params: Optional[Dict[str, object]] | Omit = omit, + task_metadata: Optional[Dict[str, object]] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Task: + """ + Update mutable fields for a task by its unique Name. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_name: + raise ValueError(f"Expected a non-empty value for `task_name` but received {task_name!r}") + return self._put( + path_template("/tasks/name/{task_name}", task_name=task_name), + body=maybe_transform( + { + "merge_params": merge_params, + "task_metadata": task_metadata, + }, + task_update_by_name_params.TaskUpdateByNameParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Task, + ) + + +class AsyncTasksResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncTasksResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return AsyncTasksResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncTasksResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return AsyncTasksResourceWithStreamingResponse(self) + + async def retrieve( + self, + task_id: str, + *, + relationships: List[Literal["agents"]] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> TaskRetrieveResponse: + """ + Get a task by its unique ID. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_id: + raise ValueError(f"Expected a non-empty value for `task_id` but received {task_id!r}") + return await self._get( + path_template("/tasks/{task_id}", task_id=task_id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"relationships": relationships}, task_retrieve_params.TaskRetrieveParams + ), + ), + cast_to=TaskRetrieveResponse, + ) + + async def list( + self, + *, + agent_id: Optional[str] | Omit = omit, + agent_name: Optional[str] | Omit = omit, + limit: int | Omit = omit, + order_by: Optional[str] | Omit = omit, + order_direction: str | Omit = omit, + page_number: int | Omit = omit, + relationships: List[Literal["agents"]] | Omit = omit, + status: Optional[ + Literal["CANCELED", "COMPLETED", "FAILED", "RUNNING", "INTERRUPTED", "TERMINATED", "TIMED_OUT", "DELETED"] + ] + | Omit = omit, + task_metadata: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> TaskListResponse: + """List tasks. + + Returns a lean summary per task and omits `params`; fetch GET + /tasks/{task_id} for the full record including `params`. + + Args: + status: Filter tasks by status (e.g. RUNNING, COMPLETED). + + task_metadata: + JSON-encoded object used to filter tasks via JSONB containment. Example: + {"created_by_user_id": "abc-123"}. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/tasks", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "agent_id": agent_id, + "agent_name": agent_name, + "limit": limit, + "order_by": order_by, + "order_direction": order_direction, + "page_number": page_number, + "relationships": relationships, + "status": status, + "task_metadata": task_metadata, + }, + task_list_params.TaskListParams, + ), + ), + cast_to=TaskListResponse, + ) + + async def delete( + self, + task_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeleteResponse: + """ + Delete a task by its unique ID. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_id: + raise ValueError(f"Expected a non-empty value for `task_id` but received {task_id!r}") + return await self._delete( + path_template("/tasks/{task_id}", task_id=task_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeleteResponse, + ) + + async def cancel( + self, + task_id: str, + *, + reason: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Task: + """ + Mark a running task as canceled. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_id: + raise ValueError(f"Expected a non-empty value for `task_id` but received {task_id!r}") + return await self._post( + path_template("/tasks/{task_id}/cancel", task_id=task_id), + body=await async_maybe_transform({"reason": reason}, task_cancel_params.TaskCancelParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Task, + ) + + async def complete( + self, + task_id: str, + *, + reason: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Task: + """ + Mark a running task as completed. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_id: + raise ValueError(f"Expected a non-empty value for `task_id` but received {task_id!r}") + return await self._post( + path_template("/tasks/{task_id}/complete", task_id=task_id), + body=await async_maybe_transform({"reason": reason}, task_complete_params.TaskCompleteParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Task, + ) + + async def delete_by_name( + self, + task_name: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> DeleteResponse: + """ + Delete a task by its unique name. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_name: + raise ValueError(f"Expected a non-empty value for `task_name` but received {task_name!r}") + return await self._delete( + path_template("/tasks/name/{task_name}", task_name=task_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=DeleteResponse, + ) + + async def fail( + self, + task_id: str, + *, + reason: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Task: + """ + Mark a running task as failed. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_id: + raise ValueError(f"Expected a non-empty value for `task_id` but received {task_id!r}") + return await self._post( + path_template("/tasks/{task_id}/fail", task_id=task_id), + body=await async_maybe_transform({"reason": reason}, task_fail_params.TaskFailParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Task, + ) + + async def interrupt( + self, + task_id: str, + *, + reason: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Task: + """Stop the in-flight turn without terminating the task. + + Transitions a running task + to the non-terminal INTERRUPTED status; the task stays continuable and the next + message or event resumes it. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_id: + raise ValueError(f"Expected a non-empty value for `task_id` but received {task_id!r}") + return await self._post( + path_template("/tasks/{task_id}/interrupt", task_id=task_id), + body=await async_maybe_transform({"reason": reason}, task_interrupt_params.TaskInterruptParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Task, + ) + + async def query_workflow( + self, + query_name: str, + *, + task_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> TaskQueryWorkflowResponse: + """ + Query a Temporal workflow associated with a task for its current state. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_id: + raise ValueError(f"Expected a non-empty value for `task_id` but received {task_id!r}") + if not query_name: + raise ValueError(f"Expected a non-empty value for `query_name` but received {query_name!r}") + return await self._get( + path_template("/tasks/{task_id}/query/{query_name}", task_id=task_id, query_name=query_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=TaskQueryWorkflowResponse, + ) + + async def retrieve_by_name( + self, + task_name: str, + *, + relationships: List[Literal["agents"]] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> TaskRetrieveByNameResponse: + """ + Get a task by its unique name. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_name: + raise ValueError(f"Expected a non-empty value for `task_name` but received {task_name!r}") + return await self._get( + path_template("/tasks/name/{task_name}", task_name=task_name), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"relationships": relationships}, task_retrieve_by_name_params.TaskRetrieveByNameParams + ), + ), + cast_to=TaskRetrieveByNameResponse, + ) + + async def stream_events( + self, + task_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AsyncStream[object]: + """ + Stream events for a task by its unique ID. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_id: + raise ValueError(f"Expected a non-empty value for `task_id` but received {task_id!r}") + return await self._get( + path_template("/tasks/{task_id}/stream", task_id=task_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=object, + stream=True, + stream_cls=AsyncStream[object], + ) + + async def stream_events_by_name( + self, + task_name: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AsyncStream[object]: + """ + Stream events for a task by its unique name. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_name: + raise ValueError(f"Expected a non-empty value for `task_name` but received {task_name!r}") + return await self._get( + path_template("/tasks/name/{task_name}/stream", task_name=task_name), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=object, + stream=True, + stream_cls=AsyncStream[object], + ) + + async def terminate( + self, + task_id: str, + *, + reason: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Task: + """ + Mark a running task as terminated. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_id: + raise ValueError(f"Expected a non-empty value for `task_id` but received {task_id!r}") + return await self._post( + path_template("/tasks/{task_id}/terminate", task_id=task_id), + body=await async_maybe_transform({"reason": reason}, task_terminate_params.TaskTerminateParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Task, + ) + + async def timeout( + self, + task_id: str, + *, + reason: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Task: + """ + Mark a running task as timed out. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_id: + raise ValueError(f"Expected a non-empty value for `task_id` but received {task_id!r}") + return await self._post( + path_template("/tasks/{task_id}/timeout", task_id=task_id), + body=await async_maybe_transform({"reason": reason}, task_timeout_params.TaskTimeoutParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Task, + ) + + async def update_by_id( + self, + task_id: str, + *, + merge_params: Optional[Dict[str, object]] | Omit = omit, + task_metadata: Optional[Dict[str, object]] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Task: + """ + Update mutable fields for a task by its unique ID. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_id: + raise ValueError(f"Expected a non-empty value for `task_id` but received {task_id!r}") + return await self._put( + path_template("/tasks/{task_id}", task_id=task_id), + body=await async_maybe_transform( + { + "merge_params": merge_params, + "task_metadata": task_metadata, + }, + task_update_by_id_params.TaskUpdateByIDParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Task, + ) + + async def update_by_name( + self, + task_name: str, + *, + merge_params: Optional[Dict[str, object]] | Omit = omit, + task_metadata: Optional[Dict[str, object]] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Task: + """ + Update mutable fields for a task by its unique Name. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not task_name: + raise ValueError(f"Expected a non-empty value for `task_name` but received {task_name!r}") + return await self._put( + path_template("/tasks/name/{task_name}", task_name=task_name), + body=await async_maybe_transform( + { + "merge_params": merge_params, + "task_metadata": task_metadata, + }, + task_update_by_name_params.TaskUpdateByNameParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Task, + ) + + +class TasksResourceWithRawResponse: + def __init__(self, tasks: TasksResource) -> None: + self._tasks = tasks + + self.retrieve = to_raw_response_wrapper( + tasks.retrieve, + ) + self.list = to_raw_response_wrapper( + tasks.list, + ) + self.delete = to_raw_response_wrapper( + tasks.delete, + ) + self.cancel = to_raw_response_wrapper( + tasks.cancel, + ) + self.complete = to_raw_response_wrapper( + tasks.complete, + ) + self.delete_by_name = to_raw_response_wrapper( + tasks.delete_by_name, + ) + self.fail = to_raw_response_wrapper( + tasks.fail, + ) + self.interrupt = to_raw_response_wrapper( + tasks.interrupt, + ) + self.query_workflow = to_raw_response_wrapper( + tasks.query_workflow, + ) + self.retrieve_by_name = to_raw_response_wrapper( + tasks.retrieve_by_name, + ) + self.stream_events = to_raw_response_wrapper( + tasks.stream_events, + ) + self.stream_events_by_name = to_raw_response_wrapper( + tasks.stream_events_by_name, + ) + self.terminate = to_raw_response_wrapper( + tasks.terminate, + ) + self.timeout = to_raw_response_wrapper( + tasks.timeout, + ) + self.update_by_id = to_raw_response_wrapper( + tasks.update_by_id, + ) + self.update_by_name = to_raw_response_wrapper( + tasks.update_by_name, + ) + + +class AsyncTasksResourceWithRawResponse: + def __init__(self, tasks: AsyncTasksResource) -> None: + self._tasks = tasks + + self.retrieve = async_to_raw_response_wrapper( + tasks.retrieve, + ) + self.list = async_to_raw_response_wrapper( + tasks.list, + ) + self.delete = async_to_raw_response_wrapper( + tasks.delete, + ) + self.cancel = async_to_raw_response_wrapper( + tasks.cancel, + ) + self.complete = async_to_raw_response_wrapper( + tasks.complete, + ) + self.delete_by_name = async_to_raw_response_wrapper( + tasks.delete_by_name, + ) + self.fail = async_to_raw_response_wrapper( + tasks.fail, + ) + self.interrupt = async_to_raw_response_wrapper( + tasks.interrupt, + ) + self.query_workflow = async_to_raw_response_wrapper( + tasks.query_workflow, + ) + self.retrieve_by_name = async_to_raw_response_wrapper( + tasks.retrieve_by_name, + ) + self.stream_events = async_to_raw_response_wrapper( + tasks.stream_events, + ) + self.stream_events_by_name = async_to_raw_response_wrapper( + tasks.stream_events_by_name, + ) + self.terminate = async_to_raw_response_wrapper( + tasks.terminate, + ) + self.timeout = async_to_raw_response_wrapper( + tasks.timeout, + ) + self.update_by_id = async_to_raw_response_wrapper( + tasks.update_by_id, + ) + self.update_by_name = async_to_raw_response_wrapper( + tasks.update_by_name, + ) + + +class TasksResourceWithStreamingResponse: + def __init__(self, tasks: TasksResource) -> None: + self._tasks = tasks + + self.retrieve = to_streamed_response_wrapper( + tasks.retrieve, + ) + self.list = to_streamed_response_wrapper( + tasks.list, + ) + self.delete = to_streamed_response_wrapper( + tasks.delete, + ) + self.cancel = to_streamed_response_wrapper( + tasks.cancel, + ) + self.complete = to_streamed_response_wrapper( + tasks.complete, + ) + self.delete_by_name = to_streamed_response_wrapper( + tasks.delete_by_name, + ) + self.fail = to_streamed_response_wrapper( + tasks.fail, + ) + self.interrupt = to_streamed_response_wrapper( + tasks.interrupt, + ) + self.query_workflow = to_streamed_response_wrapper( + tasks.query_workflow, + ) + self.retrieve_by_name = to_streamed_response_wrapper( + tasks.retrieve_by_name, + ) + self.stream_events = to_streamed_response_wrapper( + tasks.stream_events, + ) + self.stream_events_by_name = to_streamed_response_wrapper( + tasks.stream_events_by_name, + ) + self.terminate = to_streamed_response_wrapper( + tasks.terminate, + ) + self.timeout = to_streamed_response_wrapper( + tasks.timeout, + ) + self.update_by_id = to_streamed_response_wrapper( + tasks.update_by_id, + ) + self.update_by_name = to_streamed_response_wrapper( + tasks.update_by_name, + ) + + +class AsyncTasksResourceWithStreamingResponse: + def __init__(self, tasks: AsyncTasksResource) -> None: + self._tasks = tasks + + self.retrieve = async_to_streamed_response_wrapper( + tasks.retrieve, + ) + self.list = async_to_streamed_response_wrapper( + tasks.list, + ) + self.delete = async_to_streamed_response_wrapper( + tasks.delete, + ) + self.cancel = async_to_streamed_response_wrapper( + tasks.cancel, + ) + self.complete = async_to_streamed_response_wrapper( + tasks.complete, + ) + self.delete_by_name = async_to_streamed_response_wrapper( + tasks.delete_by_name, + ) + self.fail = async_to_streamed_response_wrapper( + tasks.fail, + ) + self.interrupt = async_to_streamed_response_wrapper( + tasks.interrupt, + ) + self.query_workflow = async_to_streamed_response_wrapper( + tasks.query_workflow, + ) + self.retrieve_by_name = async_to_streamed_response_wrapper( + tasks.retrieve_by_name, + ) + self.stream_events = async_to_streamed_response_wrapper( + tasks.stream_events, + ) + self.stream_events_by_name = async_to_streamed_response_wrapper( + tasks.stream_events_by_name, + ) + self.terminate = async_to_streamed_response_wrapper( + tasks.terminate, + ) + self.timeout = async_to_streamed_response_wrapper( + tasks.timeout, + ) + self.update_by_id = async_to_streamed_response_wrapper( + tasks.update_by_id, + ) + self.update_by_name = async_to_streamed_response_wrapper( + tasks.update_by_name, + ) diff --git a/src/agentex/resources/tracker.py b/src/agentex/resources/tracker.py new file mode 100644 index 000000000..14de3fc49 --- /dev/null +++ b/src/agentex/resources/tracker.py @@ -0,0 +1,416 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional + +import httpx + +from ..types import tracker_list_params, tracker_update_params +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from .._utils import path_template, maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.agent_task_tracker import AgentTaskTracker +from ..types.tracker_list_response import TrackerListResponse + +__all__ = ["TrackerResource", "AsyncTrackerResource"] + + +class TrackerResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> TrackerResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return TrackerResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> TrackerResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return TrackerResourceWithStreamingResponse(self) + + def retrieve( + self, + tracker_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AgentTaskTracker: + """ + Get agent task tracker by tracker ID + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not tracker_id: + raise ValueError(f"Expected a non-empty value for `tracker_id` but received {tracker_id!r}") + return self._get( + path_template("/tracker/{tracker_id}", tracker_id=tracker_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AgentTaskTracker, + ) + + def update( + self, + tracker_id: str, + *, + last_processed_event_id: Optional[str] | Omit = omit, + status: Optional[str] | Omit = omit, + status_reason: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AgentTaskTracker: + """ + Update agent task tracker by tracker ID + + Args: + last_processed_event_id: The most recent processed event ID (omit to leave unchanged) + + status: Processing status + + status_reason: Optional status reason + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not tracker_id: + raise ValueError(f"Expected a non-empty value for `tracker_id` but received {tracker_id!r}") + return self._put( + path_template("/tracker/{tracker_id}", tracker_id=tracker_id), + body=maybe_transform( + { + "last_processed_event_id": last_processed_event_id, + "status": status, + "status_reason": status_reason, + }, + tracker_update_params.TrackerUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AgentTaskTracker, + ) + + def list( + self, + *, + agent_id: Optional[str] | Omit = omit, + limit: int | Omit = omit, + order_by: Optional[str] | Omit = omit, + order_direction: str | Omit = omit, + page_number: int | Omit = omit, + task_id: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> TrackerListResponse: + """ + List all agent task trackers, optionally filtered by query parameters. + + Args: + agent_id: Agent ID + + limit: Limit + + order_by: Field to order by + + order_direction: Order direction (asc or desc) + + page_number: Page number + + task_id: Task ID + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get( + "/tracker", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "agent_id": agent_id, + "limit": limit, + "order_by": order_by, + "order_direction": order_direction, + "page_number": page_number, + "task_id": task_id, + }, + tracker_list_params.TrackerListParams, + ), + ), + cast_to=TrackerListResponse, + ) + + +class AsyncTrackerResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncTrackerResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return AsyncTrackerResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncTrackerResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return AsyncTrackerResourceWithStreamingResponse(self) + + async def retrieve( + self, + tracker_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AgentTaskTracker: + """ + Get agent task tracker by tracker ID + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not tracker_id: + raise ValueError(f"Expected a non-empty value for `tracker_id` but received {tracker_id!r}") + return await self._get( + path_template("/tracker/{tracker_id}", tracker_id=tracker_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AgentTaskTracker, + ) + + async def update( + self, + tracker_id: str, + *, + last_processed_event_id: Optional[str] | Omit = omit, + status: Optional[str] | Omit = omit, + status_reason: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AgentTaskTracker: + """ + Update agent task tracker by tracker ID + + Args: + last_processed_event_id: The most recent processed event ID (omit to leave unchanged) + + status: Processing status + + status_reason: Optional status reason + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not tracker_id: + raise ValueError(f"Expected a non-empty value for `tracker_id` but received {tracker_id!r}") + return await self._put( + path_template("/tracker/{tracker_id}", tracker_id=tracker_id), + body=await async_maybe_transform( + { + "last_processed_event_id": last_processed_event_id, + "status": status, + "status_reason": status_reason, + }, + tracker_update_params.TrackerUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AgentTaskTracker, + ) + + async def list( + self, + *, + agent_id: Optional[str] | Omit = omit, + limit: int | Omit = omit, + order_by: Optional[str] | Omit = omit, + order_direction: str | Omit = omit, + page_number: int | Omit = omit, + task_id: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> TrackerListResponse: + """ + List all agent task trackers, optionally filtered by query parameters. + + Args: + agent_id: Agent ID + + limit: Limit + + order_by: Field to order by + + order_direction: Order direction (asc or desc) + + page_number: Page number + + task_id: Task ID + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._get( + "/tracker", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "agent_id": agent_id, + "limit": limit, + "order_by": order_by, + "order_direction": order_direction, + "page_number": page_number, + "task_id": task_id, + }, + tracker_list_params.TrackerListParams, + ), + ), + cast_to=TrackerListResponse, + ) + + +class TrackerResourceWithRawResponse: + def __init__(self, tracker: TrackerResource) -> None: + self._tracker = tracker + + self.retrieve = to_raw_response_wrapper( + tracker.retrieve, + ) + self.update = to_raw_response_wrapper( + tracker.update, + ) + self.list = to_raw_response_wrapper( + tracker.list, + ) + + +class AsyncTrackerResourceWithRawResponse: + def __init__(self, tracker: AsyncTrackerResource) -> None: + self._tracker = tracker + + self.retrieve = async_to_raw_response_wrapper( + tracker.retrieve, + ) + self.update = async_to_raw_response_wrapper( + tracker.update, + ) + self.list = async_to_raw_response_wrapper( + tracker.list, + ) + + +class TrackerResourceWithStreamingResponse: + def __init__(self, tracker: TrackerResource) -> None: + self._tracker = tracker + + self.retrieve = to_streamed_response_wrapper( + tracker.retrieve, + ) + self.update = to_streamed_response_wrapper( + tracker.update, + ) + self.list = to_streamed_response_wrapper( + tracker.list, + ) + + +class AsyncTrackerResourceWithStreamingResponse: + def __init__(self, tracker: AsyncTrackerResource) -> None: + self._tracker = tracker + + self.retrieve = async_to_streamed_response_wrapper( + tracker.retrieve, + ) + self.update = async_to_streamed_response_wrapper( + tracker.update, + ) + self.list = async_to_streamed_response_wrapper( + tracker.list, + ) diff --git a/src/agentex/resources/webhooks.py b/src/agentex/resources/webhooks.py new file mode 100644 index 000000000..f565a7870 --- /dev/null +++ b/src/agentex/resources/webhooks.py @@ -0,0 +1,242 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import Literal + +import httpx + +from ..types import webhook_create_webhook_trigger_params +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.webhook_create_webhook_trigger_response import WebhookCreateWebhookTriggerResponse + +__all__ = ["WebhooksResource", "AsyncWebhooksResource"] + + +class WebhooksResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> WebhooksResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return WebhooksResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> WebhooksResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return WebhooksResourceWithStreamingResponse(self) + + def create_webhook_trigger( + self, + *, + agent_name: str, + forward_path: str, + name: str, + base_url: Optional[str] | Omit = omit, + secret: Optional[str] | Omit = omit, + source: Literal["internal", "external", "github", "slack"] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> WebhookCreateWebhookTriggerResponse: + """ + Wire a webhook trigger in one call. + + Registers the source's signature-verification key (github/slack) for the agent + and returns the ready-to-paste forward webhook URL plus the signing secret + (shown once). The webhook then flows through the existing /agents/forward + ingress, which verifies the signature against this key. Bundles the existing + key-create + URL composition so a UI (or a curl) can set up a trigger without + two steps. + + Args: + agent_name: The agent the webhook drives. + + forward_path: Subpath the agent's own route handles, e.g. 'github-pr/'. Appended to + /agents/forward/name/{agent_name}/ to form the webhook URL. + + name: Signature-lookup key: the repo full_name (github) or api_app_id (slack) that the + forward ingress matches the incoming webhook against. + + base_url: Optional public agentex base URL for the returned webhook_url; defaults to the + AGENTEX_PUBLIC_URL env var. + + secret: Signing secret. For GitHub, omit to generate one, or provide an existing webhook + secret. For Slack, this is required and must be the Slack app's Signing Secret. + + source: Webhook source whose signature is verified (github or slack). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/agent_api_keys/webhook-trigger", + body=maybe_transform( + { + "agent_name": agent_name, + "forward_path": forward_path, + "name": name, + "base_url": base_url, + "secret": secret, + "source": source, + }, + webhook_create_webhook_trigger_params.WebhookCreateWebhookTriggerParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=WebhookCreateWebhookTriggerResponse, + ) + + +class AsyncWebhooksResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncWebhooksResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#accessing-raw-response-data-eg-headers + """ + return AsyncWebhooksResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncWebhooksResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/scaleapi/scale-agentex-python#with_streaming_response + """ + return AsyncWebhooksResourceWithStreamingResponse(self) + + async def create_webhook_trigger( + self, + *, + agent_name: str, + forward_path: str, + name: str, + base_url: Optional[str] | Omit = omit, + secret: Optional[str] | Omit = omit, + source: Literal["internal", "external", "github", "slack"] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> WebhookCreateWebhookTriggerResponse: + """ + Wire a webhook trigger in one call. + + Registers the source's signature-verification key (github/slack) for the agent + and returns the ready-to-paste forward webhook URL plus the signing secret + (shown once). The webhook then flows through the existing /agents/forward + ingress, which verifies the signature against this key. Bundles the existing + key-create + URL composition so a UI (or a curl) can set up a trigger without + two steps. + + Args: + agent_name: The agent the webhook drives. + + forward_path: Subpath the agent's own route handles, e.g. 'github-pr/'. Appended to + /agents/forward/name/{agent_name}/ to form the webhook URL. + + name: Signature-lookup key: the repo full_name (github) or api_app_id (slack) that the + forward ingress matches the incoming webhook against. + + base_url: Optional public agentex base URL for the returned webhook_url; defaults to the + AGENTEX_PUBLIC_URL env var. + + secret: Signing secret. For GitHub, omit to generate one, or provide an existing webhook + secret. For Slack, this is required and must be the Slack app's Signing Secret. + + source: Webhook source whose signature is verified (github or slack). + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/agent_api_keys/webhook-trigger", + body=await async_maybe_transform( + { + "agent_name": agent_name, + "forward_path": forward_path, + "name": name, + "base_url": base_url, + "secret": secret, + "source": source, + }, + webhook_create_webhook_trigger_params.WebhookCreateWebhookTriggerParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=WebhookCreateWebhookTriggerResponse, + ) + + +class WebhooksResourceWithRawResponse: + def __init__(self, webhooks: WebhooksResource) -> None: + self._webhooks = webhooks + + self.create_webhook_trigger = to_raw_response_wrapper( + webhooks.create_webhook_trigger, + ) + + +class AsyncWebhooksResourceWithRawResponse: + def __init__(self, webhooks: AsyncWebhooksResource) -> None: + self._webhooks = webhooks + + self.create_webhook_trigger = async_to_raw_response_wrapper( + webhooks.create_webhook_trigger, + ) + + +class WebhooksResourceWithStreamingResponse: + def __init__(self, webhooks: WebhooksResource) -> None: + self._webhooks = webhooks + + self.create_webhook_trigger = to_streamed_response_wrapper( + webhooks.create_webhook_trigger, + ) + + +class AsyncWebhooksResourceWithStreamingResponse: + def __init__(self, webhooks: AsyncWebhooksResource) -> None: + self._webhooks = webhooks + + self.create_webhook_trigger = async_to_streamed_response_wrapper( + webhooks.create_webhook_trigger, + ) diff --git a/src/agentex/types/__init__.py b/src/agentex/types/__init__.py new file mode 100644 index 000000000..674503012 --- /dev/null +++ b/src/agentex/types/__init__.py @@ -0,0 +1,94 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .span import Span as Span +from .task import Task as Task +from .agent import Agent as Agent +from .event import Event as Event +from .state import State as State +from .shared import DeleteResponse as DeleteResponse +from .acp_type import AcpType as AcpType +from .data_delta import DataDelta as DataDelta +from .text_delta import TextDelta as TextDelta +from .text_format import TextFormat as TextFormat +from .data_content import DataContent as DataContent +from .task_message import TaskMessage as TaskMessage +from .text_content import TextContent as TextContent +from .message_style import MessageStyle as MessageStyle +from .message_author import MessageAuthor as MessageAuthor +from .agent_rpc_params import AgentRpcParams as AgentRpcParams +from .agent_rpc_result import AgentRpcResult as AgentRpcResult +from .span_list_params import SpanListParams as SpanListParams +from .task_fail_params import TaskFailParams as TaskFailParams +from .task_list_params import TaskListParams as TaskListParams +from .agent_list_params import AgentListParams as AgentListParams +from .event_list_params import EventListParams as EventListParams +from .reasoning_content import ReasoningContent as ReasoningContent +from .state_list_params import StateListParams as StateListParams +from .agent_rpc_response import AgentRpcResponse as AgentRpcResponse +from .agent_task_tracker import AgentTaskTracker as AgentTaskTracker +from .data_content_param import DataContentParam as DataContentParam +from .deployment_history import DeploymentHistory as DeploymentHistory +from .span_create_params import SpanCreateParams as SpanCreateParams +from .span_list_response import SpanListResponse as SpanListResponse +from .span_update_params import SpanUpdateParams as SpanUpdateParams +from .task_cancel_params import TaskCancelParams as TaskCancelParams +from .task_list_response import TaskListResponse as TaskListResponse +from .task_message_delta import TaskMessageDelta as TaskMessageDelta +from .text_content_param import TextContentParam as TextContentParam +from .tool_request_delta import ToolRequestDelta as ToolRequestDelta +from .agent_list_response import AgentListResponse as AgentListResponse +from .event_list_response import EventListResponse as EventListResponse +from .message_list_params import MessageListParams as MessageListParams +from .state_create_params import StateCreateParams as StateCreateParams +from .state_list_response import StateListResponse as StateListResponse +from .state_update_params import StateUpdateParams as StateUpdateParams +from .task_message_update import TaskMessageUpdate as TaskMessageUpdate +from .task_timeout_params import TaskTimeoutParams as TaskTimeoutParams +from .tool_response_delta import ToolResponseDelta as ToolResponseDelta +from .tracker_list_params import TrackerListParams as TrackerListParams +from .task_complete_params import TaskCompleteParams as TaskCompleteParams +from .task_message_content import TaskMessageContent as TaskMessageContent +from .task_retrieve_params import TaskRetrieveParams as TaskRetrieveParams +from .tool_request_content import ToolRequestContent as ToolRequestContent +from .checkpoint_put_params import CheckpointPutParams as CheckpointPutParams +from .message_create_params import MessageCreateParams as MessageCreateParams +from .message_list_response import MessageListResponse as MessageListResponse +from .message_update_params import MessageUpdateParams as MessageUpdateParams +from .task_interrupt_params import TaskInterruptParams as TaskInterruptParams +from .task_terminate_params import TaskTerminateParams as TaskTerminateParams +from .tool_response_content import ToolResponseContent as ToolResponseContent +from .tracker_list_response import TrackerListResponse as TrackerListResponse +from .tracker_update_params import TrackerUpdateParams as TrackerUpdateParams +from .checkpoint_list_params import CheckpointListParams as CheckpointListParams +from .task_retrieve_response import TaskRetrieveResponse as TaskRetrieveResponse +from .checkpoint_put_response import CheckpointPutResponse as CheckpointPutResponse +from .reasoning_content_delta import ReasoningContentDelta as ReasoningContentDelta +from .reasoning_content_param import ReasoningContentParam as ReasoningContentParam +from .reasoning_summary_delta import ReasoningSummaryDelta as ReasoningSummaryDelta +from .agent_rpc_by_name_params import AgentRpcByNameParams as AgentRpcByNameParams +from .checkpoint_list_response import CheckpointListResponse as CheckpointListResponse +from .task_update_by_id_params import TaskUpdateByIDParams as TaskUpdateByIDParams +from .task_message_content_param import TaskMessageContentParam as TaskMessageContentParam +from .task_update_by_name_params import TaskUpdateByNameParams as TaskUpdateByNameParams +from .tool_request_content_param import ToolRequestContentParam as ToolRequestContentParam +from .agent_register_build_params import AgentRegisterBuildParams as AgentRegisterBuildParams +from .checkpoint_get_tuple_params import CheckpointGetTupleParams as CheckpointGetTupleParams +from .tool_response_content_param import ToolResponseContentParam as ToolResponseContentParam +from .checkpoint_put_writes_params import CheckpointPutWritesParams as CheckpointPutWritesParams +from .task_query_workflow_response import TaskQueryWorkflowResponse as TaskQueryWorkflowResponse +from .task_retrieve_by_name_params import TaskRetrieveByNameParams as TaskRetrieveByNameParams +from .checkpoint_get_tuple_response import CheckpointGetTupleResponse as CheckpointGetTupleResponse +from .message_list_paginated_params import MessageListPaginatedParams as MessageListPaginatedParams +from .deployment_history_list_params import DeploymentHistoryListParams as DeploymentHistoryListParams +from .task_retrieve_by_name_response import TaskRetrieveByNameResponse as TaskRetrieveByNameResponse +from .checkpoint_delete_thread_params import CheckpointDeleteThreadParams as CheckpointDeleteThreadParams +from .message_list_paginated_response import MessageListPaginatedResponse as MessageListPaginatedResponse +from .deployment_history_list_response import DeploymentHistoryListResponse as DeploymentHistoryListResponse +from .webhook_create_webhook_trigger_params import ( + WebhookCreateWebhookTriggerParams as WebhookCreateWebhookTriggerParams, +) +from .webhook_create_webhook_trigger_response import ( + WebhookCreateWebhookTriggerResponse as WebhookCreateWebhookTriggerResponse, +) diff --git a/src/agentex/types/acp_type.py b/src/agentex/types/acp_type.py new file mode 100644 index 000000000..8b70a2924 --- /dev/null +++ b/src/agentex/types/acp_type.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal, TypeAlias + +__all__ = ["AcpType"] + +AcpType: TypeAlias = Literal["sync", "async", "agentic"] diff --git a/src/agentex/types/agent.py b/src/agentex/types/agent.py new file mode 100644 index 000000000..e9daa58d5 --- /dev/null +++ b/src/agentex/types/agent.py @@ -0,0 +1,48 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from datetime import datetime +from typing_extensions import Literal + +from .._models import BaseModel +from .acp_type import AcpType + +__all__ = ["Agent"] + + +class Agent(BaseModel): + id: str + """The unique identifier of the agent.""" + + acp_type: AcpType + """The type of the ACP Server (Either sync or async)""" + + created_at: datetime + """The timestamp when the agent was created""" + + description: str + """The description of the action.""" + + name: str + """The unique name of the agent.""" + + updated_at: datetime + """The timestamp when the agent was last updated""" + + agent_input_type: Optional[Literal["text", "json"]] = None + """The type of input the agent expects.""" + + production_deployment_id: Optional[str] = None + """ID of the current production deployment.""" + + registered_at: Optional[datetime] = None + """The timestamp when the agent was last registered""" + + registration_metadata: Optional[Dict[str, object]] = None + """The metadata for the agent's registration.""" + + status: Optional[Literal["Ready", "Failed", "Unknown", "Deleted", "Unhealthy", "BuildOnly"]] = None + """The status of the action, indicating if it's building, ready, failed, etc.""" + + status_reason: Optional[str] = None + """The reason for the status of the action.""" diff --git a/src/agentex/types/agent_list_params.py b/src/agentex/types/agent_list_params.py new file mode 100644 index 000000000..f0a327aec --- /dev/null +++ b/src/agentex/types/agent_list_params.py @@ -0,0 +1,35 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import TypedDict + +__all__ = ["AgentListParams"] + + +class AgentListParams(TypedDict, total=False): + agent_card_metadata: Optional[str] + """ + JSON-encoded object used to filter agents on + `registration_metadata.agent_card.metadata` via JSONB containment. Example: + {"permits_capable": true}. Only matches cards published through the direct + registration path: registrations that carry a `deployment_id` write the card to + the deployment record instead of `registration_metadata`, so those agents never + match this filter. + """ + + limit: int + """Limit""" + + order_by: Optional[str] + """Field to order by""" + + order_direction: str + """Order direction (asc or desc)""" + + page_number: int + """Page number""" + + task_id: Optional[str] + """Task ID""" diff --git a/src/agentex/types/agent_list_response.py b/src/agentex/types/agent_list_response.py new file mode 100644 index 000000000..f33d2016c --- /dev/null +++ b/src/agentex/types/agent_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .agent import Agent + +__all__ = ["AgentListResponse"] + +AgentListResponse: TypeAlias = List[Agent] diff --git a/src/agentex/types/agent_register_build_params.py b/src/agentex/types/agent_register_build_params.py new file mode 100644 index 000000000..b93d1d639 --- /dev/null +++ b/src/agentex/types/agent_register_build_params.py @@ -0,0 +1,22 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Optional +from typing_extensions import Literal, Required, TypedDict + +__all__ = ["AgentRegisterBuildParams"] + + +class AgentRegisterBuildParams(TypedDict, total=False): + description: Required[str] + """The description of the agent.""" + + name: Required[str] + """The unique name of the agent.""" + + agent_input_type: Optional[Literal["text", "json"]] + """The type of input the agent expects.""" + + registration_metadata: Optional[Dict[str, object]] + """The metadata for the agent's build registration.""" diff --git a/src/agentex/types/agent_rpc_by_name_params.py b/src/agentex/types/agent_rpc_by_name_params.py new file mode 100644 index 000000000..208dfa0c5 --- /dev/null +++ b/src/agentex/types/agent_rpc_by_name_params.py @@ -0,0 +1,107 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Union, Optional +from typing_extensions import Literal, Required, TypeAlias, TypedDict + +from .task_message_content_param import TaskMessageContentParam + +__all__ = [ + "AgentRpcByNameParams", + "Params", + "ParamsCreateTaskRequest", + "ParamsCancelTaskRequest", + "ParamsInterruptTaskRequest", + "ParamsSendMessageRequest", + "ParamsSendEventRequest", +] + + +class AgentRpcByNameParams(TypedDict, total=False): + method: Required[Literal["event/send", "task/create", "message/send", "task/cancel", "task/interrupt"]] + + params: Required[Params] + """The parameters for the agent RPC request""" + + id: Union[int, str, None] + + jsonrpc: Literal["2.0"] + + +class ParamsCreateTaskRequest(TypedDict, total=False): + name: Optional[str] + """Optional human-readable name for the task. + + When set it must be globally unique. task/create is get-or-create by name: + reusing an existing name returns the existing task (with its prior history) + instead of creating a new one, so omit name (or make it unique, e.g. by + appending a UUID) whenever each call should produce a fresh task. + """ + + params: Optional[Dict[str, object]] + """The parameters for the task. + + On a get-or-create by name, providing params overwrites the existing task's + params (it is not a pure read). + """ + + task_metadata: Optional[Dict[str, object]] + """Caller-provided metadata to persist on the task row. + + Only applied at task creation; ignored if a task with this name already exists. + Forwarded to the agent inside the ACP payload for backward compatibility. + """ + + +class ParamsCancelTaskRequest(TypedDict, total=False): + task_id: Optional[str] + """The ID of the task to cancel. Either this or task_name must be provided.""" + + task_name: Optional[str] + """The name of the task to cancel. Either this or task_id must be provided.""" + + +class ParamsInterruptTaskRequest(TypedDict, total=False): + task_id: Optional[str] + """The ID of the task to interrupt. Either this or task_name must be provided.""" + + task_name: Optional[str] + """The name of the task to interrupt. Either this or task_id must be provided.""" + + +class ParamsSendMessageRequest(TypedDict, total=False): + content: Required[TaskMessageContentParam] + """The message that was sent to the agent""" + + stream: bool + """Whether to stream the response message back to the client""" + + task_id: Optional[str] + """The ID of the task that the message was sent to""" + + task_name: Optional[str] + """The name of the task that the message was sent to""" + + task_params: Optional[Dict[str, object]] + """The parameters for the task (only used when creating new tasks)""" + + +class ParamsSendEventRequest(TypedDict, total=False): + content: Optional[TaskMessageContentParam] + """The content to send to the event""" + + task_id: Optional[str] + """The ID of the task that the event was sent to""" + + task_name: Optional[str] + """The name of the task that the event was sent to""" + + +Params: TypeAlias = Union[ + ParamsCreateTaskRequest, + ParamsCancelTaskRequest, + ParamsInterruptTaskRequest, + ParamsSendMessageRequest, + ParamsSendEventRequest, +] diff --git a/src/agentex/types/agent_rpc_params.py b/src/agentex/types/agent_rpc_params.py new file mode 100644 index 000000000..380001ccf --- /dev/null +++ b/src/agentex/types/agent_rpc_params.py @@ -0,0 +1,107 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Union, Optional +from typing_extensions import Literal, Required, TypeAlias, TypedDict + +from .task_message_content_param import TaskMessageContentParam + +__all__ = [ + "AgentRpcParams", + "Params", + "ParamsCreateTaskRequest", + "ParamsCancelTaskRequest", + "ParamsInterruptTaskRequest", + "ParamsSendMessageRequest", + "ParamsSendEventRequest", +] + + +class AgentRpcParams(TypedDict, total=False): + method: Required[Literal["event/send", "task/create", "message/send", "task/cancel", "task/interrupt"]] + + params: Required[Params] + """The parameters for the agent RPC request""" + + id: Union[int, str, None] + + jsonrpc: Literal["2.0"] + + +class ParamsCreateTaskRequest(TypedDict, total=False): + name: Optional[str] + """Optional human-readable name for the task. + + When set it must be globally unique. task/create is get-or-create by name: + reusing an existing name returns the existing task (with its prior history) + instead of creating a new one, so omit name (or make it unique, e.g. by + appending a UUID) whenever each call should produce a fresh task. + """ + + params: Optional[Dict[str, object]] + """The parameters for the task. + + On a get-or-create by name, providing params overwrites the existing task's + params (it is not a pure read). + """ + + task_metadata: Optional[Dict[str, object]] + """Caller-provided metadata to persist on the task row. + + Only applied at task creation; ignored if a task with this name already exists. + Forwarded to the agent inside the ACP payload for backward compatibility. + """ + + +class ParamsCancelTaskRequest(TypedDict, total=False): + task_id: Optional[str] + """The ID of the task to cancel. Either this or task_name must be provided.""" + + task_name: Optional[str] + """The name of the task to cancel. Either this or task_id must be provided.""" + + +class ParamsInterruptTaskRequest(TypedDict, total=False): + task_id: Optional[str] + """The ID of the task to interrupt. Either this or task_name must be provided.""" + + task_name: Optional[str] + """The name of the task to interrupt. Either this or task_id must be provided.""" + + +class ParamsSendMessageRequest(TypedDict, total=False): + content: Required[TaskMessageContentParam] + """The message that was sent to the agent""" + + stream: bool + """Whether to stream the response message back to the client""" + + task_id: Optional[str] + """The ID of the task that the message was sent to""" + + task_name: Optional[str] + """The name of the task that the message was sent to""" + + task_params: Optional[Dict[str, object]] + """The parameters for the task (only used when creating new tasks)""" + + +class ParamsSendEventRequest(TypedDict, total=False): + content: Optional[TaskMessageContentParam] + """The content to send to the event""" + + task_id: Optional[str] + """The ID of the task that the event was sent to""" + + task_name: Optional[str] + """The name of the task that the event was sent to""" + + +Params: TypeAlias = Union[ + ParamsCreateTaskRequest, + ParamsCancelTaskRequest, + ParamsInterruptTaskRequest, + ParamsSendMessageRequest, + ParamsSendEventRequest, +] diff --git a/src/agentex/types/agent_rpc_response.py b/src/agentex/types/agent_rpc_response.py new file mode 100644 index 000000000..97f0f9c2f --- /dev/null +++ b/src/agentex/types/agent_rpc_response.py @@ -0,0 +1,56 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. +from __future__ import annotations + +from typing import Union, Optional +from typing_extensions import Literal + +from .task import Task +from .event import Event +from .._models import BaseModel +from .task_message import TaskMessage +from .agent_rpc_result import AgentRpcResult +from .task_message_update import TaskMessageUpdate + +__all__ = [ + "AgentRpcResponse", + "CancelTaskResponse", + "CreateTaskResponse", + "SendEventResponse", + "SendMessageResponse", + "SendMessageStreamResponse", +] + + +class BaseAgentRpcResponse(BaseModel): + id: Union[int, str, None] = None + error: Optional[object] = None + jsonrpc: Optional[Literal["2.0"]] = None + + +class AgentRpcResponse(BaseAgentRpcResponse): + result: Optional[AgentRpcResult] = None + """The result of the agent RPC request""" + + +class CreateTaskResponse(BaseAgentRpcResponse): + result: Task + """The result of the task creation""" + + +class CancelTaskResponse(BaseAgentRpcResponse): + result: Task + """The result of the task cancellation""" + + +class SendMessageResponse(BaseAgentRpcResponse): + result: list[TaskMessage] + """The result of the message sending""" + +class SendMessageStreamResponse(BaseAgentRpcResponse): + result: Optional[TaskMessageUpdate] = None + """The result of the message sending""" + + +class SendEventResponse(BaseAgentRpcResponse): + result: Event + """The result of the event sending""" diff --git a/src/agentex/types/agent_rpc_result.py b/src/agentex/types/agent_rpc_result.py new file mode 100644 index 000000000..d8a0ead88 --- /dev/null +++ b/src/agentex/types/agent_rpc_result.py @@ -0,0 +1,98 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Union, Optional +from typing_extensions import Literal, TypeAlias + +from .task import Task +from .event import Event +from .._models import BaseModel +from .task_message import TaskMessage +from .task_message_delta import TaskMessageDelta +from .task_message_content import TaskMessageContent + +__all__ = [ + "AgentRpcResult", + "StreamTaskMessageStart", + "StreamTaskMessageDelta", + "StreamTaskMessageFull", + "StreamTaskMessageDone", +] + + +class StreamTaskMessageStart(BaseModel): + """Event for starting a streaming message""" + + content: TaskMessageContent + + index: Optional[int] = None + + parent_task_message: Optional[TaskMessage] = None + """Represents a message in the agent system. + + This entity is used to store messages in MongoDB, with each message associated + with a specific task. + """ + + type: Optional[Literal["start"]] = None + + +class StreamTaskMessageDelta(BaseModel): + """Event for streaming chunks of content""" + + delta: Optional[TaskMessageDelta] = None + """Delta for text updates""" + + index: Optional[int] = None + + parent_task_message: Optional[TaskMessage] = None + """Represents a message in the agent system. + + This entity is used to store messages in MongoDB, with each message associated + with a specific task. + """ + + type: Optional[Literal["delta"]] = None + + +class StreamTaskMessageFull(BaseModel): + """Event for streaming the full content""" + + content: TaskMessageContent + + index: Optional[int] = None + + parent_task_message: Optional[TaskMessage] = None + """Represents a message in the agent system. + + This entity is used to store messages in MongoDB, with each message associated + with a specific task. + """ + + type: Optional[Literal["full"]] = None + + +class StreamTaskMessageDone(BaseModel): + """Event for indicating the task is done""" + + index: Optional[int] = None + + parent_task_message: Optional[TaskMessage] = None + """Represents a message in the agent system. + + This entity is used to store messages in MongoDB, with each message associated + with a specific task. + """ + + type: Optional[Literal["done"]] = None + + +AgentRpcResult: TypeAlias = Union[ + List[TaskMessage], + StreamTaskMessageStart, + StreamTaskMessageDelta, + StreamTaskMessageFull, + StreamTaskMessageDone, + Task, + Event, + None, +] diff --git a/src/agentex/types/agent_task_tracker.py b/src/agentex/types/agent_task_tracker.py new file mode 100644 index 000000000..4f6ebaa79 --- /dev/null +++ b/src/agentex/types/agent_task_tracker.py @@ -0,0 +1,34 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime + +from .._models import BaseModel + +__all__ = ["AgentTaskTracker"] + + +class AgentTaskTracker(BaseModel): + id: str + """The UUID of the agent task tracker""" + + agent_id: str + """The UUID of the agent""" + + created_at: datetime + """When the agent task tracker was created""" + + task_id: str + """The UUID of the task""" + + last_processed_event_id: Optional[str] = None + """The last processed event ID""" + + status: Optional[str] = None + """Processing status""" + + status_reason: Optional[str] = None + """Optional status reason""" + + updated_at: Optional[datetime] = None + """When the agent task tracker was last updated""" diff --git a/src/agentex/types/agents/__init__.py b/src/agentex/types/agents/__init__.py new file mode 100644 index 000000000..2b5cc98f4 --- /dev/null +++ b/src/agentex/types/agents/__init__.py @@ -0,0 +1,35 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .schedule_list_params import ScheduleListParams as ScheduleListParams +from .schedule_skip_params import ScheduleSkipParams as ScheduleSkipParams +from .schedule_pause_params import SchedulePauseParams as SchedulePauseParams +from .deployment_list_params import DeploymentListParams as DeploymentListParams +from .schedule_create_params import ScheduleCreateParams as ScheduleCreateParams +from .schedule_list_response import ScheduleListResponse as ScheduleListResponse +from .schedule_resume_params import ScheduleResumeParams as ScheduleResumeParams +from .schedule_skip_response import ScheduleSkipResponse as ScheduleSkipResponse +from .schedule_unskip_params import ScheduleUnskipParams as ScheduleUnskipParams +from .schedule_update_params import ScheduleUpdateParams as ScheduleUpdateParams +from .schedule_pause_response import SchedulePauseResponse as SchedulePauseResponse +from .deployment_create_params import DeploymentCreateParams as DeploymentCreateParams +from .deployment_list_response import DeploymentListResponse as DeploymentListResponse +from .schedule_create_response import ScheduleCreateResponse as ScheduleCreateResponse +from .schedule_resume_response import ScheduleResumeResponse as ScheduleResumeResponse +from .schedule_unskip_response import ScheduleUnskipResponse as ScheduleUnskipResponse +from .schedule_update_response import ScheduleUpdateResponse as ScheduleUpdateResponse +from .schedule_trigger_response import ScheduleTriggerResponse as ScheduleTriggerResponse +from .deployment_create_response import DeploymentCreateResponse as DeploymentCreateResponse +from .schedule_retrieve_response import ScheduleRetrieveResponse as ScheduleRetrieveResponse +from .deployment_promote_response import DeploymentPromoteResponse as DeploymentPromoteResponse +from .deployment_retrieve_response import DeploymentRetrieveResponse as DeploymentRetrieveResponse +from .deployment_preview_rpc_params import DeploymentPreviewRpcParams as DeploymentPreviewRpcParams +from .schedule_pause_by_name_params import SchedulePauseByNameParams as SchedulePauseByNameParams +from .schedule_resume_by_name_params import ScheduleResumeByNameParams as ScheduleResumeByNameParams +from .schedule_update_by_name_params import ScheduleUpdateByNameParams as ScheduleUpdateByNameParams +from .schedule_pause_by_name_response import SchedulePauseByNameResponse as SchedulePauseByNameResponse +from .schedule_resume_by_name_response import ScheduleResumeByNameResponse as ScheduleResumeByNameResponse +from .schedule_update_by_name_response import ScheduleUpdateByNameResponse as ScheduleUpdateByNameResponse +from .schedule_trigger_by_name_response import ScheduleTriggerByNameResponse as ScheduleTriggerByNameResponse +from .schedule_retrieve_by_name_response import ScheduleRetrieveByNameResponse as ScheduleRetrieveByNameResponse diff --git a/src/agentex/types/agents/deployment_create_params.py b/src/agentex/types/agents/deployment_create_params.py new file mode 100644 index 000000000..b9f57c03c --- /dev/null +++ b/src/agentex/types/agents/deployment_create_params.py @@ -0,0 +1,25 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Optional +from typing_extensions import Required, TypedDict + +__all__ = ["DeploymentCreateParams"] + + +class DeploymentCreateParams(TypedDict, total=False): + docker_image: Required[str] + """Full Docker image URI.""" + + helm_release_name: Optional[str] + """Helm release name.""" + + registration_metadata: Optional[Dict[str, object]] + """ + Git/build metadata (commit_hash, branch_name, author_name, author_email, + build_timestamp). + """ + + sgp_deploy_id: Optional[str] + """SGP deployment ID.""" diff --git a/src/agentex/types/agents/deployment_create_response.py b/src/agentex/types/agents/deployment_create_response.py new file mode 100644 index 000000000..a7d4007b9 --- /dev/null +++ b/src/agentex/types/agents/deployment_create_response.py @@ -0,0 +1,47 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel + +__all__ = ["DeploymentCreateResponse"] + + +class DeploymentCreateResponse(BaseModel): + id: str + """The unique identifier of the deployment.""" + + agent_id: str + """The agent this deployment belongs to.""" + + docker_image: str + """Full Docker image URI.""" + + is_production: bool + """Whether this is the production deployment.""" + + status: Literal["Pending", "Ready", "Failed"] + """Current deployment status.""" + + acp_url: Optional[str] = None + """ACP URL set when agent registers.""" + + created_at: Optional[datetime] = None + """When the deployment was created.""" + + expires_at: Optional[datetime] = None + """When marked for cleanup.""" + + helm_release_name: Optional[str] = None + """Helm release name for cleanup.""" + + promoted_at: Optional[datetime] = None + """When promoted to production.""" + + registration_metadata: Optional[Dict[str, object]] = None + """Git/build metadata from the agent pod.""" + + sgp_deploy_id: Optional[str] = None + """Correlates to SGP's agentex_deploys.id.""" diff --git a/src/agentex/types/agents/deployment_list_params.py b/src/agentex/types/agents/deployment_list_params.py new file mode 100644 index 000000000..394905797 --- /dev/null +++ b/src/agentex/types/agents/deployment_list_params.py @@ -0,0 +1,22 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import TypedDict + +__all__ = ["DeploymentListParams"] + + +class DeploymentListParams(TypedDict, total=False): + limit: int + """Limit""" + + order_by: Optional[str] + """Field to order by""" + + order_direction: str + """Order direction (asc or desc)""" + + page_number: int + """Page number""" diff --git a/src/agentex/types/agents/deployment_list_response.py b/src/agentex/types/agents/deployment_list_response.py new file mode 100644 index 000000000..3e00fab2c --- /dev/null +++ b/src/agentex/types/agents/deployment_list_response.py @@ -0,0 +1,50 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional +from datetime import datetime +from typing_extensions import Literal, TypeAlias + +from ..._models import BaseModel + +__all__ = ["DeploymentListResponse", "DeploymentListResponseItem"] + + +class DeploymentListResponseItem(BaseModel): + id: str + """The unique identifier of the deployment.""" + + agent_id: str + """The agent this deployment belongs to.""" + + docker_image: str + """Full Docker image URI.""" + + is_production: bool + """Whether this is the production deployment.""" + + status: Literal["Pending", "Ready", "Failed"] + """Current deployment status.""" + + acp_url: Optional[str] = None + """ACP URL set when agent registers.""" + + created_at: Optional[datetime] = None + """When the deployment was created.""" + + expires_at: Optional[datetime] = None + """When marked for cleanup.""" + + helm_release_name: Optional[str] = None + """Helm release name for cleanup.""" + + promoted_at: Optional[datetime] = None + """When promoted to production.""" + + registration_metadata: Optional[Dict[str, object]] = None + """Git/build metadata from the agent pod.""" + + sgp_deploy_id: Optional[str] = None + """Correlates to SGP's agentex_deploys.id.""" + + +DeploymentListResponse: TypeAlias = List[DeploymentListResponseItem] diff --git a/src/agentex/types/agents/deployment_preview_rpc_params.py b/src/agentex/types/agents/deployment_preview_rpc_params.py new file mode 100644 index 000000000..3d9ade95a --- /dev/null +++ b/src/agentex/types/agents/deployment_preview_rpc_params.py @@ -0,0 +1,109 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Union, Optional +from typing_extensions import Literal, Required, TypeAlias, TypedDict + +from ..task_message_content_param import TaskMessageContentParam + +__all__ = [ + "DeploymentPreviewRpcParams", + "Params", + "ParamsCreateTaskRequest", + "ParamsCancelTaskRequest", + "ParamsInterruptTaskRequest", + "ParamsSendMessageRequest", + "ParamsSendEventRequest", +] + + +class DeploymentPreviewRpcParams(TypedDict, total=False): + agent_id: Required[str] + + method: Required[Literal["event/send", "task/create", "message/send", "task/cancel", "task/interrupt"]] + + params: Required[Params] + """The parameters for the agent RPC request""" + + id: Union[int, str, None] + + jsonrpc: Literal["2.0"] + + +class ParamsCreateTaskRequest(TypedDict, total=False): + name: Optional[str] + """Optional human-readable name for the task. + + When set it must be globally unique. task/create is get-or-create by name: + reusing an existing name returns the existing task (with its prior history) + instead of creating a new one, so omit name (or make it unique, e.g. by + appending a UUID) whenever each call should produce a fresh task. + """ + + params: Optional[Dict[str, object]] + """The parameters for the task. + + On a get-or-create by name, providing params overwrites the existing task's + params (it is not a pure read). + """ + + task_metadata: Optional[Dict[str, object]] + """Caller-provided metadata to persist on the task row. + + Only applied at task creation; ignored if a task with this name already exists. + Forwarded to the agent inside the ACP payload for backward compatibility. + """ + + +class ParamsCancelTaskRequest(TypedDict, total=False): + task_id: Optional[str] + """The ID of the task to cancel. Either this or task_name must be provided.""" + + task_name: Optional[str] + """The name of the task to cancel. Either this or task_id must be provided.""" + + +class ParamsInterruptTaskRequest(TypedDict, total=False): + task_id: Optional[str] + """The ID of the task to interrupt. Either this or task_name must be provided.""" + + task_name: Optional[str] + """The name of the task to interrupt. Either this or task_id must be provided.""" + + +class ParamsSendMessageRequest(TypedDict, total=False): + content: Required[TaskMessageContentParam] + """The message that was sent to the agent""" + + stream: bool + """Whether to stream the response message back to the client""" + + task_id: Optional[str] + """The ID of the task that the message was sent to""" + + task_name: Optional[str] + """The name of the task that the message was sent to""" + + task_params: Optional[Dict[str, object]] + """The parameters for the task (only used when creating new tasks)""" + + +class ParamsSendEventRequest(TypedDict, total=False): + content: Optional[TaskMessageContentParam] + """The content to send to the event""" + + task_id: Optional[str] + """The ID of the task that the event was sent to""" + + task_name: Optional[str] + """The name of the task that the event was sent to""" + + +Params: TypeAlias = Union[ + ParamsCreateTaskRequest, + ParamsCancelTaskRequest, + ParamsInterruptTaskRequest, + ParamsSendMessageRequest, + ParamsSendEventRequest, +] diff --git a/src/agentex/types/agents/deployment_promote_response.py b/src/agentex/types/agents/deployment_promote_response.py new file mode 100644 index 000000000..94f9fb62d --- /dev/null +++ b/src/agentex/types/agents/deployment_promote_response.py @@ -0,0 +1,47 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel + +__all__ = ["DeploymentPromoteResponse"] + + +class DeploymentPromoteResponse(BaseModel): + id: str + """The unique identifier of the deployment.""" + + agent_id: str + """The agent this deployment belongs to.""" + + docker_image: str + """Full Docker image URI.""" + + is_production: bool + """Whether this is the production deployment.""" + + status: Literal["Pending", "Ready", "Failed"] + """Current deployment status.""" + + acp_url: Optional[str] = None + """ACP URL set when agent registers.""" + + created_at: Optional[datetime] = None + """When the deployment was created.""" + + expires_at: Optional[datetime] = None + """When marked for cleanup.""" + + helm_release_name: Optional[str] = None + """Helm release name for cleanup.""" + + promoted_at: Optional[datetime] = None + """When promoted to production.""" + + registration_metadata: Optional[Dict[str, object]] = None + """Git/build metadata from the agent pod.""" + + sgp_deploy_id: Optional[str] = None + """Correlates to SGP's agentex_deploys.id.""" diff --git a/src/agentex/types/agents/deployment_retrieve_response.py b/src/agentex/types/agents/deployment_retrieve_response.py new file mode 100644 index 000000000..cd90f3a2a --- /dev/null +++ b/src/agentex/types/agents/deployment_retrieve_response.py @@ -0,0 +1,47 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel + +__all__ = ["DeploymentRetrieveResponse"] + + +class DeploymentRetrieveResponse(BaseModel): + id: str + """The unique identifier of the deployment.""" + + agent_id: str + """The agent this deployment belongs to.""" + + docker_image: str + """Full Docker image URI.""" + + is_production: bool + """Whether this is the production deployment.""" + + status: Literal["Pending", "Ready", "Failed"] + """Current deployment status.""" + + acp_url: Optional[str] = None + """ACP URL set when agent registers.""" + + created_at: Optional[datetime] = None + """When the deployment was created.""" + + expires_at: Optional[datetime] = None + """When marked for cleanup.""" + + helm_release_name: Optional[str] = None + """Helm release name for cleanup.""" + + promoted_at: Optional[datetime] = None + """When promoted to production.""" + + registration_metadata: Optional[Dict[str, object]] = None + """Git/build metadata from the agent pod.""" + + sgp_deploy_id: Optional[str] = None + """Correlates to SGP's agentex_deploys.id.""" diff --git a/src/agentex/types/agents/schedule_create_params.py b/src/agentex/types/agents/schedule_create_params.py new file mode 100644 index 000000000..2210ad873 --- /dev/null +++ b/src/agentex/types/agents/schedule_create_params.py @@ -0,0 +1,63 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Union, Optional +from datetime import datetime +from typing_extensions import Literal, Required, Annotated, TypedDict + +from ..._utils import PropertyInfo +from ..message_author import MessageAuthor + +__all__ = ["ScheduleCreateParams", "InitialInput"] + + +class ScheduleCreateParams(TypedDict, total=False): + initial_input: Required[InitialInput] + """The first input delivered to each created task.""" + + name: Required[str] + """Human-readable name, unique among active schedules for the agent.""" + + cron_expression: Optional[str] + """Cron expression for the cadence (e.g. + + '0 17 \\** \\** MON-FRI'). Mutually exclusive with interval_seconds. + """ + + description: Optional[str] + """Optional description of what this schedule does.""" + + end_at: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] + """When the schedule should stop being active.""" + + interval_seconds: Optional[int] + """Interval cadence in seconds. Mutually exclusive with cron_expression.""" + + paused: bool + """Whether to create the schedule in a paused state.""" + + start_at: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] + """When the schedule should start being active.""" + + task_metadata: Optional[Dict[str, object]] + """Metadata copied onto each created task at fire time.""" + + task_params: Optional[Dict[str, object]] + """Resolved config forwarded as task `params` at fire time.""" + + timezone: str + """IANA timezone the cron expression is evaluated in (e.g. 'America/New_York').""" + + +class InitialInput(TypedDict, total=False): + """The first input delivered to each created task.""" + + content: Required[str] + """The initial prompt delivered to the task.""" + + author: MessageAuthor + """The author attributed to the initial input.""" + + type: Literal["text"] + """Input content type.""" diff --git a/src/agentex/types/agents/schedule_create_response.py b/src/agentex/types/agents/schedule_create_response.py new file mode 100644 index 000000000..9902bb832 --- /dev/null +++ b/src/agentex/types/agents/schedule_create_response.py @@ -0,0 +1,123 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel +from ..message_author import MessageAuthor + +__all__ = ["ScheduleCreateResponse", "InitialInput", "CreatorPrincipal"] + + +class InitialInput(BaseModel): + """The initial input.""" + + content: str + """The initial prompt delivered to the task.""" + + author: Optional[MessageAuthor] = None + """The author attributed to the initial input.""" + + type: Optional[Literal["text"]] = None + """Input content type.""" + + +class CreatorPrincipal(BaseModel): + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it + is creator *context* used only for AuthZ and ownership at fire time. + """ + + account_id: Optional[str] = None + """Account/workspace id of the creator.""" + + principal_type: Optional[str] = None + """e.g. 'user' or 'service_account'.""" + + service_account_id: Optional[str] = None + """Creator service-account id, if a service principal.""" + + user_id: Optional[str] = None + """Creator user id, if a user principal.""" + + +class ScheduleCreateResponse(BaseModel): + """Response model describing a scheduled agent run.""" + + id: str + """The unique identifier of the run schedule.""" + + agent_id: str + """The agent this schedule belongs to.""" + + initial_input: InitialInput + """The initial input.""" + + initial_input_method: str + """Delivery method, inferred from the agent's ACP type.""" + + name: str + """Human-readable schedule name.""" + + created_at: Optional[datetime] = None + """When the schedule was created.""" + + creator_principal: Optional[CreatorPrincipal] = None + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it is + creator _context_ used only for AuthZ and ownership at fire time. + """ + + cron_expression: Optional[str] = None + """Cron cadence, if cron-based.""" + + description: Optional[str] = None + """Optional description.""" + + end_at: Optional[datetime] = None + """Schedule deactivation time.""" + + interval_seconds: Optional[int] = None + """Interval cadence in seconds, if interval-based.""" + + last_action_time: Optional[datetime] = None + """When the schedule last fired.""" + + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + + next_action_times: Optional[List[datetime]] = None + """Upcoming scheduled fire times.""" + + num_actions_taken: Optional[int] = None + """Number of times the schedule has fired.""" + + paused: Optional[bool] = None + """Whether the schedule is paused.""" + + skipped_action_times: Optional[List[datetime]] = None + """Skipped one-off scheduled fire times.""" + + start_at: Optional[datetime] = None + """Schedule activation time.""" + + state: Optional[Literal["ACTIVE", "PAUSED"]] = None + """Live schedule state from Temporal.""" + + task_metadata: Optional[Dict[str, object]] = None + """Task metadata at fire time.""" + + task_params: Optional[Dict[str, object]] = None + """Task params at fire time.""" + + timezone: Optional[str] = None + """Timezone the cron expression is evaluated in.""" + + updated_at: Optional[datetime] = None + """When the schedule was updated.""" diff --git a/src/agentex/types/agents/schedule_list_params.py b/src/agentex/types/agents/schedule_list_params.py new file mode 100644 index 000000000..6e767e207 --- /dev/null +++ b/src/agentex/types/agents/schedule_list_params.py @@ -0,0 +1,14 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["ScheduleListParams"] + + +class ScheduleListParams(TypedDict, total=False): + include_live: bool + """Include live Temporal state and upcoming action times.""" + + limit: int diff --git a/src/agentex/types/agents/schedule_list_response.py b/src/agentex/types/agents/schedule_list_response.py new file mode 100644 index 000000000..faa19527f --- /dev/null +++ b/src/agentex/types/agents/schedule_list_response.py @@ -0,0 +1,133 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel +from ..message_author import MessageAuthor + +__all__ = ["ScheduleListResponse", "RunSchedule", "RunScheduleInitialInput", "RunScheduleCreatorPrincipal"] + + +class RunScheduleInitialInput(BaseModel): + """The initial input.""" + + content: str + """The initial prompt delivered to the task.""" + + author: Optional[MessageAuthor] = None + """The author attributed to the initial input.""" + + type: Optional[Literal["text"]] = None + """Input content type.""" + + +class RunScheduleCreatorPrincipal(BaseModel): + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it + is creator *context* used only for AuthZ and ownership at fire time. + """ + + account_id: Optional[str] = None + """Account/workspace id of the creator.""" + + principal_type: Optional[str] = None + """e.g. 'user' or 'service_account'.""" + + service_account_id: Optional[str] = None + """Creator service-account id, if a service principal.""" + + user_id: Optional[str] = None + """Creator user id, if a user principal.""" + + +class RunSchedule(BaseModel): + """Response model describing a scheduled agent run.""" + + id: str + """The unique identifier of the run schedule.""" + + agent_id: str + """The agent this schedule belongs to.""" + + initial_input: RunScheduleInitialInput + """The initial input.""" + + initial_input_method: str + """Delivery method, inferred from the agent's ACP type.""" + + name: str + """Human-readable schedule name.""" + + created_at: Optional[datetime] = None + """When the schedule was created.""" + + creator_principal: Optional[RunScheduleCreatorPrincipal] = None + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it is + creator _context_ used only for AuthZ and ownership at fire time. + """ + + cron_expression: Optional[str] = None + """Cron cadence, if cron-based.""" + + description: Optional[str] = None + """Optional description.""" + + end_at: Optional[datetime] = None + """Schedule deactivation time.""" + + interval_seconds: Optional[int] = None + """Interval cadence in seconds, if interval-based.""" + + last_action_time: Optional[datetime] = None + """When the schedule last fired.""" + + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + + next_action_times: Optional[List[datetime]] = None + """Upcoming scheduled fire times.""" + + num_actions_taken: Optional[int] = None + """Number of times the schedule has fired.""" + + paused: Optional[bool] = None + """Whether the schedule is paused.""" + + skipped_action_times: Optional[List[datetime]] = None + """Skipped one-off scheduled fire times.""" + + start_at: Optional[datetime] = None + """Schedule activation time.""" + + state: Optional[Literal["ACTIVE", "PAUSED"]] = None + """Live schedule state from Temporal.""" + + task_metadata: Optional[Dict[str, object]] = None + """Task metadata at fire time.""" + + task_params: Optional[Dict[str, object]] = None + """Task params at fire time.""" + + timezone: Optional[str] = None + """Timezone the cron expression is evaluated in.""" + + updated_at: Optional[datetime] = None + """When the schedule was updated.""" + + +class ScheduleListResponse(BaseModel): + """Response model for listing run schedules.""" + + run_schedules: List[RunSchedule] + """The list of run schedules.""" + + total: int + """The number of run schedules returned.""" diff --git a/src/agentex/types/agents/schedule_pause_by_name_params.py b/src/agentex/types/agents/schedule_pause_by_name_params.py new file mode 100644 index 000000000..4afc26a82 --- /dev/null +++ b/src/agentex/types/agents/schedule_pause_by_name_params.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import Required, TypedDict + +__all__ = ["SchedulePauseByNameParams"] + + +class SchedulePauseByNameParams(TypedDict, total=False): + agent_id: Required[str] + + note: Optional[str] + """Optional note explaining the pause.""" diff --git a/src/agentex/types/agents/schedule_pause_by_name_response.py b/src/agentex/types/agents/schedule_pause_by_name_response.py new file mode 100644 index 000000000..61232fdde --- /dev/null +++ b/src/agentex/types/agents/schedule_pause_by_name_response.py @@ -0,0 +1,123 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel +from ..message_author import MessageAuthor + +__all__ = ["SchedulePauseByNameResponse", "InitialInput", "CreatorPrincipal"] + + +class InitialInput(BaseModel): + """The initial input.""" + + content: str + """The initial prompt delivered to the task.""" + + author: Optional[MessageAuthor] = None + """The author attributed to the initial input.""" + + type: Optional[Literal["text"]] = None + """Input content type.""" + + +class CreatorPrincipal(BaseModel): + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it + is creator *context* used only for AuthZ and ownership at fire time. + """ + + account_id: Optional[str] = None + """Account/workspace id of the creator.""" + + principal_type: Optional[str] = None + """e.g. 'user' or 'service_account'.""" + + service_account_id: Optional[str] = None + """Creator service-account id, if a service principal.""" + + user_id: Optional[str] = None + """Creator user id, if a user principal.""" + + +class SchedulePauseByNameResponse(BaseModel): + """Response model describing a scheduled agent run.""" + + id: str + """The unique identifier of the run schedule.""" + + agent_id: str + """The agent this schedule belongs to.""" + + initial_input: InitialInput + """The initial input.""" + + initial_input_method: str + """Delivery method, inferred from the agent's ACP type.""" + + name: str + """Human-readable schedule name.""" + + created_at: Optional[datetime] = None + """When the schedule was created.""" + + creator_principal: Optional[CreatorPrincipal] = None + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it is + creator _context_ used only for AuthZ and ownership at fire time. + """ + + cron_expression: Optional[str] = None + """Cron cadence, if cron-based.""" + + description: Optional[str] = None + """Optional description.""" + + end_at: Optional[datetime] = None + """Schedule deactivation time.""" + + interval_seconds: Optional[int] = None + """Interval cadence in seconds, if interval-based.""" + + last_action_time: Optional[datetime] = None + """When the schedule last fired.""" + + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + + next_action_times: Optional[List[datetime]] = None + """Upcoming scheduled fire times.""" + + num_actions_taken: Optional[int] = None + """Number of times the schedule has fired.""" + + paused: Optional[bool] = None + """Whether the schedule is paused.""" + + skipped_action_times: Optional[List[datetime]] = None + """Skipped one-off scheduled fire times.""" + + start_at: Optional[datetime] = None + """Schedule activation time.""" + + state: Optional[Literal["ACTIVE", "PAUSED"]] = None + """Live schedule state from Temporal.""" + + task_metadata: Optional[Dict[str, object]] = None + """Task metadata at fire time.""" + + task_params: Optional[Dict[str, object]] = None + """Task params at fire time.""" + + timezone: Optional[str] = None + """Timezone the cron expression is evaluated in.""" + + updated_at: Optional[datetime] = None + """When the schedule was updated.""" diff --git a/src/agentex/types/agents/schedule_pause_params.py b/src/agentex/types/agents/schedule_pause_params.py new file mode 100644 index 000000000..73b9f5140 --- /dev/null +++ b/src/agentex/types/agents/schedule_pause_params.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import Required, TypedDict + +__all__ = ["SchedulePauseParams"] + + +class SchedulePauseParams(TypedDict, total=False): + agent_id: Required[str] + + note: Optional[str] + """Optional note explaining the pause.""" diff --git a/src/agentex/types/agents/schedule_pause_response.py b/src/agentex/types/agents/schedule_pause_response.py new file mode 100644 index 000000000..16e4a85ce --- /dev/null +++ b/src/agentex/types/agents/schedule_pause_response.py @@ -0,0 +1,123 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel +from ..message_author import MessageAuthor + +__all__ = ["SchedulePauseResponse", "InitialInput", "CreatorPrincipal"] + + +class InitialInput(BaseModel): + """The initial input.""" + + content: str + """The initial prompt delivered to the task.""" + + author: Optional[MessageAuthor] = None + """The author attributed to the initial input.""" + + type: Optional[Literal["text"]] = None + """Input content type.""" + + +class CreatorPrincipal(BaseModel): + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it + is creator *context* used only for AuthZ and ownership at fire time. + """ + + account_id: Optional[str] = None + """Account/workspace id of the creator.""" + + principal_type: Optional[str] = None + """e.g. 'user' or 'service_account'.""" + + service_account_id: Optional[str] = None + """Creator service-account id, if a service principal.""" + + user_id: Optional[str] = None + """Creator user id, if a user principal.""" + + +class SchedulePauseResponse(BaseModel): + """Response model describing a scheduled agent run.""" + + id: str + """The unique identifier of the run schedule.""" + + agent_id: str + """The agent this schedule belongs to.""" + + initial_input: InitialInput + """The initial input.""" + + initial_input_method: str + """Delivery method, inferred from the agent's ACP type.""" + + name: str + """Human-readable schedule name.""" + + created_at: Optional[datetime] = None + """When the schedule was created.""" + + creator_principal: Optional[CreatorPrincipal] = None + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it is + creator _context_ used only for AuthZ and ownership at fire time. + """ + + cron_expression: Optional[str] = None + """Cron cadence, if cron-based.""" + + description: Optional[str] = None + """Optional description.""" + + end_at: Optional[datetime] = None + """Schedule deactivation time.""" + + interval_seconds: Optional[int] = None + """Interval cadence in seconds, if interval-based.""" + + last_action_time: Optional[datetime] = None + """When the schedule last fired.""" + + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + + next_action_times: Optional[List[datetime]] = None + """Upcoming scheduled fire times.""" + + num_actions_taken: Optional[int] = None + """Number of times the schedule has fired.""" + + paused: Optional[bool] = None + """Whether the schedule is paused.""" + + skipped_action_times: Optional[List[datetime]] = None + """Skipped one-off scheduled fire times.""" + + start_at: Optional[datetime] = None + """Schedule activation time.""" + + state: Optional[Literal["ACTIVE", "PAUSED"]] = None + """Live schedule state from Temporal.""" + + task_metadata: Optional[Dict[str, object]] = None + """Task metadata at fire time.""" + + task_params: Optional[Dict[str, object]] = None + """Task params at fire time.""" + + timezone: Optional[str] = None + """Timezone the cron expression is evaluated in.""" + + updated_at: Optional[datetime] = None + """When the schedule was updated.""" diff --git a/src/agentex/types/agents/schedule_resume_by_name_params.py b/src/agentex/types/agents/schedule_resume_by_name_params.py new file mode 100644 index 000000000..b8e5c514b --- /dev/null +++ b/src/agentex/types/agents/schedule_resume_by_name_params.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import Required, TypedDict + +__all__ = ["ScheduleResumeByNameParams"] + + +class ScheduleResumeByNameParams(TypedDict, total=False): + agent_id: Required[str] + + note: Optional[str] + """Optional note explaining the resume.""" diff --git a/src/agentex/types/agents/schedule_resume_by_name_response.py b/src/agentex/types/agents/schedule_resume_by_name_response.py new file mode 100644 index 000000000..5999059c0 --- /dev/null +++ b/src/agentex/types/agents/schedule_resume_by_name_response.py @@ -0,0 +1,123 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel +from ..message_author import MessageAuthor + +__all__ = ["ScheduleResumeByNameResponse", "InitialInput", "CreatorPrincipal"] + + +class InitialInput(BaseModel): + """The initial input.""" + + content: str + """The initial prompt delivered to the task.""" + + author: Optional[MessageAuthor] = None + """The author attributed to the initial input.""" + + type: Optional[Literal["text"]] = None + """Input content type.""" + + +class CreatorPrincipal(BaseModel): + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it + is creator *context* used only for AuthZ and ownership at fire time. + """ + + account_id: Optional[str] = None + """Account/workspace id of the creator.""" + + principal_type: Optional[str] = None + """e.g. 'user' or 'service_account'.""" + + service_account_id: Optional[str] = None + """Creator service-account id, if a service principal.""" + + user_id: Optional[str] = None + """Creator user id, if a user principal.""" + + +class ScheduleResumeByNameResponse(BaseModel): + """Response model describing a scheduled agent run.""" + + id: str + """The unique identifier of the run schedule.""" + + agent_id: str + """The agent this schedule belongs to.""" + + initial_input: InitialInput + """The initial input.""" + + initial_input_method: str + """Delivery method, inferred from the agent's ACP type.""" + + name: str + """Human-readable schedule name.""" + + created_at: Optional[datetime] = None + """When the schedule was created.""" + + creator_principal: Optional[CreatorPrincipal] = None + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it is + creator _context_ used only for AuthZ and ownership at fire time. + """ + + cron_expression: Optional[str] = None + """Cron cadence, if cron-based.""" + + description: Optional[str] = None + """Optional description.""" + + end_at: Optional[datetime] = None + """Schedule deactivation time.""" + + interval_seconds: Optional[int] = None + """Interval cadence in seconds, if interval-based.""" + + last_action_time: Optional[datetime] = None + """When the schedule last fired.""" + + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + + next_action_times: Optional[List[datetime]] = None + """Upcoming scheduled fire times.""" + + num_actions_taken: Optional[int] = None + """Number of times the schedule has fired.""" + + paused: Optional[bool] = None + """Whether the schedule is paused.""" + + skipped_action_times: Optional[List[datetime]] = None + """Skipped one-off scheduled fire times.""" + + start_at: Optional[datetime] = None + """Schedule activation time.""" + + state: Optional[Literal["ACTIVE", "PAUSED"]] = None + """Live schedule state from Temporal.""" + + task_metadata: Optional[Dict[str, object]] = None + """Task metadata at fire time.""" + + task_params: Optional[Dict[str, object]] = None + """Task params at fire time.""" + + timezone: Optional[str] = None + """Timezone the cron expression is evaluated in.""" + + updated_at: Optional[datetime] = None + """When the schedule was updated.""" diff --git a/src/agentex/types/agents/schedule_resume_params.py b/src/agentex/types/agents/schedule_resume_params.py new file mode 100644 index 000000000..7ebe2451d --- /dev/null +++ b/src/agentex/types/agents/schedule_resume_params.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import Required, TypedDict + +__all__ = ["ScheduleResumeParams"] + + +class ScheduleResumeParams(TypedDict, total=False): + agent_id: Required[str] + + note: Optional[str] + """Optional note explaining the resume.""" diff --git a/src/agentex/types/agents/schedule_resume_response.py b/src/agentex/types/agents/schedule_resume_response.py new file mode 100644 index 000000000..70e6e2aa0 --- /dev/null +++ b/src/agentex/types/agents/schedule_resume_response.py @@ -0,0 +1,123 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel +from ..message_author import MessageAuthor + +__all__ = ["ScheduleResumeResponse", "InitialInput", "CreatorPrincipal"] + + +class InitialInput(BaseModel): + """The initial input.""" + + content: str + """The initial prompt delivered to the task.""" + + author: Optional[MessageAuthor] = None + """The author attributed to the initial input.""" + + type: Optional[Literal["text"]] = None + """Input content type.""" + + +class CreatorPrincipal(BaseModel): + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it + is creator *context* used only for AuthZ and ownership at fire time. + """ + + account_id: Optional[str] = None + """Account/workspace id of the creator.""" + + principal_type: Optional[str] = None + """e.g. 'user' or 'service_account'.""" + + service_account_id: Optional[str] = None + """Creator service-account id, if a service principal.""" + + user_id: Optional[str] = None + """Creator user id, if a user principal.""" + + +class ScheduleResumeResponse(BaseModel): + """Response model describing a scheduled agent run.""" + + id: str + """The unique identifier of the run schedule.""" + + agent_id: str + """The agent this schedule belongs to.""" + + initial_input: InitialInput + """The initial input.""" + + initial_input_method: str + """Delivery method, inferred from the agent's ACP type.""" + + name: str + """Human-readable schedule name.""" + + created_at: Optional[datetime] = None + """When the schedule was created.""" + + creator_principal: Optional[CreatorPrincipal] = None + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it is + creator _context_ used only for AuthZ and ownership at fire time. + """ + + cron_expression: Optional[str] = None + """Cron cadence, if cron-based.""" + + description: Optional[str] = None + """Optional description.""" + + end_at: Optional[datetime] = None + """Schedule deactivation time.""" + + interval_seconds: Optional[int] = None + """Interval cadence in seconds, if interval-based.""" + + last_action_time: Optional[datetime] = None + """When the schedule last fired.""" + + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + + next_action_times: Optional[List[datetime]] = None + """Upcoming scheduled fire times.""" + + num_actions_taken: Optional[int] = None + """Number of times the schedule has fired.""" + + paused: Optional[bool] = None + """Whether the schedule is paused.""" + + skipped_action_times: Optional[List[datetime]] = None + """Skipped one-off scheduled fire times.""" + + start_at: Optional[datetime] = None + """Schedule activation time.""" + + state: Optional[Literal["ACTIVE", "PAUSED"]] = None + """Live schedule state from Temporal.""" + + task_metadata: Optional[Dict[str, object]] = None + """Task metadata at fire time.""" + + task_params: Optional[Dict[str, object]] = None + """Task params at fire time.""" + + timezone: Optional[str] = None + """Timezone the cron expression is evaluated in.""" + + updated_at: Optional[datetime] = None + """When the schedule was updated.""" diff --git a/src/agentex/types/agents/schedule_retrieve_by_name_response.py b/src/agentex/types/agents/schedule_retrieve_by_name_response.py new file mode 100644 index 000000000..7b21ebb0a --- /dev/null +++ b/src/agentex/types/agents/schedule_retrieve_by_name_response.py @@ -0,0 +1,123 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel +from ..message_author import MessageAuthor + +__all__ = ["ScheduleRetrieveByNameResponse", "InitialInput", "CreatorPrincipal"] + + +class InitialInput(BaseModel): + """The initial input.""" + + content: str + """The initial prompt delivered to the task.""" + + author: Optional[MessageAuthor] = None + """The author attributed to the initial input.""" + + type: Optional[Literal["text"]] = None + """Input content type.""" + + +class CreatorPrincipal(BaseModel): + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it + is creator *context* used only for AuthZ and ownership at fire time. + """ + + account_id: Optional[str] = None + """Account/workspace id of the creator.""" + + principal_type: Optional[str] = None + """e.g. 'user' or 'service_account'.""" + + service_account_id: Optional[str] = None + """Creator service-account id, if a service principal.""" + + user_id: Optional[str] = None + """Creator user id, if a user principal.""" + + +class ScheduleRetrieveByNameResponse(BaseModel): + """Response model describing a scheduled agent run.""" + + id: str + """The unique identifier of the run schedule.""" + + agent_id: str + """The agent this schedule belongs to.""" + + initial_input: InitialInput + """The initial input.""" + + initial_input_method: str + """Delivery method, inferred from the agent's ACP type.""" + + name: str + """Human-readable schedule name.""" + + created_at: Optional[datetime] = None + """When the schedule was created.""" + + creator_principal: Optional[CreatorPrincipal] = None + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it is + creator _context_ used only for AuthZ and ownership at fire time. + """ + + cron_expression: Optional[str] = None + """Cron cadence, if cron-based.""" + + description: Optional[str] = None + """Optional description.""" + + end_at: Optional[datetime] = None + """Schedule deactivation time.""" + + interval_seconds: Optional[int] = None + """Interval cadence in seconds, if interval-based.""" + + last_action_time: Optional[datetime] = None + """When the schedule last fired.""" + + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + + next_action_times: Optional[List[datetime]] = None + """Upcoming scheduled fire times.""" + + num_actions_taken: Optional[int] = None + """Number of times the schedule has fired.""" + + paused: Optional[bool] = None + """Whether the schedule is paused.""" + + skipped_action_times: Optional[List[datetime]] = None + """Skipped one-off scheduled fire times.""" + + start_at: Optional[datetime] = None + """Schedule activation time.""" + + state: Optional[Literal["ACTIVE", "PAUSED"]] = None + """Live schedule state from Temporal.""" + + task_metadata: Optional[Dict[str, object]] = None + """Task metadata at fire time.""" + + task_params: Optional[Dict[str, object]] = None + """Task params at fire time.""" + + timezone: Optional[str] = None + """Timezone the cron expression is evaluated in.""" + + updated_at: Optional[datetime] = None + """When the schedule was updated.""" diff --git a/src/agentex/types/agents/schedule_retrieve_response.py b/src/agentex/types/agents/schedule_retrieve_response.py new file mode 100644 index 000000000..374ac635f --- /dev/null +++ b/src/agentex/types/agents/schedule_retrieve_response.py @@ -0,0 +1,123 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel +from ..message_author import MessageAuthor + +__all__ = ["ScheduleRetrieveResponse", "InitialInput", "CreatorPrincipal"] + + +class InitialInput(BaseModel): + """The initial input.""" + + content: str + """The initial prompt delivered to the task.""" + + author: Optional[MessageAuthor] = None + """The author attributed to the initial input.""" + + type: Optional[Literal["text"]] = None + """Input content type.""" + + +class CreatorPrincipal(BaseModel): + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it + is creator *context* used only for AuthZ and ownership at fire time. + """ + + account_id: Optional[str] = None + """Account/workspace id of the creator.""" + + principal_type: Optional[str] = None + """e.g. 'user' or 'service_account'.""" + + service_account_id: Optional[str] = None + """Creator service-account id, if a service principal.""" + + user_id: Optional[str] = None + """Creator user id, if a user principal.""" + + +class ScheduleRetrieveResponse(BaseModel): + """Response model describing a scheduled agent run.""" + + id: str + """The unique identifier of the run schedule.""" + + agent_id: str + """The agent this schedule belongs to.""" + + initial_input: InitialInput + """The initial input.""" + + initial_input_method: str + """Delivery method, inferred from the agent's ACP type.""" + + name: str + """Human-readable schedule name.""" + + created_at: Optional[datetime] = None + """When the schedule was created.""" + + creator_principal: Optional[CreatorPrincipal] = None + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it is + creator _context_ used only for AuthZ and ownership at fire time. + """ + + cron_expression: Optional[str] = None + """Cron cadence, if cron-based.""" + + description: Optional[str] = None + """Optional description.""" + + end_at: Optional[datetime] = None + """Schedule deactivation time.""" + + interval_seconds: Optional[int] = None + """Interval cadence in seconds, if interval-based.""" + + last_action_time: Optional[datetime] = None + """When the schedule last fired.""" + + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + + next_action_times: Optional[List[datetime]] = None + """Upcoming scheduled fire times.""" + + num_actions_taken: Optional[int] = None + """Number of times the schedule has fired.""" + + paused: Optional[bool] = None + """Whether the schedule is paused.""" + + skipped_action_times: Optional[List[datetime]] = None + """Skipped one-off scheduled fire times.""" + + start_at: Optional[datetime] = None + """Schedule activation time.""" + + state: Optional[Literal["ACTIVE", "PAUSED"]] = None + """Live schedule state from Temporal.""" + + task_metadata: Optional[Dict[str, object]] = None + """Task metadata at fire time.""" + + task_params: Optional[Dict[str, object]] = None + """Task params at fire time.""" + + timezone: Optional[str] = None + """Timezone the cron expression is evaluated in.""" + + updated_at: Optional[datetime] = None + """When the schedule was updated.""" diff --git a/src/agentex/types/agents/schedule_skip_params.py b/src/agentex/types/agents/schedule_skip_params.py new file mode 100644 index 000000000..e15ce6c07 --- /dev/null +++ b/src/agentex/types/agents/schedule_skip_params.py @@ -0,0 +1,18 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from datetime import datetime +from typing_extensions import Required, Annotated, TypedDict + +from ..._utils import PropertyInfo + +__all__ = ["ScheduleSkipParams"] + + +class ScheduleSkipParams(TypedDict, total=False): + agent_id: Required[str] + + scheduled_time: Required[Annotated[Union[str, datetime], PropertyInfo(format="iso8601")]] + """Specific scheduled fire time to skip.""" diff --git a/src/agentex/types/agents/schedule_skip_response.py b/src/agentex/types/agents/schedule_skip_response.py new file mode 100644 index 000000000..f97216e0b --- /dev/null +++ b/src/agentex/types/agents/schedule_skip_response.py @@ -0,0 +1,123 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel +from ..message_author import MessageAuthor + +__all__ = ["ScheduleSkipResponse", "InitialInput", "CreatorPrincipal"] + + +class InitialInput(BaseModel): + """The initial input.""" + + content: str + """The initial prompt delivered to the task.""" + + author: Optional[MessageAuthor] = None + """The author attributed to the initial input.""" + + type: Optional[Literal["text"]] = None + """Input content type.""" + + +class CreatorPrincipal(BaseModel): + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it + is creator *context* used only for AuthZ and ownership at fire time. + """ + + account_id: Optional[str] = None + """Account/workspace id of the creator.""" + + principal_type: Optional[str] = None + """e.g. 'user' or 'service_account'.""" + + service_account_id: Optional[str] = None + """Creator service-account id, if a service principal.""" + + user_id: Optional[str] = None + """Creator user id, if a user principal.""" + + +class ScheduleSkipResponse(BaseModel): + """Response model describing a scheduled agent run.""" + + id: str + """The unique identifier of the run schedule.""" + + agent_id: str + """The agent this schedule belongs to.""" + + initial_input: InitialInput + """The initial input.""" + + initial_input_method: str + """Delivery method, inferred from the agent's ACP type.""" + + name: str + """Human-readable schedule name.""" + + created_at: Optional[datetime] = None + """When the schedule was created.""" + + creator_principal: Optional[CreatorPrincipal] = None + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it is + creator _context_ used only for AuthZ and ownership at fire time. + """ + + cron_expression: Optional[str] = None + """Cron cadence, if cron-based.""" + + description: Optional[str] = None + """Optional description.""" + + end_at: Optional[datetime] = None + """Schedule deactivation time.""" + + interval_seconds: Optional[int] = None + """Interval cadence in seconds, if interval-based.""" + + last_action_time: Optional[datetime] = None + """When the schedule last fired.""" + + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + + next_action_times: Optional[List[datetime]] = None + """Upcoming scheduled fire times.""" + + num_actions_taken: Optional[int] = None + """Number of times the schedule has fired.""" + + paused: Optional[bool] = None + """Whether the schedule is paused.""" + + skipped_action_times: Optional[List[datetime]] = None + """Skipped one-off scheduled fire times.""" + + start_at: Optional[datetime] = None + """Schedule activation time.""" + + state: Optional[Literal["ACTIVE", "PAUSED"]] = None + """Live schedule state from Temporal.""" + + task_metadata: Optional[Dict[str, object]] = None + """Task metadata at fire time.""" + + task_params: Optional[Dict[str, object]] = None + """Task params at fire time.""" + + timezone: Optional[str] = None + """Timezone the cron expression is evaluated in.""" + + updated_at: Optional[datetime] = None + """When the schedule was updated.""" diff --git a/src/agentex/types/agents/schedule_trigger_by_name_response.py b/src/agentex/types/agents/schedule_trigger_by_name_response.py new file mode 100644 index 000000000..4006b7ad7 --- /dev/null +++ b/src/agentex/types/agents/schedule_trigger_by_name_response.py @@ -0,0 +1,123 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel +from ..message_author import MessageAuthor + +__all__ = ["ScheduleTriggerByNameResponse", "InitialInput", "CreatorPrincipal"] + + +class InitialInput(BaseModel): + """The initial input.""" + + content: str + """The initial prompt delivered to the task.""" + + author: Optional[MessageAuthor] = None + """The author attributed to the initial input.""" + + type: Optional[Literal["text"]] = None + """Input content type.""" + + +class CreatorPrincipal(BaseModel): + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it + is creator *context* used only for AuthZ and ownership at fire time. + """ + + account_id: Optional[str] = None + """Account/workspace id of the creator.""" + + principal_type: Optional[str] = None + """e.g. 'user' or 'service_account'.""" + + service_account_id: Optional[str] = None + """Creator service-account id, if a service principal.""" + + user_id: Optional[str] = None + """Creator user id, if a user principal.""" + + +class ScheduleTriggerByNameResponse(BaseModel): + """Response model describing a scheduled agent run.""" + + id: str + """The unique identifier of the run schedule.""" + + agent_id: str + """The agent this schedule belongs to.""" + + initial_input: InitialInput + """The initial input.""" + + initial_input_method: str + """Delivery method, inferred from the agent's ACP type.""" + + name: str + """Human-readable schedule name.""" + + created_at: Optional[datetime] = None + """When the schedule was created.""" + + creator_principal: Optional[CreatorPrincipal] = None + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it is + creator _context_ used only for AuthZ and ownership at fire time. + """ + + cron_expression: Optional[str] = None + """Cron cadence, if cron-based.""" + + description: Optional[str] = None + """Optional description.""" + + end_at: Optional[datetime] = None + """Schedule deactivation time.""" + + interval_seconds: Optional[int] = None + """Interval cadence in seconds, if interval-based.""" + + last_action_time: Optional[datetime] = None + """When the schedule last fired.""" + + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + + next_action_times: Optional[List[datetime]] = None + """Upcoming scheduled fire times.""" + + num_actions_taken: Optional[int] = None + """Number of times the schedule has fired.""" + + paused: Optional[bool] = None + """Whether the schedule is paused.""" + + skipped_action_times: Optional[List[datetime]] = None + """Skipped one-off scheduled fire times.""" + + start_at: Optional[datetime] = None + """Schedule activation time.""" + + state: Optional[Literal["ACTIVE", "PAUSED"]] = None + """Live schedule state from Temporal.""" + + task_metadata: Optional[Dict[str, object]] = None + """Task metadata at fire time.""" + + task_params: Optional[Dict[str, object]] = None + """Task params at fire time.""" + + timezone: Optional[str] = None + """Timezone the cron expression is evaluated in.""" + + updated_at: Optional[datetime] = None + """When the schedule was updated.""" diff --git a/src/agentex/types/agents/schedule_trigger_response.py b/src/agentex/types/agents/schedule_trigger_response.py new file mode 100644 index 000000000..dbff651b0 --- /dev/null +++ b/src/agentex/types/agents/schedule_trigger_response.py @@ -0,0 +1,123 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel +from ..message_author import MessageAuthor + +__all__ = ["ScheduleTriggerResponse", "InitialInput", "CreatorPrincipal"] + + +class InitialInput(BaseModel): + """The initial input.""" + + content: str + """The initial prompt delivered to the task.""" + + author: Optional[MessageAuthor] = None + """The author attributed to the initial input.""" + + type: Optional[Literal["text"]] = None + """Input content type.""" + + +class CreatorPrincipal(BaseModel): + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it + is creator *context* used only for AuthZ and ownership at fire time. + """ + + account_id: Optional[str] = None + """Account/workspace id of the creator.""" + + principal_type: Optional[str] = None + """e.g. 'user' or 'service_account'.""" + + service_account_id: Optional[str] = None + """Creator service-account id, if a service principal.""" + + user_id: Optional[str] = None + """Creator user id, if a user principal.""" + + +class ScheduleTriggerResponse(BaseModel): + """Response model describing a scheduled agent run.""" + + id: str + """The unique identifier of the run schedule.""" + + agent_id: str + """The agent this schedule belongs to.""" + + initial_input: InitialInput + """The initial input.""" + + initial_input_method: str + """Delivery method, inferred from the agent's ACP type.""" + + name: str + """Human-readable schedule name.""" + + created_at: Optional[datetime] = None + """When the schedule was created.""" + + creator_principal: Optional[CreatorPrincipal] = None + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it is + creator _context_ used only for AuthZ and ownership at fire time. + """ + + cron_expression: Optional[str] = None + """Cron cadence, if cron-based.""" + + description: Optional[str] = None + """Optional description.""" + + end_at: Optional[datetime] = None + """Schedule deactivation time.""" + + interval_seconds: Optional[int] = None + """Interval cadence in seconds, if interval-based.""" + + last_action_time: Optional[datetime] = None + """When the schedule last fired.""" + + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + + next_action_times: Optional[List[datetime]] = None + """Upcoming scheduled fire times.""" + + num_actions_taken: Optional[int] = None + """Number of times the schedule has fired.""" + + paused: Optional[bool] = None + """Whether the schedule is paused.""" + + skipped_action_times: Optional[List[datetime]] = None + """Skipped one-off scheduled fire times.""" + + start_at: Optional[datetime] = None + """Schedule activation time.""" + + state: Optional[Literal["ACTIVE", "PAUSED"]] = None + """Live schedule state from Temporal.""" + + task_metadata: Optional[Dict[str, object]] = None + """Task metadata at fire time.""" + + task_params: Optional[Dict[str, object]] = None + """Task params at fire time.""" + + timezone: Optional[str] = None + """Timezone the cron expression is evaluated in.""" + + updated_at: Optional[datetime] = None + """When the schedule was updated.""" diff --git a/src/agentex/types/agents/schedule_unskip_params.py b/src/agentex/types/agents/schedule_unskip_params.py new file mode 100644 index 000000000..70792a1e6 --- /dev/null +++ b/src/agentex/types/agents/schedule_unskip_params.py @@ -0,0 +1,18 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from datetime import datetime +from typing_extensions import Required, Annotated, TypedDict + +from ..._utils import PropertyInfo + +__all__ = ["ScheduleUnskipParams"] + + +class ScheduleUnskipParams(TypedDict, total=False): + agent_id: Required[str] + + scheduled_time: Required[Annotated[Union[str, datetime], PropertyInfo(format="iso8601")]] + """Specific scheduled fire time to unskip.""" diff --git a/src/agentex/types/agents/schedule_unskip_response.py b/src/agentex/types/agents/schedule_unskip_response.py new file mode 100644 index 000000000..96f624220 --- /dev/null +++ b/src/agentex/types/agents/schedule_unskip_response.py @@ -0,0 +1,123 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel +from ..message_author import MessageAuthor + +__all__ = ["ScheduleUnskipResponse", "InitialInput", "CreatorPrincipal"] + + +class InitialInput(BaseModel): + """The initial input.""" + + content: str + """The initial prompt delivered to the task.""" + + author: Optional[MessageAuthor] = None + """The author attributed to the initial input.""" + + type: Optional[Literal["text"]] = None + """Input content type.""" + + +class CreatorPrincipal(BaseModel): + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it + is creator *context* used only for AuthZ and ownership at fire time. + """ + + account_id: Optional[str] = None + """Account/workspace id of the creator.""" + + principal_type: Optional[str] = None + """e.g. 'user' or 'service_account'.""" + + service_account_id: Optional[str] = None + """Creator service-account id, if a service principal.""" + + user_id: Optional[str] = None + """Creator user id, if a user principal.""" + + +class ScheduleUnskipResponse(BaseModel): + """Response model describing a scheduled agent run.""" + + id: str + """The unique identifier of the run schedule.""" + + agent_id: str + """The agent this schedule belongs to.""" + + initial_input: InitialInput + """The initial input.""" + + initial_input_method: str + """Delivery method, inferred from the agent's ACP type.""" + + name: str + """Human-readable schedule name.""" + + created_at: Optional[datetime] = None + """When the schedule was created.""" + + creator_principal: Optional[CreatorPrincipal] = None + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it is + creator _context_ used only for AuthZ and ownership at fire time. + """ + + cron_expression: Optional[str] = None + """Cron cadence, if cron-based.""" + + description: Optional[str] = None + """Optional description.""" + + end_at: Optional[datetime] = None + """Schedule deactivation time.""" + + interval_seconds: Optional[int] = None + """Interval cadence in seconds, if interval-based.""" + + last_action_time: Optional[datetime] = None + """When the schedule last fired.""" + + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + + next_action_times: Optional[List[datetime]] = None + """Upcoming scheduled fire times.""" + + num_actions_taken: Optional[int] = None + """Number of times the schedule has fired.""" + + paused: Optional[bool] = None + """Whether the schedule is paused.""" + + skipped_action_times: Optional[List[datetime]] = None + """Skipped one-off scheduled fire times.""" + + start_at: Optional[datetime] = None + """Schedule activation time.""" + + state: Optional[Literal["ACTIVE", "PAUSED"]] = None + """Live schedule state from Temporal.""" + + task_metadata: Optional[Dict[str, object]] = None + """Task metadata at fire time.""" + + task_params: Optional[Dict[str, object]] = None + """Task params at fire time.""" + + timezone: Optional[str] = None + """Timezone the cron expression is evaluated in.""" + + updated_at: Optional[datetime] = None + """When the schedule was updated.""" diff --git a/src/agentex/types/agents/schedule_update_by_name_params.py b/src/agentex/types/agents/schedule_update_by_name_params.py new file mode 100644 index 000000000..0c15e199f --- /dev/null +++ b/src/agentex/types/agents/schedule_update_by_name_params.py @@ -0,0 +1,62 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Union, Optional +from datetime import datetime +from typing_extensions import Literal, Required, Annotated, TypedDict + +from ..._utils import PropertyInfo +from ..message_author import MessageAuthor + +__all__ = ["ScheduleUpdateByNameParams", "InitialInput"] + + +class ScheduleUpdateByNameParams(TypedDict, total=False): + agent_id: Required[str] + + cron_expression: Optional[str] + """New cron cadence. Mutually exclusive with interval_seconds.""" + + description: Optional[str] + """Optional description of what this schedule does.""" + + end_at: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] + """When the schedule should stop being active.""" + + initial_input: Optional[InitialInput] + """The first input delivered to each freshly created scheduled task.""" + + interval_seconds: Optional[int] + """New interval cadence in seconds. Mutually exclusive with cron_expression.""" + + body_name: Annotated[Optional[str], PropertyInfo(alias="name")] + """Human-readable name, unique among active schedules for the agent.""" + + paused: Optional[bool] + """Pause/resume the schedule as part of the update.""" + + start_at: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] + """When the schedule should start being active.""" + + task_metadata: Optional[Dict[str, object]] + """Metadata copied onto each created task at fire time.""" + + task_params: Optional[Dict[str, object]] + """Resolved config forwarded as task `params` at fire time.""" + + timezone: Optional[str] + """IANA timezone the cron expression is evaluated in.""" + + +class InitialInput(TypedDict, total=False): + """The first input delivered to each freshly created scheduled task.""" + + content: Required[str] + """The initial prompt delivered to the task.""" + + author: MessageAuthor + """The author attributed to the initial input.""" + + type: Literal["text"] + """Input content type.""" diff --git a/src/agentex/types/agents/schedule_update_by_name_response.py b/src/agentex/types/agents/schedule_update_by_name_response.py new file mode 100644 index 000000000..e2905593c --- /dev/null +++ b/src/agentex/types/agents/schedule_update_by_name_response.py @@ -0,0 +1,123 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel +from ..message_author import MessageAuthor + +__all__ = ["ScheduleUpdateByNameResponse", "InitialInput", "CreatorPrincipal"] + + +class InitialInput(BaseModel): + """The initial input.""" + + content: str + """The initial prompt delivered to the task.""" + + author: Optional[MessageAuthor] = None + """The author attributed to the initial input.""" + + type: Optional[Literal["text"]] = None + """Input content type.""" + + +class CreatorPrincipal(BaseModel): + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it + is creator *context* used only for AuthZ and ownership at fire time. + """ + + account_id: Optional[str] = None + """Account/workspace id of the creator.""" + + principal_type: Optional[str] = None + """e.g. 'user' or 'service_account'.""" + + service_account_id: Optional[str] = None + """Creator service-account id, if a service principal.""" + + user_id: Optional[str] = None + """Creator user id, if a user principal.""" + + +class ScheduleUpdateByNameResponse(BaseModel): + """Response model describing a scheduled agent run.""" + + id: str + """The unique identifier of the run schedule.""" + + agent_id: str + """The agent this schedule belongs to.""" + + initial_input: InitialInput + """The initial input.""" + + initial_input_method: str + """Delivery method, inferred from the agent's ACP type.""" + + name: str + """Human-readable schedule name.""" + + created_at: Optional[datetime] = None + """When the schedule was created.""" + + creator_principal: Optional[CreatorPrincipal] = None + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it is + creator _context_ used only for AuthZ and ownership at fire time. + """ + + cron_expression: Optional[str] = None + """Cron cadence, if cron-based.""" + + description: Optional[str] = None + """Optional description.""" + + end_at: Optional[datetime] = None + """Schedule deactivation time.""" + + interval_seconds: Optional[int] = None + """Interval cadence in seconds, if interval-based.""" + + last_action_time: Optional[datetime] = None + """When the schedule last fired.""" + + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + + next_action_times: Optional[List[datetime]] = None + """Upcoming scheduled fire times.""" + + num_actions_taken: Optional[int] = None + """Number of times the schedule has fired.""" + + paused: Optional[bool] = None + """Whether the schedule is paused.""" + + skipped_action_times: Optional[List[datetime]] = None + """Skipped one-off scheduled fire times.""" + + start_at: Optional[datetime] = None + """Schedule activation time.""" + + state: Optional[Literal["ACTIVE", "PAUSED"]] = None + """Live schedule state from Temporal.""" + + task_metadata: Optional[Dict[str, object]] = None + """Task metadata at fire time.""" + + task_params: Optional[Dict[str, object]] = None + """Task params at fire time.""" + + timezone: Optional[str] = None + """Timezone the cron expression is evaluated in.""" + + updated_at: Optional[datetime] = None + """When the schedule was updated.""" diff --git a/src/agentex/types/agents/schedule_update_params.py b/src/agentex/types/agents/schedule_update_params.py new file mode 100644 index 000000000..0c3b6254b --- /dev/null +++ b/src/agentex/types/agents/schedule_update_params.py @@ -0,0 +1,62 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Union, Optional +from datetime import datetime +from typing_extensions import Literal, Required, Annotated, TypedDict + +from ..._utils import PropertyInfo +from ..message_author import MessageAuthor + +__all__ = ["ScheduleUpdateParams", "InitialInput"] + + +class ScheduleUpdateParams(TypedDict, total=False): + agent_id: Required[str] + + cron_expression: Optional[str] + """New cron cadence. Mutually exclusive with interval_seconds.""" + + description: Optional[str] + """Optional description of what this schedule does.""" + + end_at: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] + """When the schedule should stop being active.""" + + initial_input: Optional[InitialInput] + """The first input delivered to each freshly created scheduled task.""" + + interval_seconds: Optional[int] + """New interval cadence in seconds. Mutually exclusive with cron_expression.""" + + name: Optional[str] + """Human-readable name, unique among active schedules for the agent.""" + + paused: Optional[bool] + """Pause/resume the schedule as part of the update.""" + + start_at: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] + """When the schedule should start being active.""" + + task_metadata: Optional[Dict[str, object]] + """Metadata copied onto each created task at fire time.""" + + task_params: Optional[Dict[str, object]] + """Resolved config forwarded as task `params` at fire time.""" + + timezone: Optional[str] + """IANA timezone the cron expression is evaluated in.""" + + +class InitialInput(TypedDict, total=False): + """The first input delivered to each freshly created scheduled task.""" + + content: Required[str] + """The initial prompt delivered to the task.""" + + author: MessageAuthor + """The author attributed to the initial input.""" + + type: Literal["text"] + """Input content type.""" diff --git a/src/agentex/types/agents/schedule_update_response.py b/src/agentex/types/agents/schedule_update_response.py new file mode 100644 index 000000000..a7416e776 --- /dev/null +++ b/src/agentex/types/agents/schedule_update_response.py @@ -0,0 +1,123 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel +from ..message_author import MessageAuthor + +__all__ = ["ScheduleUpdateResponse", "InitialInput", "CreatorPrincipal"] + + +class InitialInput(BaseModel): + """The initial input.""" + + content: str + """The initial prompt delivered to the task.""" + + author: Optional[MessageAuthor] = None + """The author attributed to the initial input.""" + + type: Optional[Literal["text"]] = None + """Input content type.""" + + +class CreatorPrincipal(BaseModel): + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it + is creator *context* used only for AuthZ and ownership at fire time. + """ + + account_id: Optional[str] = None + """Account/workspace id of the creator.""" + + principal_type: Optional[str] = None + """e.g. 'user' or 'service_account'.""" + + service_account_id: Optional[str] = None + """Creator service-account id, if a service principal.""" + + user_id: Optional[str] = None + """Creator user id, if a user principal.""" + + +class ScheduleUpdateResponse(BaseModel): + """Response model describing a scheduled agent run.""" + + id: str + """The unique identifier of the run schedule.""" + + agent_id: str + """The agent this schedule belongs to.""" + + initial_input: InitialInput + """The initial input.""" + + initial_input_method: str + """Delivery method, inferred from the agent's ACP type.""" + + name: str + """Human-readable schedule name.""" + + created_at: Optional[datetime] = None + """When the schedule was created.""" + + creator_principal: Optional[CreatorPrincipal] = None + """Credential-free creator identity stored with the schedule. + + Never carries cookies, JWTs, API keys, OAuth tokens, or request headers — it is + creator _context_ used only for AuthZ and ownership at fire time. + """ + + cron_expression: Optional[str] = None + """Cron cadence, if cron-based.""" + + description: Optional[str] = None + """Optional description.""" + + end_at: Optional[datetime] = None + """Schedule deactivation time.""" + + interval_seconds: Optional[int] = None + """Interval cadence in seconds, if interval-based.""" + + last_action_time: Optional[datetime] = None + """When the schedule last fired.""" + + live_data_available: Optional[bool] = None + """Whether requested live Temporal fields were retrieved successfully. + + Null when live enrichment was not requested. + """ + + next_action_times: Optional[List[datetime]] = None + """Upcoming scheduled fire times.""" + + num_actions_taken: Optional[int] = None + """Number of times the schedule has fired.""" + + paused: Optional[bool] = None + """Whether the schedule is paused.""" + + skipped_action_times: Optional[List[datetime]] = None + """Skipped one-off scheduled fire times.""" + + start_at: Optional[datetime] = None + """Schedule activation time.""" + + state: Optional[Literal["ACTIVE", "PAUSED"]] = None + """Live schedule state from Temporal.""" + + task_metadata: Optional[Dict[str, object]] = None + """Task metadata at fire time.""" + + task_params: Optional[Dict[str, object]] = None + """Task params at fire time.""" + + timezone: Optional[str] = None + """Timezone the cron expression is evaluated in.""" + + updated_at: Optional[datetime] = None + """When the schedule was updated.""" diff --git a/src/agentex/types/checkpoint_delete_thread_params.py b/src/agentex/types/checkpoint_delete_thread_params.py new file mode 100644 index 000000000..b0e3305ab --- /dev/null +++ b/src/agentex/types/checkpoint_delete_thread_params.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["CheckpointDeleteThreadParams"] + + +class CheckpointDeleteThreadParams(TypedDict, total=False): + thread_id: Required[str] diff --git a/src/agentex/types/checkpoint_get_tuple_params.py b/src/agentex/types/checkpoint_get_tuple_params.py new file mode 100644 index 000000000..947ef072d --- /dev/null +++ b/src/agentex/types/checkpoint_get_tuple_params.py @@ -0,0 +1,16 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import Required, TypedDict + +__all__ = ["CheckpointGetTupleParams"] + + +class CheckpointGetTupleParams(TypedDict, total=False): + thread_id: Required[str] + + checkpoint_id: Optional[str] + + checkpoint_ns: str diff --git a/src/agentex/types/checkpoint_get_tuple_response.py b/src/agentex/types/checkpoint_get_tuple_response.py new file mode 100644 index 000000000..dab859d0d --- /dev/null +++ b/src/agentex/types/checkpoint_get_tuple_response.py @@ -0,0 +1,47 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional + +from .._models import BaseModel + +__all__ = ["CheckpointGetTupleResponse", "Blob", "PendingWrite"] + + +class Blob(BaseModel): + channel: str + + type: str + + version: str + + blob: Optional[str] = None + + +class PendingWrite(BaseModel): + channel: str + + idx: int + + task_id: str + + blob: Optional[str] = None + + type: Optional[str] = None + + +class CheckpointGetTupleResponse(BaseModel): + checkpoint: Dict[str, object] + + checkpoint_id: str + + checkpoint_ns: str + + metadata: Dict[str, object] + + thread_id: str + + blobs: Optional[List[Blob]] = None + + parent_checkpoint_id: Optional[str] = None + + pending_writes: Optional[List[PendingWrite]] = None diff --git a/src/agentex/types/checkpoint_list_params.py b/src/agentex/types/checkpoint_list_params.py new file mode 100644 index 000000000..20a21d6ac --- /dev/null +++ b/src/agentex/types/checkpoint_list_params.py @@ -0,0 +1,20 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Optional +from typing_extensions import Required, TypedDict + +__all__ = ["CheckpointListParams"] + + +class CheckpointListParams(TypedDict, total=False): + thread_id: Required[str] + + before_checkpoint_id: Optional[str] + + checkpoint_ns: Optional[str] + + filter_metadata: Optional[Dict[str, object]] + + limit: int diff --git a/src/agentex/types/checkpoint_list_response.py b/src/agentex/types/checkpoint_list_response.py new file mode 100644 index 000000000..dce4bd9a1 --- /dev/null +++ b/src/agentex/types/checkpoint_list_response.py @@ -0,0 +1,25 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional +from typing_extensions import TypeAlias + +from .._models import BaseModel + +__all__ = ["CheckpointListResponse", "CheckpointListResponseItem"] + + +class CheckpointListResponseItem(BaseModel): + checkpoint: Dict[str, object] + + checkpoint_id: str + + checkpoint_ns: str + + metadata: Dict[str, object] + + thread_id: str + + parent_checkpoint_id: Optional[str] = None + + +CheckpointListResponse: TypeAlias = List[CheckpointListResponseItem] diff --git a/src/agentex/types/checkpoint_put_params.py b/src/agentex/types/checkpoint_put_params.py new file mode 100644 index 000000000..5e692961a --- /dev/null +++ b/src/agentex/types/checkpoint_put_params.py @@ -0,0 +1,34 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Iterable, Optional +from typing_extensions import Required, TypedDict + +__all__ = ["CheckpointPutParams", "Blob"] + + +class CheckpointPutParams(TypedDict, total=False): + checkpoint: Required[Dict[str, object]] + + checkpoint_id: Required[str] + + thread_id: Required[str] + + blobs: Iterable[Blob] + + checkpoint_ns: str + + metadata: Dict[str, object] + + parent_checkpoint_id: Optional[str] + + +class Blob(TypedDict, total=False): + channel: Required[str] + + type: Required[str] + + version: Required[str] + + blob: Optional[str] diff --git a/src/agentex/types/checkpoint_put_response.py b/src/agentex/types/checkpoint_put_response.py new file mode 100644 index 000000000..90afe9ce5 --- /dev/null +++ b/src/agentex/types/checkpoint_put_response.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .._models import BaseModel + +__all__ = ["CheckpointPutResponse"] + + +class CheckpointPutResponse(BaseModel): + checkpoint_id: str + + checkpoint_ns: str + + thread_id: str diff --git a/src/agentex/types/checkpoint_put_writes_params.py b/src/agentex/types/checkpoint_put_writes_params.py new file mode 100644 index 000000000..adf8c7852 --- /dev/null +++ b/src/agentex/types/checkpoint_put_writes_params.py @@ -0,0 +1,34 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Iterable, Optional +from typing_extensions import Required, TypedDict + +__all__ = ["CheckpointPutWritesParams", "Write"] + + +class CheckpointPutWritesParams(TypedDict, total=False): + checkpoint_id: Required[str] + + thread_id: Required[str] + + writes: Required[Iterable[Write]] + + checkpoint_ns: str + + upsert: bool + + +class Write(TypedDict, total=False): + blob: Required[str] + + channel: Required[str] + + idx: Required[int] + + task_id: Required[str] + + task_path: str + + type: Optional[str] diff --git a/src/agentex/types/data_content.py b/src/agentex/types/data_content.py new file mode 100644 index 000000000..f23212fe7 --- /dev/null +++ b/src/agentex/types/data_content.py @@ -0,0 +1,30 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict +from typing_extensions import Literal + +from .._models import BaseModel +from .message_style import MessageStyle +from .message_author import MessageAuthor + +__all__ = ["DataContent"] + + +class DataContent(BaseModel): + author: MessageAuthor + """ + The role of the messages author, in this case `system`, `user`, `assistant`, or + `tool`. + """ + + data: Dict[str, object] + """The contents of the data message.""" + + style: MessageStyle = "static" + """The style of the message. + + This is used by the client to determine how to display the message. + """ + + type: Literal["data"] = "data" + """The type of the message, in this case `data`.""" diff --git a/src/agentex/types/data_content_param.py b/src/agentex/types/data_content_param.py new file mode 100644 index 000000000..2232e417b --- /dev/null +++ b/src/agentex/types/data_content_param.py @@ -0,0 +1,31 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict +from typing_extensions import Literal, Required, TypedDict + +from .message_style import MessageStyle +from .message_author import MessageAuthor + +__all__ = ["DataContentParam"] + + +class DataContentParam(TypedDict, total=False): + author: Required[MessageAuthor] + """ + The role of the messages author, in this case `system`, `user`, `assistant`, or + `tool`. + """ + + data: Required[Dict[str, object]] + """The contents of the data message.""" + + style: MessageStyle + """The style of the message. + + This is used by the client to determine how to display the message. + """ + + type: Literal["data"] + """The type of the message, in this case `data`.""" diff --git a/src/agentex/types/data_delta.py b/src/agentex/types/data_delta.py new file mode 100644 index 000000000..5b15838a3 --- /dev/null +++ b/src/agentex/types/data_delta.py @@ -0,0 +1,16 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["DataDelta"] + + +class DataDelta(BaseModel): + """Delta for data updates""" + + data_delta: Optional[str] = None + + type: Optional[Literal["data"]] = None diff --git a/src/agentex/types/deployment_history.py b/src/agentex/types/deployment_history.py new file mode 100644 index 000000000..63a25e8e2 --- /dev/null +++ b/src/agentex/types/deployment_history.py @@ -0,0 +1,35 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from datetime import datetime + +from .._models import BaseModel + +__all__ = ["DeploymentHistory"] + + +class DeploymentHistory(BaseModel): + """API schema for deployment history.""" + + id: str + """The unique identifier of the deployment record""" + + agent_id: str + """The ID of the agent this deployment belongs to""" + + author_email: str + """Email of the commit author""" + + author_name: str + """Name of the commit author""" + + branch_name: str + """Name of the branch""" + + build_timestamp: datetime + """When the build was created""" + + commit_hash: str + """Git commit hash for this deployment""" + + deployment_timestamp: datetime + """When this deployment was first seen in the system""" diff --git a/src/agentex/types/deployment_history_list_params.py b/src/agentex/types/deployment_history_list_params.py new file mode 100644 index 000000000..d77ed5d17 --- /dev/null +++ b/src/agentex/types/deployment_history_list_params.py @@ -0,0 +1,22 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import TypedDict + +__all__ = ["DeploymentHistoryListParams"] + + +class DeploymentHistoryListParams(TypedDict, total=False): + agent_id: Optional[str] + + agent_name: Optional[str] + + limit: int + + order_by: Optional[str] + + order_direction: str + + page_number: int diff --git a/src/agentex/types/deployment_history_list_response.py b/src/agentex/types/deployment_history_list_response.py new file mode 100644 index 000000000..c71a8f037 --- /dev/null +++ b/src/agentex/types/deployment_history_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .deployment_history import DeploymentHistory + +__all__ = ["DeploymentHistoryListResponse"] + +DeploymentHistoryListResponse: TypeAlias = List[DeploymentHistory] diff --git a/src/agentex/types/event.py b/src/agentex/types/event.py new file mode 100644 index 000000000..9a544cb66 --- /dev/null +++ b/src/agentex/types/event.py @@ -0,0 +1,29 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime + +from .._models import BaseModel +from .task_message_content import TaskMessageContent + +__all__ = ["Event"] + + +class Event(BaseModel): + id: str + """The UUID of the event""" + + agent_id: str + """The UUID of the agent that the event belongs to""" + + sequence_id: int + """The sequence ID of the event""" + + task_id: str + """The UUID of the task that the event belongs to""" + + content: Optional[TaskMessageContent] = None + """The content of the event""" + + created_at: Optional[datetime] = None + """The timestamp of the event""" diff --git a/src/agentex/types/event_list_params.py b/src/agentex/types/event_list_params.py new file mode 100644 index 000000000..2d628dd69 --- /dev/null +++ b/src/agentex/types/event_list_params.py @@ -0,0 +1,22 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import Required, TypedDict + +__all__ = ["EventListParams"] + + +class EventListParams(TypedDict, total=False): + agent_id: Required[str] + """The agent ID to filter events by""" + + task_id: Required[str] + """The task ID to filter events by""" + + last_processed_event_id: Optional[str] + """Optional event ID to get events after this ID""" + + limit: Optional[int] + """Optional limit on number of results""" diff --git a/src/agentex/types/event_list_response.py b/src/agentex/types/event_list_response.py new file mode 100644 index 000000000..050571806 --- /dev/null +++ b/src/agentex/types/event_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .event import Event + +__all__ = ["EventListResponse"] + +EventListResponse: TypeAlias = List[Event] diff --git a/src/agentex/types/message_author.py b/src/agentex/types/message_author.py new file mode 100644 index 000000000..c902ac5e7 --- /dev/null +++ b/src/agentex/types/message_author.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal, TypeAlias + +__all__ = ["MessageAuthor"] + +MessageAuthor: TypeAlias = Literal["user", "agent"] diff --git a/src/agentex/types/message_create_params.py b/src/agentex/types/message_create_params.py new file mode 100644 index 000000000..d8a1963a6 --- /dev/null +++ b/src/agentex/types/message_create_params.py @@ -0,0 +1,29 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union, Optional +from datetime import datetime +from typing_extensions import Literal, Required, Annotated, TypedDict + +from .._utils import PropertyInfo +from .task_message_content_param import TaskMessageContentParam + +__all__ = ["MessageCreateParams"] + + +class MessageCreateParams(TypedDict, total=False): + content: Required[TaskMessageContentParam] + + task_id: Required[str] + + created_at: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] + """Optional timestamp for the message. + + Workflow callers should pass workflow.now() (Temporal's deterministic monotonic + clock) so that two awaited messages.create calls from the same workflow are + guaranteed to have monotonic timestamps regardless of HTTP scheduling at the + server. If omitted, the server's wall clock at insert time is used. + """ + + streaming_status: Optional[Literal["IN_PROGRESS", "DONE"]] diff --git a/src/agentex/types/message_list_paginated_params.py b/src/agentex/types/message_list_paginated_params.py new file mode 100644 index 000000000..9ddb2b21a --- /dev/null +++ b/src/agentex/types/message_list_paginated_params.py @@ -0,0 +1,534 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import Literal, Required, TypedDict + +__all__ = ["MessageListPaginatedParams"] + + +class MessageListPaginatedParams(TypedDict, total=False): + task_id: Required[str] + """The task ID""" + + cursor: Optional[str] + + direction: Literal["older", "newer"] + + filters: Optional[str] + """JSON-encoded array of TaskMessageEntityFilter objects. + + Schema: { + "$defs": { + "DataContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "data", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `data`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The contents of the data message.", + "title": "Data" + } + }, + "title": "DataContentEntityOptional", + "type": "object" + }, + "FileAttachmentEntity": { + "description": "Represents a file attachment in messages.", + "properties": { + "file_id": { + "description": "The unique ID of the attached file", + "title": "File Id", + "type": "string" + }, + "name": { + "description": "The name of the file", + "title": "Name", + "type": "string" + }, + "size": { + "description": "The size of the file in bytes", + "title": "Size", + "type": "integer" + }, + "type": { + "description": "The MIME type or content type of the file", + "title": "Type", + "type": "string" + } + }, + "required": [ + "file_id", + "name", + "size", + "type" + ], + "title": "FileAttachmentEntity", + "type": "object" + }, + "MessageAuthor": { + "enum": [ + "user", + "agent" + ], + "title": "MessageAuthor", + "type": "string" + }, + "MessageStyle": { + "enum": [ + "static", + "active" + ], + "title": "MessageStyle", + "type": "string" + }, + "ReasoningContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "reasoning", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `reasoning`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "summary": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "A list of short reasoning summaries", + "title": "Summary" + }, + "content": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The reasoning content or chain-of-thought text", + "title": "Content" + } + }, + "title": "ReasoningContentEntityOptional", + "type": "object" + }, + "TextContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "text", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `text`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "format": { + "anyOf": [ + { + "$ref": + "#/$defs/TextFormat" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The format of the message. This is used by the client to determine how to display the message." + }, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The contents of the text message.", + "title": "Content" + }, + "attachments": { + "anyOf": [ + { + "items": { + "$ref": + "#/$defs/FileAttachmentEntity" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional list of file attachments with structured metadata.", + "title": "Attachments" + } + }, + "title": "TextContentEntityOptional", + "type": "object" + }, + "TextFormat": { + "enum": [ + "markdown", + "plain", + "code" + ], + "title": "TextFormat", + "type": "string" + }, + "ToolRequestContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "tool_request", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `tool_request`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "tool_call_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The ID of the tool call that is being requested.", + "title": "Tool Call Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The name of the tool that is being requested.", + "title": "Name" + }, + "arguments": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The arguments to the tool.", + "title": "Arguments" + } + }, + "title": "ToolRequestContentEntityOptional", + "type": "object" + }, + "ToolResponseContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "tool_response", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `tool_response`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "tool_call_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The ID of the tool call that is being responded to.", + "title": "Tool Call Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The name of the tool that is being responded to.", + "title": "Name" + }, + "content": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The result of the tool.", + "title": "Content" + }, + "is_error": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Whether the tool call resulted in an error. `None` when the harness does not report a status.", + "title": "Is Error" + } + }, + "title": "ToolResponseContentEntityOptional", + "type": "object" + } + }, + "description": "Filter model for TaskMessage - all fields optional for flexible filtering.\n\nThe `exclude` field determines whether this filter is inclusionary or exclusionary.\nWhen multiple filters are provided:\n- Inclusionary filters (exclude=False) are OR'd together\n- Exclusionary filters (exclude=True) are OR'd together and negated with $nor\n- The two groups are AND'd: (include1 OR include2) AND NOT (exclude1 OR exclude2)", + "properties": { + "content": { + "anyOf": [ + { + "$ref": + "#/$defs/ToolRequestContentEntityOptional" + }, + { + "$ref": + "#/$defs/DataContentEntityOptional" + }, + { + "$ref": + "#/$defs/TextContentEntityOptional" + }, + { + "$ref": + "#/$defs/ToolResponseContentEntityOptional" + }, + { + "$ref": + "#/$defs/ReasoningContentEntityOptional" }, { "type": "null" } ], "default": + null, "description": "Filter by message content", "title": "Content" }, + "streaming_status": { "anyOf": [ { "enum": [ "IN_PROGRESS", "DONE" ], "type": + "string" }, { "type": "null" } ], "default": null, "description": "Filter by + streaming status", "title": "Streaming Status" }, "exclude": { "default": false, + "description": "If true, this filter excludes matching messages", "title": + "Exclude", "type": "boolean" } }, "title": "TaskMessageEntityFilter", "type": + "object" } + + Each filter can include: + + - `content`: Filter by message content (type, author, data fields) + - `streaming_status`: Filter by status ("IN_PROGRESS" or "DONE") + - `exclude`: If true, excludes matching messages (default: false) + + Multiple filters are combined: inclusionary filters (exclude=false) are OR'd + together, exclusionary filters (exclude=true) are OR'd and negated, then both + groups are AND'd. + """ + + limit: int diff --git a/src/agentex/types/message_list_paginated_response.py b/src/agentex/types/message_list_paginated_response.py new file mode 100644 index 000000000..40af0b4a5 --- /dev/null +++ b/src/agentex/types/message_list_paginated_response.py @@ -0,0 +1,21 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional + +from .._models import BaseModel +from .task_message import TaskMessage + +__all__ = ["MessageListPaginatedResponse"] + + +class MessageListPaginatedResponse(BaseModel): + """Response with cursor pagination metadata.""" + + data: List[TaskMessage] + """List of messages""" + + has_more: Optional[bool] = None + """Whether there are more messages to fetch""" + + next_cursor: Optional[str] = None + """Cursor for fetching the next page of older messages""" diff --git a/src/agentex/types/message_list_params.py b/src/agentex/types/message_list_params.py new file mode 100644 index 000000000..b3ce650f9 --- /dev/null +++ b/src/agentex/types/message_list_params.py @@ -0,0 +1,536 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import Required, TypedDict + +__all__ = ["MessageListParams"] + + +class MessageListParams(TypedDict, total=False): + task_id: Required[str] + """The task ID""" + + filters: Optional[str] + """JSON-encoded array of TaskMessageEntityFilter objects. + + Schema: { + "$defs": { + "DataContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "data", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `data`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "data": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The contents of the data message.", + "title": "Data" + } + }, + "title": "DataContentEntityOptional", + "type": "object" + }, + "FileAttachmentEntity": { + "description": "Represents a file attachment in messages.", + "properties": { + "file_id": { + "description": "The unique ID of the attached file", + "title": "File Id", + "type": "string" + }, + "name": { + "description": "The name of the file", + "title": "Name", + "type": "string" + }, + "size": { + "description": "The size of the file in bytes", + "title": "Size", + "type": "integer" + }, + "type": { + "description": "The MIME type or content type of the file", + "title": "Type", + "type": "string" + } + }, + "required": [ + "file_id", + "name", + "size", + "type" + ], + "title": "FileAttachmentEntity", + "type": "object" + }, + "MessageAuthor": { + "enum": [ + "user", + "agent" + ], + "title": "MessageAuthor", + "type": "string" + }, + "MessageStyle": { + "enum": [ + "static", + "active" + ], + "title": "MessageStyle", + "type": "string" + }, + "ReasoningContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "reasoning", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `reasoning`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "summary": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "A list of short reasoning summaries", + "title": "Summary" + }, + "content": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The reasoning content or chain-of-thought text", + "title": "Content" + } + }, + "title": "ReasoningContentEntityOptional", + "type": "object" + }, + "TextContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "text", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `text`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "format": { + "anyOf": [ + { + "$ref": + "#/$defs/TextFormat" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The format of the message. This is used by the client to determine how to display the message." + }, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The contents of the text message.", + "title": "Content" + }, + "attachments": { + "anyOf": [ + { + "items": { + "$ref": + "#/$defs/FileAttachmentEntity" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional list of file attachments with structured metadata.", + "title": "Attachments" + } + }, + "title": "TextContentEntityOptional", + "type": "object" + }, + "TextFormat": { + "enum": [ + "markdown", + "plain", + "code" + ], + "title": "TextFormat", + "type": "string" + }, + "ToolRequestContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "tool_request", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `tool_request`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "tool_call_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The ID of the tool call that is being requested.", + "title": "Tool Call Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The name of the tool that is being requested.", + "title": "Name" + }, + "arguments": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The arguments to the tool.", + "title": "Arguments" + } + }, + "title": "ToolRequestContentEntityOptional", + "type": "object" + }, + "ToolResponseContentEntityOptional": { + "properties": { + "type": { + "anyOf": [ + { + "const": "tool_response", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The type of the message, in this case `tool_response`.", + "title": "Type" + }, + "author": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageAuthor" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The role of the messages author, in this case `system`, `user`, `assistant`, or `tool`." + }, + "style": { + "anyOf": [ + { + "$ref": + "#/$defs/MessageStyle" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The style of the message. This is used by the client to determine how to display the message." + }, + "tool_call_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The ID of the tool call that is being responded to.", + "title": "Tool Call Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The name of the tool that is being responded to.", + "title": "Name" + }, + "content": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The result of the tool.", + "title": "Content" + }, + "is_error": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Whether the tool call resulted in an error. `None` when the harness does not report a status.", + "title": "Is Error" + } + }, + "title": "ToolResponseContentEntityOptional", + "type": "object" + } + }, + "description": "Filter model for TaskMessage - all fields optional for flexible filtering.\n\nThe `exclude` field determines whether this filter is inclusionary or exclusionary.\nWhen multiple filters are provided:\n- Inclusionary filters (exclude=False) are OR'd together\n- Exclusionary filters (exclude=True) are OR'd together and negated with $nor\n- The two groups are AND'd: (include1 OR include2) AND NOT (exclude1 OR exclude2)", + "properties": { + "content": { + "anyOf": [ + { + "$ref": + "#/$defs/ToolRequestContentEntityOptional" + }, + { + "$ref": + "#/$defs/DataContentEntityOptional" + }, + { + "$ref": + "#/$defs/TextContentEntityOptional" + }, + { + "$ref": + "#/$defs/ToolResponseContentEntityOptional" + }, + { + "$ref": + "#/$defs/ReasoningContentEntityOptional" }, { "type": "null" } ], "default": + null, "description": "Filter by message content", "title": "Content" }, + "streaming_status": { "anyOf": [ { "enum": [ "IN_PROGRESS", "DONE" ], "type": + "string" }, { "type": "null" } ], "default": null, "description": "Filter by + streaming status", "title": "Streaming Status" }, "exclude": { "default": false, + "description": "If true, this filter excludes matching messages", "title": + "Exclude", "type": "boolean" } }, "title": "TaskMessageEntityFilter", "type": + "object" } + + Each filter can include: + + - `content`: Filter by message content (type, author, data fields) + - `streaming_status`: Filter by status ("IN_PROGRESS" or "DONE") + - `exclude`: If true, excludes matching messages (default: false) + + Multiple filters are combined: inclusionary filters (exclude=false) are OR'd + together, exclusionary filters (exclude=true) are OR'd and negated, then both + groups are AND'd. + """ + + limit: int + + order_by: Optional[str] + + order_direction: str + + page_number: int diff --git a/src/agentex/types/message_list_response.py b/src/agentex/types/message_list_response.py new file mode 100644 index 000000000..37123d902 --- /dev/null +++ b/src/agentex/types/message_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .task_message import TaskMessage + +__all__ = ["MessageListResponse"] + +MessageListResponse: TypeAlias = List[TaskMessage] diff --git a/src/agentex/types/message_style.py b/src/agentex/types/message_style.py new file mode 100644 index 000000000..81520ffb5 --- /dev/null +++ b/src/agentex/types/message_style.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal, TypeAlias + +__all__ = ["MessageStyle"] + +MessageStyle: TypeAlias = Literal["static", "active"] diff --git a/src/agentex/types/message_update_params.py b/src/agentex/types/message_update_params.py new file mode 100644 index 000000000..ea3dbaf89 --- /dev/null +++ b/src/agentex/types/message_update_params.py @@ -0,0 +1,18 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import Literal, Required, TypedDict + +from .task_message_content_param import TaskMessageContentParam + +__all__ = ["MessageUpdateParams"] + + +class MessageUpdateParams(TypedDict, total=False): + content: Required[TaskMessageContentParam] + + task_id: Required[str] + + streaming_status: Optional[Literal["IN_PROGRESS", "DONE"]] diff --git a/src/agentex/types/messages/__init__.py b/src/agentex/types/messages/__init__.py new file mode 100644 index 000000000..00a2dde57 --- /dev/null +++ b/src/agentex/types/messages/__init__.py @@ -0,0 +1,8 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .batch_create_params import BatchCreateParams as BatchCreateParams +from .batch_update_params import BatchUpdateParams as BatchUpdateParams +from .batch_create_response import BatchCreateResponse as BatchCreateResponse +from .batch_update_response import BatchUpdateResponse as BatchUpdateResponse diff --git a/src/agentex/types/messages/batch_create_params.py b/src/agentex/types/messages/batch_create_params.py new file mode 100644 index 000000000..21aea4b2e --- /dev/null +++ b/src/agentex/types/messages/batch_create_params.py @@ -0,0 +1,26 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union, Iterable +from datetime import datetime +from typing_extensions import Required, Annotated, TypedDict + +from ..._utils import PropertyInfo +from ..task_message_content_param import TaskMessageContentParam + +__all__ = ["BatchCreateParams"] + + +class BatchCreateParams(TypedDict, total=False): + contents: Required[Iterable[TaskMessageContentParam]] + + task_id: Required[str] + + created_at: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] + """Optional base timestamp. + + Each message in the batch is stamped with base + i milliseconds to guarantee + unique, monotonic ordering. If omitted, the server stamps datetime.now(UTC) at + insert time. + """ diff --git a/src/agentex/types/messages/batch_create_response.py b/src/agentex/types/messages/batch_create_response.py new file mode 100644 index 000000000..d110e3e3e --- /dev/null +++ b/src/agentex/types/messages/batch_create_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from ..task_message import TaskMessage + +__all__ = ["BatchCreateResponse"] + +BatchCreateResponse: TypeAlias = List[TaskMessage] diff --git a/src/agentex/types/messages/batch_update_params.py b/src/agentex/types/messages/batch_update_params.py new file mode 100644 index 000000000..c25e46f6c --- /dev/null +++ b/src/agentex/types/messages/batch_update_params.py @@ -0,0 +1,16 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict +from typing_extensions import Required, TypedDict + +from ..task_message_content_param import TaskMessageContentParam + +__all__ = ["BatchUpdateParams"] + + +class BatchUpdateParams(TypedDict, total=False): + task_id: Required[str] + + updates: Required[Dict[str, TaskMessageContentParam]] diff --git a/src/agentex/types/messages/batch_update_response.py b/src/agentex/types/messages/batch_update_response.py new file mode 100644 index 000000000..12de86030 --- /dev/null +++ b/src/agentex/types/messages/batch_update_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from ..task_message import TaskMessage + +__all__ = ["BatchUpdateResponse"] + +BatchUpdateResponse: TypeAlias = List[TaskMessage] diff --git a/src/agentex/types/reasoning_content.py b/src/agentex/types/reasoning_content.py new file mode 100644 index 000000000..98b35aef9 --- /dev/null +++ b/src/agentex/types/reasoning_content.py @@ -0,0 +1,33 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional +from typing_extensions import Literal + +from .._models import BaseModel +from .message_style import MessageStyle +from .message_author import MessageAuthor + +__all__ = ["ReasoningContent"] + + +class ReasoningContent(BaseModel): + author: MessageAuthor + """ + The role of the messages author, in this case `system`, `user`, `assistant`, or + `tool`. + """ + + summary: List[str] + """A list of short reasoning summaries""" + + content: Optional[List[str]] = None + """The reasoning content or chain-of-thought text""" + + style: Optional[MessageStyle] = None + """The style of the message. + + This is used by the client to determine how to display the message. + """ + + type: Optional[Literal["reasoning"]] = None + """The type of the message, in this case `reasoning`.""" diff --git a/src/agentex/types/reasoning_content_delta.py b/src/agentex/types/reasoning_content_delta.py new file mode 100644 index 000000000..8a3e2a88a --- /dev/null +++ b/src/agentex/types/reasoning_content_delta.py @@ -0,0 +1,18 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["ReasoningContentDelta"] + + +class ReasoningContentDelta(BaseModel): + """Delta for reasoning content updates""" + + content_index: int + + content_delta: Optional[str] = None + + type: Optional[Literal["reasoning_content"]] = None diff --git a/src/agentex/types/reasoning_content_param.py b/src/agentex/types/reasoning_content_param.py new file mode 100644 index 000000000..7fe923e4b --- /dev/null +++ b/src/agentex/types/reasoning_content_param.py @@ -0,0 +1,35 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import Literal, Required, TypedDict + +from .._types import SequenceNotStr +from .message_style import MessageStyle +from .message_author import MessageAuthor + +__all__ = ["ReasoningContentParam"] + + +class ReasoningContentParam(TypedDict, total=False): + author: Required[MessageAuthor] + """ + The role of the messages author, in this case `system`, `user`, `assistant`, or + `tool`. + """ + + summary: Required[SequenceNotStr[str]] + """A list of short reasoning summaries""" + + content: Optional[SequenceNotStr[str]] + """The reasoning content or chain-of-thought text""" + + style: MessageStyle + """The style of the message. + + This is used by the client to determine how to display the message. + """ + + type: Literal["reasoning"] + """The type of the message, in this case `reasoning`.""" diff --git a/src/agentex/types/reasoning_summary_delta.py b/src/agentex/types/reasoning_summary_delta.py new file mode 100644 index 000000000..f12a12e3a --- /dev/null +++ b/src/agentex/types/reasoning_summary_delta.py @@ -0,0 +1,18 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["ReasoningSummaryDelta"] + + +class ReasoningSummaryDelta(BaseModel): + """Delta for reasoning summary updates""" + + summary_index: int + + summary_delta: Optional[str] = None + + type: Optional[Literal["reasoning_summary"]] = None diff --git a/src/agentex/types/shared/__init__.py b/src/agentex/types/shared/__init__.py new file mode 100644 index 000000000..b4526793f --- /dev/null +++ b/src/agentex/types/shared/__init__.py @@ -0,0 +1,3 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .delete_response import DeleteResponse as DeleteResponse diff --git a/src/agentex/types/shared/delete_response.py b/src/agentex/types/shared/delete_response.py new file mode 100644 index 000000000..7b72bdf52 --- /dev/null +++ b/src/agentex/types/shared/delete_response.py @@ -0,0 +1,11 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from ..._models import BaseModel + +__all__ = ["DeleteResponse"] + + +class DeleteResponse(BaseModel): + id: str + + message: str diff --git a/src/agentex/types/span.py b/src/agentex/types/span.py new file mode 100644 index 000000000..98793c03b --- /dev/null +++ b/src/agentex/types/span.py @@ -0,0 +1,39 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Union, Optional +from datetime import datetime + +from .._models import BaseModel + +__all__ = ["Span"] + + +class Span(BaseModel): + id: str + + name: str + """Name that describes what operation this span represents""" + + start_time: datetime + """The time the span started""" + + trace_id: str + """Unique identifier for the trace this span belongs to""" + + data: Union[Dict[str, object], List[Dict[str, object]], None] = None + """Any additional metadata or context for the span""" + + end_time: Optional[datetime] = None + """The time the span ended""" + + input: Union[Dict[str, object], List[Dict[str, object]], None] = None + """Input parameters or data for the operation""" + + output: Union[Dict[str, object], List[Dict[str, object]], None] = None + """Output data resulting from the operation""" + + parent_id: Optional[str] = None + """ID of the parent span if this is a child span in a trace""" + + task_id: Optional[str] = None + """ID of the task this span belongs to""" diff --git a/src/agentex/types/span_create_params.py b/src/agentex/types/span_create_params.py new file mode 100644 index 000000000..7debfc8d4 --- /dev/null +++ b/src/agentex/types/span_create_params.py @@ -0,0 +1,43 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Union, Iterable, Optional +from datetime import datetime +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["SpanCreateParams"] + + +class SpanCreateParams(TypedDict, total=False): + name: Required[str] + """Name that describes what operation this span represents""" + + start_time: Required[Annotated[Union[str, datetime], PropertyInfo(format="iso8601")]] + """The time the span started""" + + trace_id: Required[str] + """Unique identifier for the trace this span belongs to""" + + id: Optional[str] + """Unique identifier for the span. If not provided, an ID will be generated.""" + + data: Union[Dict[str, object], Iterable[Dict[str, object]], None] + """Any additional metadata or context for the span""" + + end_time: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] + """The time the span ended""" + + input: Union[Dict[str, object], Iterable[Dict[str, object]], None] + """Input parameters or data for the operation""" + + output: Union[Dict[str, object], Iterable[Dict[str, object]], None] + """Output data resulting from the operation""" + + parent_id: Optional[str] + """ID of the parent span if this is a child span in a trace""" + + task_id: Optional[str] + """ID of the task this span belongs to""" diff --git a/src/agentex/types/span_list_params.py b/src/agentex/types/span_list_params.py new file mode 100644 index 000000000..286c3d2bf --- /dev/null +++ b/src/agentex/types/span_list_params.py @@ -0,0 +1,22 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import TypedDict + +__all__ = ["SpanListParams"] + + +class SpanListParams(TypedDict, total=False): + limit: int + + order_by: Optional[str] + + order_direction: str + + page_number: int + + task_id: Optional[str] + + trace_id: Optional[str] diff --git a/src/agentex/types/span_list_response.py b/src/agentex/types/span_list_response.py new file mode 100644 index 000000000..123486650 --- /dev/null +++ b/src/agentex/types/span_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .span import Span + +__all__ = ["SpanListResponse"] + +SpanListResponse: TypeAlias = List[Span] diff --git a/src/agentex/types/span_update_params.py b/src/agentex/types/span_update_params.py new file mode 100644 index 000000000..fda32dbad --- /dev/null +++ b/src/agentex/types/span_update_params.py @@ -0,0 +1,40 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Union, Iterable, Optional +from datetime import datetime +from typing_extensions import Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["SpanUpdateParams"] + + +class SpanUpdateParams(TypedDict, total=False): + data: Union[Dict[str, object], Iterable[Dict[str, object]], None] + """Any additional metadata or context for the span""" + + end_time: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] + """The time the span ended""" + + input: Union[Dict[str, object], Iterable[Dict[str, object]], None] + """Input parameters or data for the operation""" + + name: Optional[str] + """Name that describes what operation this span represents""" + + output: Union[Dict[str, object], Iterable[Dict[str, object]], None] + """Output data resulting from the operation""" + + parent_id: Optional[str] + """ID of the parent span if this is a child span in a trace""" + + start_time: Annotated[Union[str, datetime, None], PropertyInfo(format="iso8601")] + """The time the span started""" + + task_id: Optional[str] + """ID of the task this span belongs to""" + + trace_id: Optional[str] + """Unique identifier for the trace this span belongs to""" diff --git a/src/agentex/types/state.py b/src/agentex/types/state.py new file mode 100644 index 000000000..f0d919b73 --- /dev/null +++ b/src/agentex/types/state.py @@ -0,0 +1,35 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from datetime import datetime + +from .._models import BaseModel + +__all__ = ["State"] + + +class State(BaseModel): + """Represents a state in the agent system. + + A state is associated uniquely with a task and an agent. + + This entity is used to store states in MongoDB, with each state + associated with a specific task and agent. The combination of task_id and agent_id is globally unique. + + The state is a dictionary of arbitrary data. + """ + + id: str + """The task state's unique id""" + + agent_id: str + + created_at: datetime + """The timestamp when the state was created""" + + state: Dict[str, object] + + task_id: str + + updated_at: Optional[datetime] = None + """The timestamp when the state was last updated""" diff --git a/src/agentex/types/state_create_params.py b/src/agentex/types/state_create_params.py new file mode 100644 index 000000000..b90c8fbbe --- /dev/null +++ b/src/agentex/types/state_create_params.py @@ -0,0 +1,16 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict +from typing_extensions import Required, TypedDict + +__all__ = ["StateCreateParams"] + + +class StateCreateParams(TypedDict, total=False): + agent_id: Required[str] + + state: Required[Dict[str, object]] + + task_id: Required[str] diff --git a/src/agentex/types/state_list_params.py b/src/agentex/types/state_list_params.py new file mode 100644 index 000000000..9cb7d3dc2 --- /dev/null +++ b/src/agentex/types/state_list_params.py @@ -0,0 +1,28 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import TypedDict + +__all__ = ["StateListParams"] + + +class StateListParams(TypedDict, total=False): + agent_id: Optional[str] + """Agent ID""" + + limit: int + """Limit""" + + order_by: Optional[str] + """Field to order by""" + + order_direction: str + """Order direction (asc or desc)""" + + page_number: int + """Page number""" + + task_id: Optional[str] + """Task ID""" diff --git a/src/agentex/types/state_list_response.py b/src/agentex/types/state_list_response.py new file mode 100644 index 000000000..2feefedd7 --- /dev/null +++ b/src/agentex/types/state_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .state import State + +__all__ = ["StateListResponse"] + +StateListResponse: TypeAlias = List[State] diff --git a/src/agentex/types/state_update_params.py b/src/agentex/types/state_update_params.py new file mode 100644 index 000000000..4a4d2d744 --- /dev/null +++ b/src/agentex/types/state_update_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict +from typing_extensions import Required, TypedDict + +__all__ = ["StateUpdateParams"] + + +class StateUpdateParams(TypedDict, total=False): + state: Required[Dict[str, object]] diff --git a/src/agentex/types/task.py b/src/agentex/types/task.py new file mode 100644 index 000000000..e4348b395 --- /dev/null +++ b/src/agentex/types/task.py @@ -0,0 +1,31 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from datetime import datetime +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["Task"] + + +class Task(BaseModel): + id: str + + cleaned_at: Optional[datetime] = None + + created_at: Optional[datetime] = None + + name: Optional[str] = None + + params: Optional[Dict[str, object]] = None + + status: Optional[ + Literal["CANCELED", "COMPLETED", "FAILED", "RUNNING", "INTERRUPTED", "TERMINATED", "TIMED_OUT", "DELETED"] + ] = None + + status_reason: Optional[str] = None + + task_metadata: Optional[Dict[str, object]] = None + + updated_at: Optional[datetime] = None diff --git a/src/agentex/types/task_cancel_params.py b/src/agentex/types/task_cancel_params.py new file mode 100644 index 000000000..c76e97aa9 --- /dev/null +++ b/src/agentex/types/task_cancel_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import TypedDict + +__all__ = ["TaskCancelParams"] + + +class TaskCancelParams(TypedDict, total=False): + reason: Optional[str] diff --git a/src/agentex/types/task_complete_params.py b/src/agentex/types/task_complete_params.py new file mode 100644 index 000000000..7e523041a --- /dev/null +++ b/src/agentex/types/task_complete_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import TypedDict + +__all__ = ["TaskCompleteParams"] + + +class TaskCompleteParams(TypedDict, total=False): + reason: Optional[str] diff --git a/src/agentex/types/task_fail_params.py b/src/agentex/types/task_fail_params.py new file mode 100644 index 000000000..ba32ad5fd --- /dev/null +++ b/src/agentex/types/task_fail_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import TypedDict + +__all__ = ["TaskFailParams"] + + +class TaskFailParams(TypedDict, total=False): + reason: Optional[str] diff --git a/src/agentex/types/task_interrupt_params.py b/src/agentex/types/task_interrupt_params.py new file mode 100644 index 000000000..5f0b1a358 --- /dev/null +++ b/src/agentex/types/task_interrupt_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import TypedDict + +__all__ = ["TaskInterruptParams"] + + +class TaskInterruptParams(TypedDict, total=False): + reason: Optional[str] diff --git a/src/agentex/types/task_list_params.py b/src/agentex/types/task_list_params.py new file mode 100644 index 000000000..e9796daed --- /dev/null +++ b/src/agentex/types/task_list_params.py @@ -0,0 +1,35 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List, Optional +from typing_extensions import Literal, TypedDict + +__all__ = ["TaskListParams"] + + +class TaskListParams(TypedDict, total=False): + agent_id: Optional[str] + + agent_name: Optional[str] + + limit: int + + order_by: Optional[str] + + order_direction: str + + page_number: int + + relationships: List[Literal["agents"]] + + status: Optional[ + Literal["CANCELED", "COMPLETED", "FAILED", "RUNNING", "INTERRUPTED", "TERMINATED", "TIMED_OUT", "DELETED"] + ] + """Filter tasks by status (e.g. RUNNING, COMPLETED).""" + + task_metadata: Optional[str] + """JSON-encoded object used to filter tasks via JSONB containment. + + Example: {"created_by_user_id": "abc-123"}. + """ diff --git a/src/agentex/types/task_list_response.py b/src/agentex/types/task_list_response.py new file mode 100644 index 000000000..8333ec893 --- /dev/null +++ b/src/agentex/types/task_list_response.py @@ -0,0 +1,42 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional +from datetime import datetime +from typing_extensions import Literal, TypeAlias + +from .agent import Agent +from .._models import BaseModel + +__all__ = ["TaskListResponse", "TaskListResponseItem"] + + +class TaskListResponseItem(BaseModel): + """Lean list-response shape. + + Omits `params` (the arbitrary create-time + payload, which can carry per-caller secrets and PII); fetch GET /tasks/{id} + for the full record. + """ + + id: str + + agents: Optional[List[Agent]] = None + + cleaned_at: Optional[datetime] = None + + created_at: Optional[datetime] = None + + name: Optional[str] = None + + status: Optional[ + Literal["CANCELED", "COMPLETED", "FAILED", "RUNNING", "INTERRUPTED", "TERMINATED", "TIMED_OUT", "DELETED"] + ] = None + + status_reason: Optional[str] = None + + task_metadata: Optional[Dict[str, object]] = None + + updated_at: Optional[datetime] = None + + +TaskListResponse: TypeAlias = List[TaskListResponseItem] diff --git a/src/agentex/types/task_message.py b/src/agentex/types/task_message.py new file mode 100644 index 000000000..1f78e9256 --- /dev/null +++ b/src/agentex/types/task_message.py @@ -0,0 +1,39 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime +from typing_extensions import Literal + +from .._models import BaseModel +from .task_message_content import TaskMessageContent + +__all__ = ["TaskMessage"] + + +class TaskMessage(BaseModel): + """Represents a message in the agent system. + + This entity is used to store messages in MongoDB, with each message + associated with a specific task. + """ + + content: TaskMessageContent + """The content of the message. + + This content is not OpenAI compatible. These are messages that are meant to be + displayed to the user. + """ + + task_id: str + """ID of the task this message belongs to""" + + id: Optional[str] = None + """The task message's unique id""" + + created_at: Optional[datetime] = None + """The timestamp when the message was created""" + + streaming_status: Optional[Literal["IN_PROGRESS", "DONE"]] = None + + updated_at: Optional[datetime] = None + """The timestamp when the message was last updated""" diff --git a/src/agentex/types/task_message_content.py b/src/agentex/types/task_message_content.py new file mode 100644 index 000000000..f180867b6 --- /dev/null +++ b/src/agentex/types/task_message_content.py @@ -0,0 +1,18 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Union +from typing_extensions import Annotated, TypeAlias + +from .._utils import PropertyInfo +from .data_content import DataContent +from .text_content import TextContent +from .reasoning_content import ReasoningContent +from .tool_request_content import ToolRequestContent +from .tool_response_content import ToolResponseContent + +__all__ = ["TaskMessageContent"] + +TaskMessageContent: TypeAlias = Annotated[ + Union[TextContent, ReasoningContent, DataContent, ToolRequestContent, ToolResponseContent], + PropertyInfo(discriminator="type"), +] diff --git a/src/agentex/types/task_message_content_param.py b/src/agentex/types/task_message_content_param.py new file mode 100644 index 000000000..6349e4e7b --- /dev/null +++ b/src/agentex/types/task_message_content_param.py @@ -0,0 +1,18 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from typing_extensions import TypeAlias + +from .data_content_param import DataContentParam +from .text_content_param import TextContentParam +from .reasoning_content_param import ReasoningContentParam +from .tool_request_content_param import ToolRequestContentParam +from .tool_response_content_param import ToolResponseContentParam + +__all__ = ["TaskMessageContentParam"] + +TaskMessageContentParam: TypeAlias = Union[ + TextContentParam, ReasoningContentParam, DataContentParam, ToolRequestContentParam, ToolResponseContentParam +] diff --git a/src/agentex/types/task_message_delta.py b/src/agentex/types/task_message_delta.py new file mode 100644 index 000000000..7fce76b7e --- /dev/null +++ b/src/agentex/types/task_message_delta.py @@ -0,0 +1,19 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Union +from typing_extensions import Annotated, TypeAlias + +from .._utils import PropertyInfo +from .data_delta import DataDelta +from .text_delta import TextDelta +from .tool_request_delta import ToolRequestDelta +from .tool_response_delta import ToolResponseDelta +from .reasoning_content_delta import ReasoningContentDelta +from .reasoning_summary_delta import ReasoningSummaryDelta + +__all__ = ["TaskMessageDelta"] + +TaskMessageDelta: TypeAlias = Annotated[ + Union[TextDelta, DataDelta, ToolRequestDelta, ToolResponseDelta, ReasoningSummaryDelta, ReasoningContentDelta], + PropertyInfo(discriminator="type"), +] diff --git a/src/agentex/types/task_message_update.py b/src/agentex/types/task_message_update.py new file mode 100644 index 000000000..05ad128c7 --- /dev/null +++ b/src/agentex/types/task_message_update.py @@ -0,0 +1,91 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Union, Optional +from typing_extensions import Literal, Annotated, TypeAlias + +from .._utils import PropertyInfo +from .._models import BaseModel +from .task_message import TaskMessage +from .task_message_delta import TaskMessageDelta +from .task_message_content import TaskMessageContent + +__all__ = [ + "TaskMessageUpdate", + "StreamTaskMessageStart", + "StreamTaskMessageDelta", + "StreamTaskMessageFull", + "StreamTaskMessageDone", +] + + +class StreamTaskMessageStart(BaseModel): + """Event for starting a streaming message""" + + content: TaskMessageContent + + index: Optional[int] = None + + parent_task_message: Optional[TaskMessage] = None + """Represents a message in the agent system. + + This entity is used to store messages in MongoDB, with each message associated + with a specific task. + """ + + type: Optional[Literal["start"]] = None + + +class StreamTaskMessageDelta(BaseModel): + """Event for streaming chunks of content""" + + delta: Optional[TaskMessageDelta] = None + """Delta for text updates""" + + index: Optional[int] = None + + parent_task_message: Optional[TaskMessage] = None + """Represents a message in the agent system. + + This entity is used to store messages in MongoDB, with each message associated + with a specific task. + """ + + type: Optional[Literal["delta"]] = None + + +class StreamTaskMessageFull(BaseModel): + """Event for streaming the full content""" + + content: TaskMessageContent + + index: Optional[int] = None + + parent_task_message: Optional[TaskMessage] = None + """Represents a message in the agent system. + + This entity is used to store messages in MongoDB, with each message associated + with a specific task. + """ + + type: Optional[Literal["full"]] = None + + +class StreamTaskMessageDone(BaseModel): + """Event for indicating the task is done""" + + index: Optional[int] = None + + parent_task_message: Optional[TaskMessage] = None + """Represents a message in the agent system. + + This entity is used to store messages in MongoDB, with each message associated + with a specific task. + """ + + type: Optional[Literal["done"]] = None + + +TaskMessageUpdate: TypeAlias = Annotated[ + Union[StreamTaskMessageStart, StreamTaskMessageDelta, StreamTaskMessageFull, StreamTaskMessageDone], + PropertyInfo(discriminator="type"), +] diff --git a/src/agentex/types/task_query_workflow_response.py b/src/agentex/types/task_query_workflow_response.py new file mode 100644 index 000000000..3f8ae2c69 --- /dev/null +++ b/src/agentex/types/task_query_workflow_response.py @@ -0,0 +1,8 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict +from typing_extensions import TypeAlias + +__all__ = ["TaskQueryWorkflowResponse"] + +TaskQueryWorkflowResponse: TypeAlias = Dict[str, object] diff --git a/src/agentex/types/task_retrieve_by_name_params.py b/src/agentex/types/task_retrieve_by_name_params.py new file mode 100644 index 000000000..98d039825 --- /dev/null +++ b/src/agentex/types/task_retrieve_by_name_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List +from typing_extensions import Literal, TypedDict + +__all__ = ["TaskRetrieveByNameParams"] + + +class TaskRetrieveByNameParams(TypedDict, total=False): + relationships: List[Literal["agents"]] diff --git a/src/agentex/types/task_retrieve_by_name_response.py b/src/agentex/types/task_retrieve_by_name_response.py new file mode 100644 index 000000000..800fead16 --- /dev/null +++ b/src/agentex/types/task_retrieve_by_name_response.py @@ -0,0 +1,36 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional +from datetime import datetime +from typing_extensions import Literal + +from .agent import Agent +from .._models import BaseModel + +__all__ = ["TaskRetrieveByNameResponse"] + + +class TaskRetrieveByNameResponse(BaseModel): + """Task response model with optional related data based on relationships""" + + id: str + + agents: Optional[List[Agent]] = None + + cleaned_at: Optional[datetime] = None + + created_at: Optional[datetime] = None + + name: Optional[str] = None + + params: Optional[Dict[str, object]] = None + + status: Optional[ + Literal["CANCELED", "COMPLETED", "FAILED", "RUNNING", "INTERRUPTED", "TERMINATED", "TIMED_OUT", "DELETED"] + ] = None + + status_reason: Optional[str] = None + + task_metadata: Optional[Dict[str, object]] = None + + updated_at: Optional[datetime] = None diff --git a/src/agentex/types/task_retrieve_params.py b/src/agentex/types/task_retrieve_params.py new file mode 100644 index 000000000..61748a1be --- /dev/null +++ b/src/agentex/types/task_retrieve_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List +from typing_extensions import Literal, TypedDict + +__all__ = ["TaskRetrieveParams"] + + +class TaskRetrieveParams(TypedDict, total=False): + relationships: List[Literal["agents"]] diff --git a/src/agentex/types/task_retrieve_response.py b/src/agentex/types/task_retrieve_response.py new file mode 100644 index 000000000..94939ded1 --- /dev/null +++ b/src/agentex/types/task_retrieve_response.py @@ -0,0 +1,36 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List, Optional +from datetime import datetime +from typing_extensions import Literal + +from .agent import Agent +from .._models import BaseModel + +__all__ = ["TaskRetrieveResponse"] + + +class TaskRetrieveResponse(BaseModel): + """Task response model with optional related data based on relationships""" + + id: str + + agents: Optional[List[Agent]] = None + + cleaned_at: Optional[datetime] = None + + created_at: Optional[datetime] = None + + name: Optional[str] = None + + params: Optional[Dict[str, object]] = None + + status: Optional[ + Literal["CANCELED", "COMPLETED", "FAILED", "RUNNING", "INTERRUPTED", "TERMINATED", "TIMED_OUT", "DELETED"] + ] = None + + status_reason: Optional[str] = None + + task_metadata: Optional[Dict[str, object]] = None + + updated_at: Optional[datetime] = None diff --git a/src/agentex/types/task_terminate_params.py b/src/agentex/types/task_terminate_params.py new file mode 100644 index 000000000..869e3d45c --- /dev/null +++ b/src/agentex/types/task_terminate_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import TypedDict + +__all__ = ["TaskTerminateParams"] + + +class TaskTerminateParams(TypedDict, total=False): + reason: Optional[str] diff --git a/src/agentex/types/task_timeout_params.py b/src/agentex/types/task_timeout_params.py new file mode 100644 index 000000000..03d5fa75c --- /dev/null +++ b/src/agentex/types/task_timeout_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import TypedDict + +__all__ = ["TaskTimeoutParams"] + + +class TaskTimeoutParams(TypedDict, total=False): + reason: Optional[str] diff --git a/src/agentex/types/task_update_by_id_params.py b/src/agentex/types/task_update_by_id_params.py new file mode 100644 index 000000000..8d6aa6516 --- /dev/null +++ b/src/agentex/types/task_update_by_id_params.py @@ -0,0 +1,14 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Optional +from typing_extensions import TypedDict + +__all__ = ["TaskUpdateByIDParams"] + + +class TaskUpdateByIDParams(TypedDict, total=False): + merge_params: Optional[Dict[str, object]] + + task_metadata: Optional[Dict[str, object]] diff --git a/src/agentex/types/task_update_by_name_params.py b/src/agentex/types/task_update_by_name_params.py new file mode 100644 index 000000000..20e1a624c --- /dev/null +++ b/src/agentex/types/task_update_by_name_params.py @@ -0,0 +1,14 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Optional +from typing_extensions import TypedDict + +__all__ = ["TaskUpdateByNameParams"] + + +class TaskUpdateByNameParams(TypedDict, total=False): + merge_params: Optional[Dict[str, object]] + + task_metadata: Optional[Dict[str, object]] diff --git a/src/agentex/types/text_content.py b/src/agentex/types/text_content.py new file mode 100644 index 000000000..8c8b77e8a --- /dev/null +++ b/src/agentex/types/text_content.py @@ -0,0 +1,56 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional +from typing_extensions import Literal + +from .._models import BaseModel +from .text_format import TextFormat +from .message_style import MessageStyle +from .message_author import MessageAuthor + +__all__ = ["TextContent", "Attachment"] + + +class Attachment(BaseModel): + """Represents a file attachment in messages.""" + + file_id: str + """The unique ID of the attached file""" + + name: str + """The name of the file""" + + size: int + """The size of the file in bytes""" + + type: str + """The MIME type or content type of the file""" + + +class TextContent(BaseModel): + author: MessageAuthor + """ + The role of the messages author, in this case `system`, `user`, `assistant`, or + `tool`. + """ + + content: str + """The contents of the text message.""" + + attachments: Optional[List[Attachment]] = None + """Optional list of file attachments with structured metadata.""" + + format: TextFormat = "plain" + """The format of the message. + + This is used by the client to determine how to display the message. + """ + + style: MessageStyle = "static" + """The style of the message. + + This is used by the client to determine how to display the message. + """ + + type: Literal["text"] = "text" + """The type of the message, in this case `text`.""" diff --git a/src/agentex/types/text_content_param.py b/src/agentex/types/text_content_param.py new file mode 100644 index 000000000..5415d9c41 --- /dev/null +++ b/src/agentex/types/text_content_param.py @@ -0,0 +1,57 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Iterable, Optional +from typing_extensions import Literal, Required, TypedDict + +from .text_format import TextFormat +from .message_style import MessageStyle +from .message_author import MessageAuthor + +__all__ = ["TextContentParam", "Attachment"] + + +class Attachment(TypedDict, total=False): + """Represents a file attachment in messages.""" + + file_id: Required[str] + """The unique ID of the attached file""" + + name: Required[str] + """The name of the file""" + + size: Required[int] + """The size of the file in bytes""" + + type: Required[str] + """The MIME type or content type of the file""" + + +class TextContentParam(TypedDict, total=False): + author: Required[MessageAuthor] + """ + The role of the messages author, in this case `system`, `user`, `assistant`, or + `tool`. + """ + + content: Required[str] + """The contents of the text message.""" + + attachments: Optional[Iterable[Attachment]] + """Optional list of file attachments with structured metadata.""" + + format: TextFormat + """The format of the message. + + This is used by the client to determine how to display the message. + """ + + style: MessageStyle + """The style of the message. + + This is used by the client to determine how to display the message. + """ + + type: Literal["text"] + """The type of the message, in this case `text`.""" diff --git a/src/agentex/types/text_delta.py b/src/agentex/types/text_delta.py new file mode 100644 index 000000000..def7cf91b --- /dev/null +++ b/src/agentex/types/text_delta.py @@ -0,0 +1,16 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["TextDelta"] + + +class TextDelta(BaseModel): + """Delta for text updates""" + + text_delta: Optional[str] = None + + type: Optional[Literal["text"]] = None diff --git a/src/agentex/types/text_format.py b/src/agentex/types/text_format.py new file mode 100644 index 000000000..dea8ca30c --- /dev/null +++ b/src/agentex/types/text_format.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing_extensions import Literal, TypeAlias + +__all__ = ["TextFormat"] + +TextFormat: TypeAlias = Literal["markdown", "plain", "code"] diff --git a/src/agentex/types/tool_request_content.py b/src/agentex/types/tool_request_content.py new file mode 100644 index 000000000..8282ac3b7 --- /dev/null +++ b/src/agentex/types/tool_request_content.py @@ -0,0 +1,36 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict +from typing_extensions import Literal + +from .._models import BaseModel +from .message_style import MessageStyle +from .message_author import MessageAuthor + +__all__ = ["ToolRequestContent"] + + +class ToolRequestContent(BaseModel): + arguments: Dict[str, object] + """The arguments to the tool.""" + + author: MessageAuthor + """ + The role of the messages author, in this case `system`, `user`, `assistant`, or + `tool`. + """ + + name: str + """The name of the tool that is being requested.""" + + tool_call_id: str + """The ID of the tool call that is being requested.""" + + style: MessageStyle = "static" + """The style of the message. + + This is used by the client to determine how to display the message. + """ + + type: Literal["tool_request"] = "tool_request" + """The type of the message, in this case `tool_request`.""" diff --git a/src/agentex/types/tool_request_content_param.py b/src/agentex/types/tool_request_content_param.py new file mode 100644 index 000000000..dc2fcb489 --- /dev/null +++ b/src/agentex/types/tool_request_content_param.py @@ -0,0 +1,37 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict +from typing_extensions import Literal, Required, TypedDict + +from .message_style import MessageStyle +from .message_author import MessageAuthor + +__all__ = ["ToolRequestContentParam"] + + +class ToolRequestContentParam(TypedDict, total=False): + arguments: Required[Dict[str, object]] + """The arguments to the tool.""" + + author: Required[MessageAuthor] + """ + The role of the messages author, in this case `system`, `user`, `assistant`, or + `tool`. + """ + + name: Required[str] + """The name of the tool that is being requested.""" + + tool_call_id: Required[str] + """The ID of the tool call that is being requested.""" + + style: MessageStyle + """The style of the message. + + This is used by the client to determine how to display the message. + """ + + type: Literal["tool_request"] + """The type of the message, in this case `tool_request`.""" diff --git a/src/agentex/types/tool_request_delta.py b/src/agentex/types/tool_request_delta.py new file mode 100644 index 000000000..888520f8a --- /dev/null +++ b/src/agentex/types/tool_request_delta.py @@ -0,0 +1,20 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["ToolRequestDelta"] + + +class ToolRequestDelta(BaseModel): + """Delta for tool request updates""" + + name: str + + tool_call_id: str + + arguments_delta: Optional[str] = None + + type: Optional[Literal["tool_request"]] = None diff --git a/src/agentex/types/tool_response_content.py b/src/agentex/types/tool_response_content.py new file mode 100644 index 000000000..c22829ac6 --- /dev/null +++ b/src/agentex/types/tool_response_content.py @@ -0,0 +1,42 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from typing_extensions import Literal + +from .._models import BaseModel +from .message_style import MessageStyle +from .message_author import MessageAuthor + +__all__ = ["ToolResponseContent"] + + +class ToolResponseContent(BaseModel): + author: MessageAuthor + """ + The role of the messages author, in this case `system`, `user`, `assistant`, or + `tool`. + """ + + content: object + """The result of the tool.""" + + name: str + """The name of the tool that is being responded to.""" + + tool_call_id: str + """The ID of the tool call that is being responded to.""" + + is_error: Optional[bool] = None + """Whether the tool call resulted in an error. + + `None` when the harness does not report a status. + """ + + style: MessageStyle = "static" + """The style of the message. + + This is used by the client to determine how to display the message. + """ + + type: Literal["tool_response"] = "tool_response" + """The type of the message, in this case `tool_response`.""" diff --git a/src/agentex/types/tool_response_content_param.py b/src/agentex/types/tool_response_content_param.py new file mode 100644 index 000000000..361b8ff40 --- /dev/null +++ b/src/agentex/types/tool_response_content_param.py @@ -0,0 +1,43 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import Literal, Required, TypedDict + +from .message_style import MessageStyle +from .message_author import MessageAuthor + +__all__ = ["ToolResponseContentParam"] + + +class ToolResponseContentParam(TypedDict, total=False): + author: Required[MessageAuthor] + """ + The role of the messages author, in this case `system`, `user`, `assistant`, or + `tool`. + """ + + content: Required[object] + """The result of the tool.""" + + name: Required[str] + """The name of the tool that is being responded to.""" + + tool_call_id: Required[str] + """The ID of the tool call that is being responded to.""" + + is_error: Optional[bool] + """Whether the tool call resulted in an error. + + `None` when the harness does not report a status. + """ + + style: MessageStyle + """The style of the message. + + This is used by the client to determine how to display the message. + """ + + type: Literal["tool_response"] + """The type of the message, in this case `tool_response`.""" diff --git a/src/agentex/types/tool_response_delta.py b/src/agentex/types/tool_response_delta.py new file mode 100644 index 000000000..8c34a16a3 --- /dev/null +++ b/src/agentex/types/tool_response_delta.py @@ -0,0 +1,20 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["ToolResponseDelta"] + + +class ToolResponseDelta(BaseModel): + """Delta for tool response updates""" + + name: str + + tool_call_id: str + + content_delta: Optional[str] = None + + type: Optional[Literal["tool_response"]] = None diff --git a/src/agentex/types/tracker_list_params.py b/src/agentex/types/tracker_list_params.py new file mode 100644 index 000000000..57234f4a5 --- /dev/null +++ b/src/agentex/types/tracker_list_params.py @@ -0,0 +1,28 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import TypedDict + +__all__ = ["TrackerListParams"] + + +class TrackerListParams(TypedDict, total=False): + agent_id: Optional[str] + """Agent ID""" + + limit: int + """Limit""" + + order_by: Optional[str] + """Field to order by""" + + order_direction: str + """Order direction (asc or desc)""" + + page_number: int + """Page number""" + + task_id: Optional[str] + """Task ID""" diff --git a/src/agentex/types/tracker_list_response.py b/src/agentex/types/tracker_list_response.py new file mode 100644 index 000000000..c52051381 --- /dev/null +++ b/src/agentex/types/tracker_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .agent_task_tracker import AgentTaskTracker + +__all__ = ["TrackerListResponse"] + +TrackerListResponse: TypeAlias = List[AgentTaskTracker] diff --git a/src/agentex/types/tracker_update_params.py b/src/agentex/types/tracker_update_params.py new file mode 100644 index 000000000..127dff4de --- /dev/null +++ b/src/agentex/types/tracker_update_params.py @@ -0,0 +1,19 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import TypedDict + +__all__ = ["TrackerUpdateParams"] + + +class TrackerUpdateParams(TypedDict, total=False): + last_processed_event_id: Optional[str] + """The most recent processed event ID (omit to leave unchanged)""" + + status: Optional[str] + """Processing status""" + + status_reason: Optional[str] + """Optional status reason""" diff --git a/src/agentex/types/webhook_create_webhook_trigger_params.py b/src/agentex/types/webhook_create_webhook_trigger_params.py new file mode 100644 index 000000000..f6a1358bc --- /dev/null +++ b/src/agentex/types/webhook_create_webhook_trigger_params.py @@ -0,0 +1,42 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import Literal, Required, TypedDict + +__all__ = ["WebhookCreateWebhookTriggerParams"] + + +class WebhookCreateWebhookTriggerParams(TypedDict, total=False): + agent_name: Required[str] + """The agent the webhook drives.""" + + forward_path: Required[str] + """Subpath the agent's own route handles, e.g. + + 'github-pr/'. Appended to /agents/forward/name/{agent_name}/ to form + the webhook URL. + """ + + name: Required[str] + """ + Signature-lookup key: the repo full_name (github) or api_app_id (slack) that the + forward ingress matches the incoming webhook against. + """ + + base_url: Optional[str] + """ + Optional public agentex base URL for the returned webhook_url; defaults to the + AGENTEX_PUBLIC_URL env var. + """ + + secret: Optional[str] + """Signing secret. + + For GitHub, omit to generate one, or provide an existing webhook secret. For + Slack, this is required and must be the Slack app's Signing Secret. + """ + + source: Literal["internal", "external", "github", "slack"] + """Webhook source whose signature is verified (github or slack).""" diff --git a/src/agentex/types/webhook_create_webhook_trigger_response.py b/src/agentex/types/webhook_create_webhook_trigger_response.py new file mode 100644 index 000000000..745ce68a1 --- /dev/null +++ b/src/agentex/types/webhook_create_webhook_trigger_response.py @@ -0,0 +1,31 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from typing_extensions import Literal + +from .._models import BaseModel + +__all__ = ["WebhookCreateWebhookTriggerResponse"] + + +class WebhookCreateWebhookTriggerResponse(BaseModel): + agent_name: str + """The agent the webhook drives.""" + + key_id: str + """The created agent API key id.""" + + name: str + """Signature-lookup key (repo full_name / api_app_id).""" + + secret: str + """The signing secret — shown once; paste into the source's webhook config.""" + + source: Literal["internal", "external", "github", "slack"] + """Webhook source (github or slack).""" + + webhook_path: str + """The forward path to POST webhooks to.""" + + webhook_url: Optional[str] = None + """Full webhook URL to paste into the source (None if no base URL configured).""" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..fd8019a9a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/__init__.py b/tests/api_resources/__init__.py new file mode 100644 index 000000000..fd8019a9a --- /dev/null +++ b/tests/api_resources/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/agents/__init__.py b/tests/api_resources/agents/__init__.py new file mode 100644 index 000000000..fd8019a9a --- /dev/null +++ b/tests/api_resources/agents/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/agents/test_deployments.py b/tests/api_resources/agents/test_deployments.py new file mode 100644 index 000000000..7bddf7c49 --- /dev/null +++ b/tests/api_resources/agents/test_deployments.py @@ -0,0 +1,726 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from agentex import Agentex, AsyncAgentex +from agentex.types import AgentRpcResponse +from agentex.types.agents import ( + DeploymentListResponse, + DeploymentCreateResponse, + DeploymentPromoteResponse, + DeploymentRetrieveResponse, +) +from agentex.types.shared import DeleteResponse + +from ...utils import assert_matches_type + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestDeployments: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create(self, client: Agentex) -> None: + deployment = client.agents.deployments.create( + agent_id="agent_id", + docker_image="docker_image", + ) + assert_matches_type(DeploymentCreateResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Agentex) -> None: + deployment = client.agents.deployments.create( + agent_id="agent_id", + docker_image="docker_image", + helm_release_name="helm_release_name", + registration_metadata={"foo": "bar"}, + sgp_deploy_id="sgp_deploy_id", + ) + assert_matches_type(DeploymentCreateResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Agentex) -> None: + response = client.agents.deployments.with_raw_response.create( + agent_id="agent_id", + docker_image="docker_image", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + deployment = response.parse() + assert_matches_type(DeploymentCreateResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Agentex) -> None: + with client.agents.deployments.with_streaming_response.create( + agent_id="agent_id", + docker_image="docker_image", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + deployment = response.parse() + assert_matches_type(DeploymentCreateResponse, deployment, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_create(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.deployments.with_raw_response.create( + agent_id="", + docker_image="docker_image", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve(self, client: Agentex) -> None: + deployment = client.agents.deployments.retrieve( + deployment_id="deployment_id", + agent_id="agent_id", + ) + assert_matches_type(DeploymentRetrieveResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve(self, client: Agentex) -> None: + response = client.agents.deployments.with_raw_response.retrieve( + deployment_id="deployment_id", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + deployment = response.parse() + assert_matches_type(DeploymentRetrieveResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve(self, client: Agentex) -> None: + with client.agents.deployments.with_streaming_response.retrieve( + deployment_id="deployment_id", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + deployment = response.parse() + assert_matches_type(DeploymentRetrieveResponse, deployment, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.deployments.with_raw_response.retrieve( + deployment_id="deployment_id", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `deployment_id` but received ''"): + client.agents.deployments.with_raw_response.retrieve( + deployment_id="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Agentex) -> None: + deployment = client.agents.deployments.list( + agent_id="agent_id", + ) + assert_matches_type(DeploymentListResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Agentex) -> None: + deployment = client.agents.deployments.list( + agent_id="agent_id", + limit=1, + order_by="order_by", + order_direction="order_direction", + page_number=1, + ) + assert_matches_type(DeploymentListResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Agentex) -> None: + response = client.agents.deployments.with_raw_response.list( + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + deployment = response.parse() + assert_matches_type(DeploymentListResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Agentex) -> None: + with client.agents.deployments.with_streaming_response.list( + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + deployment = response.parse() + assert_matches_type(DeploymentListResponse, deployment, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_list(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.deployments.with_raw_response.list( + agent_id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete(self, client: Agentex) -> None: + deployment = client.agents.deployments.delete( + deployment_id="deployment_id", + agent_id="agent_id", + ) + assert_matches_type(DeleteResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: Agentex) -> None: + response = client.agents.deployments.with_raw_response.delete( + deployment_id="deployment_id", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + deployment = response.parse() + assert_matches_type(DeleteResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: Agentex) -> None: + with client.agents.deployments.with_streaming_response.delete( + deployment_id="deployment_id", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + deployment = response.parse() + assert_matches_type(DeleteResponse, deployment, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_delete(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.deployments.with_raw_response.delete( + deployment_id="deployment_id", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `deployment_id` but received ''"): + client.agents.deployments.with_raw_response.delete( + deployment_id="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_preview_rpc(self, client: Agentex) -> None: + deployment = client.agents.deployments.preview_rpc( + deployment_id="deployment_id", + agent_id="agent_id", + method="event/send", + params={}, + ) + assert_matches_type(AgentRpcResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_preview_rpc_with_all_params(self, client: Agentex) -> None: + deployment = client.agents.deployments.preview_rpc( + deployment_id="deployment_id", + agent_id="agent_id", + method="event/send", + params={ + "name": "name", + "params": {"foo": "bar"}, + "task_metadata": {"foo": "bar"}, + }, + id=0, + jsonrpc="2.0", + ) + assert_matches_type(AgentRpcResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_preview_rpc(self, client: Agentex) -> None: + response = client.agents.deployments.with_raw_response.preview_rpc( + deployment_id="deployment_id", + agent_id="agent_id", + method="event/send", + params={}, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + deployment = response.parse() + assert_matches_type(AgentRpcResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_preview_rpc(self, client: Agentex) -> None: + with client.agents.deployments.with_streaming_response.preview_rpc( + deployment_id="deployment_id", + agent_id="agent_id", + method="event/send", + params={}, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + deployment = response.parse() + assert_matches_type(AgentRpcResponse, deployment, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_preview_rpc(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.deployments.with_raw_response.preview_rpc( + deployment_id="deployment_id", + agent_id="", + method="event/send", + params={}, + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `deployment_id` but received ''"): + client.agents.deployments.with_raw_response.preview_rpc( + deployment_id="", + agent_id="agent_id", + method="event/send", + params={}, + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_promote(self, client: Agentex) -> None: + deployment = client.agents.deployments.promote( + deployment_id="deployment_id", + agent_id="agent_id", + ) + assert_matches_type(DeploymentPromoteResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_promote(self, client: Agentex) -> None: + response = client.agents.deployments.with_raw_response.promote( + deployment_id="deployment_id", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + deployment = response.parse() + assert_matches_type(DeploymentPromoteResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_promote(self, client: Agentex) -> None: + with client.agents.deployments.with_streaming_response.promote( + deployment_id="deployment_id", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + deployment = response.parse() + assert_matches_type(DeploymentPromoteResponse, deployment, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_promote(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.deployments.with_raw_response.promote( + deployment_id="deployment_id", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `deployment_id` but received ''"): + client.agents.deployments.with_raw_response.promote( + deployment_id="", + agent_id="agent_id", + ) + + +class TestAsyncDeployments: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncAgentex) -> None: + deployment = await async_client.agents.deployments.create( + agent_id="agent_id", + docker_image="docker_image", + ) + assert_matches_type(DeploymentCreateResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncAgentex) -> None: + deployment = await async_client.agents.deployments.create( + agent_id="agent_id", + docker_image="docker_image", + helm_release_name="helm_release_name", + registration_metadata={"foo": "bar"}, + sgp_deploy_id="sgp_deploy_id", + ) + assert_matches_type(DeploymentCreateResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.deployments.with_raw_response.create( + agent_id="agent_id", + docker_image="docker_image", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + deployment = await response.parse() + assert_matches_type(DeploymentCreateResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.deployments.with_streaming_response.create( + agent_id="agent_id", + docker_image="docker_image", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + deployment = await response.parse() + assert_matches_type(DeploymentCreateResponse, deployment, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_create(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.deployments.with_raw_response.create( + agent_id="", + docker_image="docker_image", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve(self, async_client: AsyncAgentex) -> None: + deployment = await async_client.agents.deployments.retrieve( + deployment_id="deployment_id", + agent_id="agent_id", + ) + assert_matches_type(DeploymentRetrieveResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.deployments.with_raw_response.retrieve( + deployment_id="deployment_id", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + deployment = await response.parse() + assert_matches_type(DeploymentRetrieveResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.deployments.with_streaming_response.retrieve( + deployment_id="deployment_id", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + deployment = await response.parse() + assert_matches_type(DeploymentRetrieveResponse, deployment, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.deployments.with_raw_response.retrieve( + deployment_id="deployment_id", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `deployment_id` but received ''"): + await async_client.agents.deployments.with_raw_response.retrieve( + deployment_id="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncAgentex) -> None: + deployment = await async_client.agents.deployments.list( + agent_id="agent_id", + ) + assert_matches_type(DeploymentListResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncAgentex) -> None: + deployment = await async_client.agents.deployments.list( + agent_id="agent_id", + limit=1, + order_by="order_by", + order_direction="order_direction", + page_number=1, + ) + assert_matches_type(DeploymentListResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.deployments.with_raw_response.list( + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + deployment = await response.parse() + assert_matches_type(DeploymentListResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.deployments.with_streaming_response.list( + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + deployment = await response.parse() + assert_matches_type(DeploymentListResponse, deployment, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_list(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.deployments.with_raw_response.list( + agent_id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncAgentex) -> None: + deployment = await async_client.agents.deployments.delete( + deployment_id="deployment_id", + agent_id="agent_id", + ) + assert_matches_type(DeleteResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.deployments.with_raw_response.delete( + deployment_id="deployment_id", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + deployment = await response.parse() + assert_matches_type(DeleteResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.deployments.with_streaming_response.delete( + deployment_id="deployment_id", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + deployment = await response.parse() + assert_matches_type(DeleteResponse, deployment, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_delete(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.deployments.with_raw_response.delete( + deployment_id="deployment_id", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `deployment_id` but received ''"): + await async_client.agents.deployments.with_raw_response.delete( + deployment_id="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_preview_rpc(self, async_client: AsyncAgentex) -> None: + deployment = await async_client.agents.deployments.preview_rpc( + deployment_id="deployment_id", + agent_id="agent_id", + method="event/send", + params={}, + ) + assert_matches_type(AgentRpcResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_preview_rpc_with_all_params(self, async_client: AsyncAgentex) -> None: + deployment = await async_client.agents.deployments.preview_rpc( + deployment_id="deployment_id", + agent_id="agent_id", + method="event/send", + params={ + "name": "name", + "params": {"foo": "bar"}, + "task_metadata": {"foo": "bar"}, + }, + id=0, + jsonrpc="2.0", + ) + assert_matches_type(AgentRpcResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_preview_rpc(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.deployments.with_raw_response.preview_rpc( + deployment_id="deployment_id", + agent_id="agent_id", + method="event/send", + params={}, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + deployment = await response.parse() + assert_matches_type(AgentRpcResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_preview_rpc(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.deployments.with_streaming_response.preview_rpc( + deployment_id="deployment_id", + agent_id="agent_id", + method="event/send", + params={}, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + deployment = await response.parse() + assert_matches_type(AgentRpcResponse, deployment, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_preview_rpc(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.deployments.with_raw_response.preview_rpc( + deployment_id="deployment_id", + agent_id="", + method="event/send", + params={}, + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `deployment_id` but received ''"): + await async_client.agents.deployments.with_raw_response.preview_rpc( + deployment_id="", + agent_id="agent_id", + method="event/send", + params={}, + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_promote(self, async_client: AsyncAgentex) -> None: + deployment = await async_client.agents.deployments.promote( + deployment_id="deployment_id", + agent_id="agent_id", + ) + assert_matches_type(DeploymentPromoteResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_promote(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.deployments.with_raw_response.promote( + deployment_id="deployment_id", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + deployment = await response.parse() + assert_matches_type(DeploymentPromoteResponse, deployment, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_promote(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.deployments.with_streaming_response.promote( + deployment_id="deployment_id", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + deployment = await response.parse() + assert_matches_type(DeploymentPromoteResponse, deployment, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_promote(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.deployments.with_raw_response.promote( + deployment_id="deployment_id", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `deployment_id` but received ''"): + await async_client.agents.deployments.with_raw_response.promote( + deployment_id="", + agent_id="agent_id", + ) diff --git a/tests/api_resources/agents/test_schedules.py b/tests/api_resources/agents/test_schedules.py new file mode 100644 index 000000000..8b281ee31 --- /dev/null +++ b/tests/api_resources/agents/test_schedules.py @@ -0,0 +1,1944 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from agentex import Agentex, AsyncAgentex +from agentex._utils import parse_datetime +from agentex.types.agents import ( + ScheduleListResponse, + ScheduleSkipResponse, + SchedulePauseResponse, + ScheduleCreateResponse, + ScheduleResumeResponse, + ScheduleUnskipResponse, + ScheduleUpdateResponse, + ScheduleTriggerResponse, + ScheduleRetrieveResponse, + SchedulePauseByNameResponse, + ScheduleResumeByNameResponse, + ScheduleUpdateByNameResponse, + ScheduleTriggerByNameResponse, + ScheduleRetrieveByNameResponse, +) +from agentex.types.shared import DeleteResponse + +from ...utils import assert_matches_type + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestSchedules: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create(self, client: Agentex) -> None: + schedule = client.agents.schedules.create( + agent_id="agent_id", + initial_input={"content": "content"}, + name="name", + ) + assert_matches_type(ScheduleCreateResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Agentex) -> None: + schedule = client.agents.schedules.create( + agent_id="agent_id", + initial_input={ + "content": "content", + "author": "user", + "type": "text", + }, + name="name", + cron_expression="cron_expression", + description="description", + end_at=parse_datetime("2019-12-27T18:11:19.117Z"), + interval_seconds=1, + paused=True, + start_at=parse_datetime("2019-12-27T18:11:19.117Z"), + task_metadata={"foo": "bar"}, + task_params={"foo": "bar"}, + timezone="timezone", + ) + assert_matches_type(ScheduleCreateResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Agentex) -> None: + response = client.agents.schedules.with_raw_response.create( + agent_id="agent_id", + initial_input={"content": "content"}, + name="name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = response.parse() + assert_matches_type(ScheduleCreateResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Agentex) -> None: + with client.agents.schedules.with_streaming_response.create( + agent_id="agent_id", + initial_input={"content": "content"}, + name="name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = response.parse() + assert_matches_type(ScheduleCreateResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_create(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.schedules.with_raw_response.create( + agent_id="", + initial_input={"content": "content"}, + name="name", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve(self, client: Agentex) -> None: + schedule = client.agents.schedules.retrieve( + schedule_id="schedule_id", + agent_id="agent_id", + ) + assert_matches_type(ScheduleRetrieveResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve(self, client: Agentex) -> None: + response = client.agents.schedules.with_raw_response.retrieve( + schedule_id="schedule_id", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = response.parse() + assert_matches_type(ScheduleRetrieveResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve(self, client: Agentex) -> None: + with client.agents.schedules.with_streaming_response.retrieve( + schedule_id="schedule_id", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = response.parse() + assert_matches_type(ScheduleRetrieveResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.schedules.with_raw_response.retrieve( + schedule_id="schedule_id", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `schedule_id` but received ''"): + client.agents.schedules.with_raw_response.retrieve( + schedule_id="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update(self, client: Agentex) -> None: + schedule = client.agents.schedules.update( + schedule_id="schedule_id", + agent_id="agent_id", + ) + assert_matches_type(ScheduleUpdateResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update_with_all_params(self, client: Agentex) -> None: + schedule = client.agents.schedules.update( + schedule_id="schedule_id", + agent_id="agent_id", + cron_expression="cron_expression", + description="description", + end_at=parse_datetime("2019-12-27T18:11:19.117Z"), + initial_input={ + "content": "content", + "author": "user", + "type": "text", + }, + interval_seconds=1, + name="name", + paused=True, + start_at=parse_datetime("2019-12-27T18:11:19.117Z"), + task_metadata={"foo": "bar"}, + task_params={"foo": "bar"}, + timezone="timezone", + ) + assert_matches_type(ScheduleUpdateResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_update(self, client: Agentex) -> None: + response = client.agents.schedules.with_raw_response.update( + schedule_id="schedule_id", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = response.parse() + assert_matches_type(ScheduleUpdateResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_update(self, client: Agentex) -> None: + with client.agents.schedules.with_streaming_response.update( + schedule_id="schedule_id", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = response.parse() + assert_matches_type(ScheduleUpdateResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_update(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.schedules.with_raw_response.update( + schedule_id="schedule_id", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `schedule_id` but received ''"): + client.agents.schedules.with_raw_response.update( + schedule_id="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Agentex) -> None: + schedule = client.agents.schedules.list( + agent_id="agent_id", + ) + assert_matches_type(ScheduleListResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Agentex) -> None: + schedule = client.agents.schedules.list( + agent_id="agent_id", + include_live=True, + limit=1, + ) + assert_matches_type(ScheduleListResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Agentex) -> None: + response = client.agents.schedules.with_raw_response.list( + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = response.parse() + assert_matches_type(ScheduleListResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Agentex) -> None: + with client.agents.schedules.with_streaming_response.list( + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = response.parse() + assert_matches_type(ScheduleListResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_list(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.schedules.with_raw_response.list( + agent_id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete(self, client: Agentex) -> None: + schedule = client.agents.schedules.delete( + schedule_id="schedule_id", + agent_id="agent_id", + ) + assert_matches_type(DeleteResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: Agentex) -> None: + response = client.agents.schedules.with_raw_response.delete( + schedule_id="schedule_id", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = response.parse() + assert_matches_type(DeleteResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: Agentex) -> None: + with client.agents.schedules.with_streaming_response.delete( + schedule_id="schedule_id", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = response.parse() + assert_matches_type(DeleteResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_delete(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.schedules.with_raw_response.delete( + schedule_id="schedule_id", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `schedule_id` but received ''"): + client.agents.schedules.with_raw_response.delete( + schedule_id="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete_by_name(self, client: Agentex) -> None: + schedule = client.agents.schedules.delete_by_name( + name="name", + agent_id="agent_id", + ) + assert_matches_type(DeleteResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete_by_name(self, client: Agentex) -> None: + response = client.agents.schedules.with_raw_response.delete_by_name( + name="name", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = response.parse() + assert_matches_type(DeleteResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete_by_name(self, client: Agentex) -> None: + with client.agents.schedules.with_streaming_response.delete_by_name( + name="name", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = response.parse() + assert_matches_type(DeleteResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_delete_by_name(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.schedules.with_raw_response.delete_by_name( + name="name", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `name` but received ''"): + client.agents.schedules.with_raw_response.delete_by_name( + name="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_pause(self, client: Agentex) -> None: + schedule = client.agents.schedules.pause( + schedule_id="schedule_id", + agent_id="agent_id", + ) + assert_matches_type(SchedulePauseResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_pause_with_all_params(self, client: Agentex) -> None: + schedule = client.agents.schedules.pause( + schedule_id="schedule_id", + agent_id="agent_id", + note="note", + ) + assert_matches_type(SchedulePauseResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_pause(self, client: Agentex) -> None: + response = client.agents.schedules.with_raw_response.pause( + schedule_id="schedule_id", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = response.parse() + assert_matches_type(SchedulePauseResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_pause(self, client: Agentex) -> None: + with client.agents.schedules.with_streaming_response.pause( + schedule_id="schedule_id", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = response.parse() + assert_matches_type(SchedulePauseResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_pause(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.schedules.with_raw_response.pause( + schedule_id="schedule_id", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `schedule_id` but received ''"): + client.agents.schedules.with_raw_response.pause( + schedule_id="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_pause_by_name(self, client: Agentex) -> None: + schedule = client.agents.schedules.pause_by_name( + name="name", + agent_id="agent_id", + ) + assert_matches_type(SchedulePauseByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_pause_by_name_with_all_params(self, client: Agentex) -> None: + schedule = client.agents.schedules.pause_by_name( + name="name", + agent_id="agent_id", + note="note", + ) + assert_matches_type(SchedulePauseByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_pause_by_name(self, client: Agentex) -> None: + response = client.agents.schedules.with_raw_response.pause_by_name( + name="name", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = response.parse() + assert_matches_type(SchedulePauseByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_pause_by_name(self, client: Agentex) -> None: + with client.agents.schedules.with_streaming_response.pause_by_name( + name="name", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = response.parse() + assert_matches_type(SchedulePauseByNameResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_pause_by_name(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.schedules.with_raw_response.pause_by_name( + name="name", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `name` but received ''"): + client.agents.schedules.with_raw_response.pause_by_name( + name="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_resume(self, client: Agentex) -> None: + schedule = client.agents.schedules.resume( + schedule_id="schedule_id", + agent_id="agent_id", + ) + assert_matches_type(ScheduleResumeResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_resume_with_all_params(self, client: Agentex) -> None: + schedule = client.agents.schedules.resume( + schedule_id="schedule_id", + agent_id="agent_id", + note="note", + ) + assert_matches_type(ScheduleResumeResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_resume(self, client: Agentex) -> None: + response = client.agents.schedules.with_raw_response.resume( + schedule_id="schedule_id", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = response.parse() + assert_matches_type(ScheduleResumeResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_resume(self, client: Agentex) -> None: + with client.agents.schedules.with_streaming_response.resume( + schedule_id="schedule_id", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = response.parse() + assert_matches_type(ScheduleResumeResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_resume(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.schedules.with_raw_response.resume( + schedule_id="schedule_id", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `schedule_id` but received ''"): + client.agents.schedules.with_raw_response.resume( + schedule_id="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_resume_by_name(self, client: Agentex) -> None: + schedule = client.agents.schedules.resume_by_name( + name="name", + agent_id="agent_id", + ) + assert_matches_type(ScheduleResumeByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_resume_by_name_with_all_params(self, client: Agentex) -> None: + schedule = client.agents.schedules.resume_by_name( + name="name", + agent_id="agent_id", + note="note", + ) + assert_matches_type(ScheduleResumeByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_resume_by_name(self, client: Agentex) -> None: + response = client.agents.schedules.with_raw_response.resume_by_name( + name="name", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = response.parse() + assert_matches_type(ScheduleResumeByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_resume_by_name(self, client: Agentex) -> None: + with client.agents.schedules.with_streaming_response.resume_by_name( + name="name", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = response.parse() + assert_matches_type(ScheduleResumeByNameResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_resume_by_name(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.schedules.with_raw_response.resume_by_name( + name="name", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `name` but received ''"): + client.agents.schedules.with_raw_response.resume_by_name( + name="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve_by_name(self, client: Agentex) -> None: + schedule = client.agents.schedules.retrieve_by_name( + name="name", + agent_id="agent_id", + ) + assert_matches_type(ScheduleRetrieveByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve_by_name(self, client: Agentex) -> None: + response = client.agents.schedules.with_raw_response.retrieve_by_name( + name="name", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = response.parse() + assert_matches_type(ScheduleRetrieveByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve_by_name(self, client: Agentex) -> None: + with client.agents.schedules.with_streaming_response.retrieve_by_name( + name="name", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = response.parse() + assert_matches_type(ScheduleRetrieveByNameResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve_by_name(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.schedules.with_raw_response.retrieve_by_name( + name="name", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `name` but received ''"): + client.agents.schedules.with_raw_response.retrieve_by_name( + name="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_skip(self, client: Agentex) -> None: + schedule = client.agents.schedules.skip( + schedule_id="schedule_id", + agent_id="agent_id", + scheduled_time=parse_datetime("2019-12-27T18:11:19.117Z"), + ) + assert_matches_type(ScheduleSkipResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_skip(self, client: Agentex) -> None: + response = client.agents.schedules.with_raw_response.skip( + schedule_id="schedule_id", + agent_id="agent_id", + scheduled_time=parse_datetime("2019-12-27T18:11:19.117Z"), + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = response.parse() + assert_matches_type(ScheduleSkipResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_skip(self, client: Agentex) -> None: + with client.agents.schedules.with_streaming_response.skip( + schedule_id="schedule_id", + agent_id="agent_id", + scheduled_time=parse_datetime("2019-12-27T18:11:19.117Z"), + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = response.parse() + assert_matches_type(ScheduleSkipResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_skip(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.schedules.with_raw_response.skip( + schedule_id="schedule_id", + agent_id="", + scheduled_time=parse_datetime("2019-12-27T18:11:19.117Z"), + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `schedule_id` but received ''"): + client.agents.schedules.with_raw_response.skip( + schedule_id="", + agent_id="agent_id", + scheduled_time=parse_datetime("2019-12-27T18:11:19.117Z"), + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_trigger(self, client: Agentex) -> None: + schedule = client.agents.schedules.trigger( + schedule_id="schedule_id", + agent_id="agent_id", + ) + assert_matches_type(ScheduleTriggerResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_trigger(self, client: Agentex) -> None: + response = client.agents.schedules.with_raw_response.trigger( + schedule_id="schedule_id", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = response.parse() + assert_matches_type(ScheduleTriggerResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_trigger(self, client: Agentex) -> None: + with client.agents.schedules.with_streaming_response.trigger( + schedule_id="schedule_id", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = response.parse() + assert_matches_type(ScheduleTriggerResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_trigger(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.schedules.with_raw_response.trigger( + schedule_id="schedule_id", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `schedule_id` but received ''"): + client.agents.schedules.with_raw_response.trigger( + schedule_id="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_trigger_by_name(self, client: Agentex) -> None: + schedule = client.agents.schedules.trigger_by_name( + name="name", + agent_id="agent_id", + ) + assert_matches_type(ScheduleTriggerByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_trigger_by_name(self, client: Agentex) -> None: + response = client.agents.schedules.with_raw_response.trigger_by_name( + name="name", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = response.parse() + assert_matches_type(ScheduleTriggerByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_trigger_by_name(self, client: Agentex) -> None: + with client.agents.schedules.with_streaming_response.trigger_by_name( + name="name", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = response.parse() + assert_matches_type(ScheduleTriggerByNameResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_trigger_by_name(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.schedules.with_raw_response.trigger_by_name( + name="name", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `name` but received ''"): + client.agents.schedules.with_raw_response.trigger_by_name( + name="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_unskip(self, client: Agentex) -> None: + schedule = client.agents.schedules.unskip( + schedule_id="schedule_id", + agent_id="agent_id", + scheduled_time=parse_datetime("2019-12-27T18:11:19.117Z"), + ) + assert_matches_type(ScheduleUnskipResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_unskip(self, client: Agentex) -> None: + response = client.agents.schedules.with_raw_response.unskip( + schedule_id="schedule_id", + agent_id="agent_id", + scheduled_time=parse_datetime("2019-12-27T18:11:19.117Z"), + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = response.parse() + assert_matches_type(ScheduleUnskipResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_unskip(self, client: Agentex) -> None: + with client.agents.schedules.with_streaming_response.unskip( + schedule_id="schedule_id", + agent_id="agent_id", + scheduled_time=parse_datetime("2019-12-27T18:11:19.117Z"), + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = response.parse() + assert_matches_type(ScheduleUnskipResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_unskip(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.schedules.with_raw_response.unskip( + schedule_id="schedule_id", + agent_id="", + scheduled_time=parse_datetime("2019-12-27T18:11:19.117Z"), + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `schedule_id` but received ''"): + client.agents.schedules.with_raw_response.unskip( + schedule_id="", + agent_id="agent_id", + scheduled_time=parse_datetime("2019-12-27T18:11:19.117Z"), + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update_by_name(self, client: Agentex) -> None: + schedule = client.agents.schedules.update_by_name( + path_name="name", + agent_id="agent_id", + ) + assert_matches_type(ScheduleUpdateByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update_by_name_with_all_params(self, client: Agentex) -> None: + schedule = client.agents.schedules.update_by_name( + path_name="name", + agent_id="agent_id", + cron_expression="cron_expression", + description="description", + end_at=parse_datetime("2019-12-27T18:11:19.117Z"), + initial_input={ + "content": "content", + "author": "user", + "type": "text", + }, + interval_seconds=1, + body_name="name", + paused=True, + start_at=parse_datetime("2019-12-27T18:11:19.117Z"), + task_metadata={"foo": "bar"}, + task_params={"foo": "bar"}, + timezone="timezone", + ) + assert_matches_type(ScheduleUpdateByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_update_by_name(self, client: Agentex) -> None: + response = client.agents.schedules.with_raw_response.update_by_name( + path_name="name", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = response.parse() + assert_matches_type(ScheduleUpdateByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_update_by_name(self, client: Agentex) -> None: + with client.agents.schedules.with_streaming_response.update_by_name( + path_name="name", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = response.parse() + assert_matches_type(ScheduleUpdateByNameResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_update_by_name(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.schedules.with_raw_response.update_by_name( + path_name="name", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `path_name` but received ''"): + client.agents.schedules.with_raw_response.update_by_name( + path_name="", + agent_id="agent_id", + ) + + +class TestAsyncSchedules: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncAgentex) -> None: + schedule = await async_client.agents.schedules.create( + agent_id="agent_id", + initial_input={"content": "content"}, + name="name", + ) + assert_matches_type(ScheduleCreateResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncAgentex) -> None: + schedule = await async_client.agents.schedules.create( + agent_id="agent_id", + initial_input={ + "content": "content", + "author": "user", + "type": "text", + }, + name="name", + cron_expression="cron_expression", + description="description", + end_at=parse_datetime("2019-12-27T18:11:19.117Z"), + interval_seconds=1, + paused=True, + start_at=parse_datetime("2019-12-27T18:11:19.117Z"), + task_metadata={"foo": "bar"}, + task_params={"foo": "bar"}, + timezone="timezone", + ) + assert_matches_type(ScheduleCreateResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.schedules.with_raw_response.create( + agent_id="agent_id", + initial_input={"content": "content"}, + name="name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = await response.parse() + assert_matches_type(ScheduleCreateResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.schedules.with_streaming_response.create( + agent_id="agent_id", + initial_input={"content": "content"}, + name="name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = await response.parse() + assert_matches_type(ScheduleCreateResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_create(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.schedules.with_raw_response.create( + agent_id="", + initial_input={"content": "content"}, + name="name", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve(self, async_client: AsyncAgentex) -> None: + schedule = await async_client.agents.schedules.retrieve( + schedule_id="schedule_id", + agent_id="agent_id", + ) + assert_matches_type(ScheduleRetrieveResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.schedules.with_raw_response.retrieve( + schedule_id="schedule_id", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = await response.parse() + assert_matches_type(ScheduleRetrieveResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.schedules.with_streaming_response.retrieve( + schedule_id="schedule_id", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = await response.parse() + assert_matches_type(ScheduleRetrieveResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.schedules.with_raw_response.retrieve( + schedule_id="schedule_id", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `schedule_id` but received ''"): + await async_client.agents.schedules.with_raw_response.retrieve( + schedule_id="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update(self, async_client: AsyncAgentex) -> None: + schedule = await async_client.agents.schedules.update( + schedule_id="schedule_id", + agent_id="agent_id", + ) + assert_matches_type(ScheduleUpdateResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update_with_all_params(self, async_client: AsyncAgentex) -> None: + schedule = await async_client.agents.schedules.update( + schedule_id="schedule_id", + agent_id="agent_id", + cron_expression="cron_expression", + description="description", + end_at=parse_datetime("2019-12-27T18:11:19.117Z"), + initial_input={ + "content": "content", + "author": "user", + "type": "text", + }, + interval_seconds=1, + name="name", + paused=True, + start_at=parse_datetime("2019-12-27T18:11:19.117Z"), + task_metadata={"foo": "bar"}, + task_params={"foo": "bar"}, + timezone="timezone", + ) + assert_matches_type(ScheduleUpdateResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_update(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.schedules.with_raw_response.update( + schedule_id="schedule_id", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = await response.parse() + assert_matches_type(ScheduleUpdateResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_update(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.schedules.with_streaming_response.update( + schedule_id="schedule_id", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = await response.parse() + assert_matches_type(ScheduleUpdateResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_update(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.schedules.with_raw_response.update( + schedule_id="schedule_id", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `schedule_id` but received ''"): + await async_client.agents.schedules.with_raw_response.update( + schedule_id="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncAgentex) -> None: + schedule = await async_client.agents.schedules.list( + agent_id="agent_id", + ) + assert_matches_type(ScheduleListResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncAgentex) -> None: + schedule = await async_client.agents.schedules.list( + agent_id="agent_id", + include_live=True, + limit=1, + ) + assert_matches_type(ScheduleListResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.schedules.with_raw_response.list( + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = await response.parse() + assert_matches_type(ScheduleListResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.schedules.with_streaming_response.list( + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = await response.parse() + assert_matches_type(ScheduleListResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_list(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.schedules.with_raw_response.list( + agent_id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncAgentex) -> None: + schedule = await async_client.agents.schedules.delete( + schedule_id="schedule_id", + agent_id="agent_id", + ) + assert_matches_type(DeleteResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.schedules.with_raw_response.delete( + schedule_id="schedule_id", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = await response.parse() + assert_matches_type(DeleteResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.schedules.with_streaming_response.delete( + schedule_id="schedule_id", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = await response.parse() + assert_matches_type(DeleteResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_delete(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.schedules.with_raw_response.delete( + schedule_id="schedule_id", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `schedule_id` but received ''"): + await async_client.agents.schedules.with_raw_response.delete( + schedule_id="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete_by_name(self, async_client: AsyncAgentex) -> None: + schedule = await async_client.agents.schedules.delete_by_name( + name="name", + agent_id="agent_id", + ) + assert_matches_type(DeleteResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete_by_name(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.schedules.with_raw_response.delete_by_name( + name="name", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = await response.parse() + assert_matches_type(DeleteResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete_by_name(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.schedules.with_streaming_response.delete_by_name( + name="name", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = await response.parse() + assert_matches_type(DeleteResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_delete_by_name(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.schedules.with_raw_response.delete_by_name( + name="name", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `name` but received ''"): + await async_client.agents.schedules.with_raw_response.delete_by_name( + name="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_pause(self, async_client: AsyncAgentex) -> None: + schedule = await async_client.agents.schedules.pause( + schedule_id="schedule_id", + agent_id="agent_id", + ) + assert_matches_type(SchedulePauseResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_pause_with_all_params(self, async_client: AsyncAgentex) -> None: + schedule = await async_client.agents.schedules.pause( + schedule_id="schedule_id", + agent_id="agent_id", + note="note", + ) + assert_matches_type(SchedulePauseResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_pause(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.schedules.with_raw_response.pause( + schedule_id="schedule_id", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = await response.parse() + assert_matches_type(SchedulePauseResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_pause(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.schedules.with_streaming_response.pause( + schedule_id="schedule_id", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = await response.parse() + assert_matches_type(SchedulePauseResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_pause(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.schedules.with_raw_response.pause( + schedule_id="schedule_id", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `schedule_id` but received ''"): + await async_client.agents.schedules.with_raw_response.pause( + schedule_id="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_pause_by_name(self, async_client: AsyncAgentex) -> None: + schedule = await async_client.agents.schedules.pause_by_name( + name="name", + agent_id="agent_id", + ) + assert_matches_type(SchedulePauseByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_pause_by_name_with_all_params(self, async_client: AsyncAgentex) -> None: + schedule = await async_client.agents.schedules.pause_by_name( + name="name", + agent_id="agent_id", + note="note", + ) + assert_matches_type(SchedulePauseByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_pause_by_name(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.schedules.with_raw_response.pause_by_name( + name="name", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = await response.parse() + assert_matches_type(SchedulePauseByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_pause_by_name(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.schedules.with_streaming_response.pause_by_name( + name="name", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = await response.parse() + assert_matches_type(SchedulePauseByNameResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_pause_by_name(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.schedules.with_raw_response.pause_by_name( + name="name", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `name` but received ''"): + await async_client.agents.schedules.with_raw_response.pause_by_name( + name="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_resume(self, async_client: AsyncAgentex) -> None: + schedule = await async_client.agents.schedules.resume( + schedule_id="schedule_id", + agent_id="agent_id", + ) + assert_matches_type(ScheduleResumeResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_resume_with_all_params(self, async_client: AsyncAgentex) -> None: + schedule = await async_client.agents.schedules.resume( + schedule_id="schedule_id", + agent_id="agent_id", + note="note", + ) + assert_matches_type(ScheduleResumeResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_resume(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.schedules.with_raw_response.resume( + schedule_id="schedule_id", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = await response.parse() + assert_matches_type(ScheduleResumeResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_resume(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.schedules.with_streaming_response.resume( + schedule_id="schedule_id", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = await response.parse() + assert_matches_type(ScheduleResumeResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_resume(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.schedules.with_raw_response.resume( + schedule_id="schedule_id", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `schedule_id` but received ''"): + await async_client.agents.schedules.with_raw_response.resume( + schedule_id="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_resume_by_name(self, async_client: AsyncAgentex) -> None: + schedule = await async_client.agents.schedules.resume_by_name( + name="name", + agent_id="agent_id", + ) + assert_matches_type(ScheduleResumeByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_resume_by_name_with_all_params(self, async_client: AsyncAgentex) -> None: + schedule = await async_client.agents.schedules.resume_by_name( + name="name", + agent_id="agent_id", + note="note", + ) + assert_matches_type(ScheduleResumeByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_resume_by_name(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.schedules.with_raw_response.resume_by_name( + name="name", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = await response.parse() + assert_matches_type(ScheduleResumeByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_resume_by_name(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.schedules.with_streaming_response.resume_by_name( + name="name", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = await response.parse() + assert_matches_type(ScheduleResumeByNameResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_resume_by_name(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.schedules.with_raw_response.resume_by_name( + name="name", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `name` but received ''"): + await async_client.agents.schedules.with_raw_response.resume_by_name( + name="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve_by_name(self, async_client: AsyncAgentex) -> None: + schedule = await async_client.agents.schedules.retrieve_by_name( + name="name", + agent_id="agent_id", + ) + assert_matches_type(ScheduleRetrieveByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve_by_name(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.schedules.with_raw_response.retrieve_by_name( + name="name", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = await response.parse() + assert_matches_type(ScheduleRetrieveByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve_by_name(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.schedules.with_streaming_response.retrieve_by_name( + name="name", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = await response.parse() + assert_matches_type(ScheduleRetrieveByNameResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve_by_name(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.schedules.with_raw_response.retrieve_by_name( + name="name", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `name` but received ''"): + await async_client.agents.schedules.with_raw_response.retrieve_by_name( + name="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_skip(self, async_client: AsyncAgentex) -> None: + schedule = await async_client.agents.schedules.skip( + schedule_id="schedule_id", + agent_id="agent_id", + scheduled_time=parse_datetime("2019-12-27T18:11:19.117Z"), + ) + assert_matches_type(ScheduleSkipResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_skip(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.schedules.with_raw_response.skip( + schedule_id="schedule_id", + agent_id="agent_id", + scheduled_time=parse_datetime("2019-12-27T18:11:19.117Z"), + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = await response.parse() + assert_matches_type(ScheduleSkipResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_skip(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.schedules.with_streaming_response.skip( + schedule_id="schedule_id", + agent_id="agent_id", + scheduled_time=parse_datetime("2019-12-27T18:11:19.117Z"), + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = await response.parse() + assert_matches_type(ScheduleSkipResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_skip(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.schedules.with_raw_response.skip( + schedule_id="schedule_id", + agent_id="", + scheduled_time=parse_datetime("2019-12-27T18:11:19.117Z"), + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `schedule_id` but received ''"): + await async_client.agents.schedules.with_raw_response.skip( + schedule_id="", + agent_id="agent_id", + scheduled_time=parse_datetime("2019-12-27T18:11:19.117Z"), + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_trigger(self, async_client: AsyncAgentex) -> None: + schedule = await async_client.agents.schedules.trigger( + schedule_id="schedule_id", + agent_id="agent_id", + ) + assert_matches_type(ScheduleTriggerResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_trigger(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.schedules.with_raw_response.trigger( + schedule_id="schedule_id", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = await response.parse() + assert_matches_type(ScheduleTriggerResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_trigger(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.schedules.with_streaming_response.trigger( + schedule_id="schedule_id", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = await response.parse() + assert_matches_type(ScheduleTriggerResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_trigger(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.schedules.with_raw_response.trigger( + schedule_id="schedule_id", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `schedule_id` but received ''"): + await async_client.agents.schedules.with_raw_response.trigger( + schedule_id="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_trigger_by_name(self, async_client: AsyncAgentex) -> None: + schedule = await async_client.agents.schedules.trigger_by_name( + name="name", + agent_id="agent_id", + ) + assert_matches_type(ScheduleTriggerByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_trigger_by_name(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.schedules.with_raw_response.trigger_by_name( + name="name", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = await response.parse() + assert_matches_type(ScheduleTriggerByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_trigger_by_name(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.schedules.with_streaming_response.trigger_by_name( + name="name", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = await response.parse() + assert_matches_type(ScheduleTriggerByNameResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_trigger_by_name(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.schedules.with_raw_response.trigger_by_name( + name="name", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `name` but received ''"): + await async_client.agents.schedules.with_raw_response.trigger_by_name( + name="", + agent_id="agent_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_unskip(self, async_client: AsyncAgentex) -> None: + schedule = await async_client.agents.schedules.unskip( + schedule_id="schedule_id", + agent_id="agent_id", + scheduled_time=parse_datetime("2019-12-27T18:11:19.117Z"), + ) + assert_matches_type(ScheduleUnskipResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_unskip(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.schedules.with_raw_response.unskip( + schedule_id="schedule_id", + agent_id="agent_id", + scheduled_time=parse_datetime("2019-12-27T18:11:19.117Z"), + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = await response.parse() + assert_matches_type(ScheduleUnskipResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_unskip(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.schedules.with_streaming_response.unskip( + schedule_id="schedule_id", + agent_id="agent_id", + scheduled_time=parse_datetime("2019-12-27T18:11:19.117Z"), + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = await response.parse() + assert_matches_type(ScheduleUnskipResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_unskip(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.schedules.with_raw_response.unskip( + schedule_id="schedule_id", + agent_id="", + scheduled_time=parse_datetime("2019-12-27T18:11:19.117Z"), + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `schedule_id` but received ''"): + await async_client.agents.schedules.with_raw_response.unskip( + schedule_id="", + agent_id="agent_id", + scheduled_time=parse_datetime("2019-12-27T18:11:19.117Z"), + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update_by_name(self, async_client: AsyncAgentex) -> None: + schedule = await async_client.agents.schedules.update_by_name( + path_name="name", + agent_id="agent_id", + ) + assert_matches_type(ScheduleUpdateByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update_by_name_with_all_params(self, async_client: AsyncAgentex) -> None: + schedule = await async_client.agents.schedules.update_by_name( + path_name="name", + agent_id="agent_id", + cron_expression="cron_expression", + description="description", + end_at=parse_datetime("2019-12-27T18:11:19.117Z"), + initial_input={ + "content": "content", + "author": "user", + "type": "text", + }, + interval_seconds=1, + body_name="name", + paused=True, + start_at=parse_datetime("2019-12-27T18:11:19.117Z"), + task_metadata={"foo": "bar"}, + task_params={"foo": "bar"}, + timezone="timezone", + ) + assert_matches_type(ScheduleUpdateByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_update_by_name(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.schedules.with_raw_response.update_by_name( + path_name="name", + agent_id="agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + schedule = await response.parse() + assert_matches_type(ScheduleUpdateByNameResponse, schedule, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_update_by_name(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.schedules.with_streaming_response.update_by_name( + path_name="name", + agent_id="agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + schedule = await response.parse() + assert_matches_type(ScheduleUpdateByNameResponse, schedule, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_update_by_name(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.schedules.with_raw_response.update_by_name( + path_name="name", + agent_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `path_name` but received ''"): + await async_client.agents.schedules.with_raw_response.update_by_name( + path_name="", + agent_id="agent_id", + ) diff --git a/tests/api_resources/messages/__init__.py b/tests/api_resources/messages/__init__.py new file mode 100644 index 000000000..fd8019a9a --- /dev/null +++ b/tests/api_resources/messages/__init__.py @@ -0,0 +1 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/tests/api_resources/messages/test_batch.py b/tests/api_resources/messages/test_batch.py new file mode 100644 index 000000000..e2fd9cd44 --- /dev/null +++ b/tests/api_resources/messages/test_batch.py @@ -0,0 +1,298 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from agentex import Agentex, AsyncAgentex +from agentex._utils import parse_datetime +from agentex.types.messages import BatchCreateResponse, BatchUpdateResponse + +from ...utils import assert_matches_type + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestBatch: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create(self, client: Agentex) -> None: + batch = client.messages.batch.create( + contents=[ + { + "author": "user", + "content": "content", + "type": "text", + } + ], + task_id="task_id", + ) + assert_matches_type(BatchCreateResponse, batch, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Agentex) -> None: + batch = client.messages.batch.create( + contents=[ + { + "author": "user", + "content": "content", + "attachments": [ + { + "file_id": "file_id", + "name": "name", + "size": 0, + "type": "type", + } + ], + "format": "markdown", + "style": "static", + "type": "text", + } + ], + task_id="task_id", + created_at=parse_datetime("2019-12-27T18:11:19.117Z"), + ) + assert_matches_type(BatchCreateResponse, batch, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Agentex) -> None: + response = client.messages.batch.with_raw_response.create( + contents=[ + { + "author": "user", + "content": "content", + "type": "text", + } + ], + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + batch = response.parse() + assert_matches_type(BatchCreateResponse, batch, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Agentex) -> None: + with client.messages.batch.with_streaming_response.create( + contents=[ + { + "author": "user", + "content": "content", + "type": "text", + } + ], + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + batch = response.parse() + assert_matches_type(BatchCreateResponse, batch, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update(self, client: Agentex) -> None: + batch = client.messages.batch.update( + task_id="task_id", + updates={ + "foo": { + "author": "user", + "content": "content", + "type": "text", + } + }, + ) + assert_matches_type(BatchUpdateResponse, batch, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_update(self, client: Agentex) -> None: + response = client.messages.batch.with_raw_response.update( + task_id="task_id", + updates={ + "foo": { + "author": "user", + "content": "content", + "type": "text", + } + }, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + batch = response.parse() + assert_matches_type(BatchUpdateResponse, batch, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_update(self, client: Agentex) -> None: + with client.messages.batch.with_streaming_response.update( + task_id="task_id", + updates={ + "foo": { + "author": "user", + "content": "content", + "type": "text", + } + }, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + batch = response.parse() + assert_matches_type(BatchUpdateResponse, batch, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncBatch: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncAgentex) -> None: + batch = await async_client.messages.batch.create( + contents=[ + { + "author": "user", + "content": "content", + "type": "text", + } + ], + task_id="task_id", + ) + assert_matches_type(BatchCreateResponse, batch, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncAgentex) -> None: + batch = await async_client.messages.batch.create( + contents=[ + { + "author": "user", + "content": "content", + "attachments": [ + { + "file_id": "file_id", + "name": "name", + "size": 0, + "type": "type", + } + ], + "format": "markdown", + "style": "static", + "type": "text", + } + ], + task_id="task_id", + created_at=parse_datetime("2019-12-27T18:11:19.117Z"), + ) + assert_matches_type(BatchCreateResponse, batch, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncAgentex) -> None: + response = await async_client.messages.batch.with_raw_response.create( + contents=[ + { + "author": "user", + "content": "content", + "type": "text", + } + ], + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + batch = await response.parse() + assert_matches_type(BatchCreateResponse, batch, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncAgentex) -> None: + async with async_client.messages.batch.with_streaming_response.create( + contents=[ + { + "author": "user", + "content": "content", + "type": "text", + } + ], + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + batch = await response.parse() + assert_matches_type(BatchCreateResponse, batch, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update(self, async_client: AsyncAgentex) -> None: + batch = await async_client.messages.batch.update( + task_id="task_id", + updates={ + "foo": { + "author": "user", + "content": "content", + "type": "text", + } + }, + ) + assert_matches_type(BatchUpdateResponse, batch, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_update(self, async_client: AsyncAgentex) -> None: + response = await async_client.messages.batch.with_raw_response.update( + task_id="task_id", + updates={ + "foo": { + "author": "user", + "content": "content", + "type": "text", + } + }, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + batch = await response.parse() + assert_matches_type(BatchUpdateResponse, batch, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_update(self, async_client: AsyncAgentex) -> None: + async with async_client.messages.batch.with_streaming_response.update( + task_id="task_id", + updates={ + "foo": { + "author": "user", + "content": "content", + "type": "text", + } + }, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + batch = await response.parse() + assert_matches_type(BatchUpdateResponse, batch, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_agents.py b/tests/api_resources/test_agents.py new file mode 100644 index 000000000..4fb6ea4e2 --- /dev/null +++ b/tests/api_resources/test_agents.py @@ -0,0 +1,808 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from agentex import Agentex, AsyncAgentex +from agentex.types import ( + Agent, + AgentRpcResponse, + AgentListResponse, +) +from agentex.types.shared import DeleteResponse + +from ..utils import assert_matches_type + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestAgents: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve(self, client: Agentex) -> None: + agent = client.agents.retrieve( + "agent_id", + ) + assert_matches_type(Agent, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve(self, client: Agentex) -> None: + response = client.agents.with_raw_response.retrieve( + "agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = response.parse() + assert_matches_type(Agent, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve(self, client: Agentex) -> None: + with client.agents.with_streaming_response.retrieve( + "agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = response.parse() + assert_matches_type(Agent, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Agentex) -> None: + agent = client.agents.list() + assert_matches_type(AgentListResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Agentex) -> None: + agent = client.agents.list( + agent_card_metadata="agent_card_metadata", + limit=1, + order_by="order_by", + order_direction="order_direction", + page_number=1, + task_id="task_id", + ) + assert_matches_type(AgentListResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Agentex) -> None: + response = client.agents.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = response.parse() + assert_matches_type(AgentListResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Agentex) -> None: + with client.agents.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = response.parse() + assert_matches_type(AgentListResponse, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete(self, client: Agentex) -> None: + agent = client.agents.delete( + "agent_id", + ) + assert_matches_type(DeleteResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: Agentex) -> None: + response = client.agents.with_raw_response.delete( + "agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = response.parse() + assert_matches_type(DeleteResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: Agentex) -> None: + with client.agents.with_streaming_response.delete( + "agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = response.parse() + assert_matches_type(DeleteResponse, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_delete(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete_by_name(self, client: Agentex) -> None: + agent = client.agents.delete_by_name( + "agent_name", + ) + assert_matches_type(DeleteResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete_by_name(self, client: Agentex) -> None: + response = client.agents.with_raw_response.delete_by_name( + "agent_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = response.parse() + assert_matches_type(DeleteResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete_by_name(self, client: Agentex) -> None: + with client.agents.with_streaming_response.delete_by_name( + "agent_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = response.parse() + assert_matches_type(DeleteResponse, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_delete_by_name(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_name` but received ''"): + client.agents.with_raw_response.delete_by_name( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_register_build(self, client: Agentex) -> None: + agent = client.agents.register_build( + description="description", + name="name", + ) + assert_matches_type(Agent, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_register_build_with_all_params(self, client: Agentex) -> None: + agent = client.agents.register_build( + description="description", + name="name", + agent_input_type="text", + registration_metadata={"foo": "bar"}, + ) + assert_matches_type(Agent, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_register_build(self, client: Agentex) -> None: + response = client.agents.with_raw_response.register_build( + description="description", + name="name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = response.parse() + assert_matches_type(Agent, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_register_build(self, client: Agentex) -> None: + with client.agents.with_streaming_response.register_build( + description="description", + name="name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = response.parse() + assert_matches_type(Agent, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve_by_name(self, client: Agentex) -> None: + agent = client.agents.retrieve_by_name( + "agent_name", + ) + assert_matches_type(Agent, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve_by_name(self, client: Agentex) -> None: + response = client.agents.with_raw_response.retrieve_by_name( + "agent_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = response.parse() + assert_matches_type(Agent, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve_by_name(self, client: Agentex) -> None: + with client.agents.with_streaming_response.retrieve_by_name( + "agent_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = response.parse() + assert_matches_type(Agent, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve_by_name(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_name` but received ''"): + client.agents.with_raw_response.retrieve_by_name( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_rpc(self, client: Agentex) -> None: + agent = client.agents.rpc( + agent_id="agent_id", + method="event/send", + params={}, + ) + assert_matches_type(AgentRpcResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_rpc_with_all_params(self, client: Agentex) -> None: + agent = client.agents.rpc( + agent_id="agent_id", + method="event/send", + params={ + "name": "name", + "params": {"foo": "bar"}, + "task_metadata": {"foo": "bar"}, + }, + id=0, + jsonrpc="2.0", + ) + assert_matches_type(AgentRpcResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_rpc(self, client: Agentex) -> None: + response = client.agents.with_raw_response.rpc( + agent_id="agent_id", + method="event/send", + params={}, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = response.parse() + assert_matches_type(AgentRpcResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_rpc(self, client: Agentex) -> None: + with client.agents.with_streaming_response.rpc( + agent_id="agent_id", + method="event/send", + params={}, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = response.parse() + assert_matches_type(AgentRpcResponse, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_rpc(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + client.agents.with_raw_response.rpc( + agent_id="", + method="event/send", + params={}, + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_rpc_by_name(self, client: Agentex) -> None: + agent = client.agents.rpc_by_name( + agent_name="agent_name", + method="event/send", + params={}, + ) + assert_matches_type(AgentRpcResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_rpc_by_name_with_all_params(self, client: Agentex) -> None: + agent = client.agents.rpc_by_name( + agent_name="agent_name", + method="event/send", + params={ + "name": "name", + "params": {"foo": "bar"}, + "task_metadata": {"foo": "bar"}, + }, + id=0, + jsonrpc="2.0", + ) + assert_matches_type(AgentRpcResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_rpc_by_name(self, client: Agentex) -> None: + response = client.agents.with_raw_response.rpc_by_name( + agent_name="agent_name", + method="event/send", + params={}, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = response.parse() + assert_matches_type(AgentRpcResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_rpc_by_name(self, client: Agentex) -> None: + with client.agents.with_streaming_response.rpc_by_name( + agent_name="agent_name", + method="event/send", + params={}, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = response.parse() + assert_matches_type(AgentRpcResponse, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_rpc_by_name(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_name` but received ''"): + client.agents.with_raw_response.rpc_by_name( + agent_name="", + method="event/send", + params={}, + ) + + +class TestAsyncAgents: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve(self, async_client: AsyncAgentex) -> None: + agent = await async_client.agents.retrieve( + "agent_id", + ) + assert_matches_type(Agent, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.with_raw_response.retrieve( + "agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = await response.parse() + assert_matches_type(Agent, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.with_streaming_response.retrieve( + "agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = await response.parse() + assert_matches_type(Agent, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncAgentex) -> None: + agent = await async_client.agents.list() + assert_matches_type(AgentListResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncAgentex) -> None: + agent = await async_client.agents.list( + agent_card_metadata="agent_card_metadata", + limit=1, + order_by="order_by", + order_direction="order_direction", + page_number=1, + task_id="task_id", + ) + assert_matches_type(AgentListResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = await response.parse() + assert_matches_type(AgentListResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = await response.parse() + assert_matches_type(AgentListResponse, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncAgentex) -> None: + agent = await async_client.agents.delete( + "agent_id", + ) + assert_matches_type(DeleteResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.with_raw_response.delete( + "agent_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = await response.parse() + assert_matches_type(DeleteResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.with_streaming_response.delete( + "agent_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = await response.parse() + assert_matches_type(DeleteResponse, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_delete(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete_by_name(self, async_client: AsyncAgentex) -> None: + agent = await async_client.agents.delete_by_name( + "agent_name", + ) + assert_matches_type(DeleteResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete_by_name(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.with_raw_response.delete_by_name( + "agent_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = await response.parse() + assert_matches_type(DeleteResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete_by_name(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.with_streaming_response.delete_by_name( + "agent_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = await response.parse() + assert_matches_type(DeleteResponse, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_delete_by_name(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_name` but received ''"): + await async_client.agents.with_raw_response.delete_by_name( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_register_build(self, async_client: AsyncAgentex) -> None: + agent = await async_client.agents.register_build( + description="description", + name="name", + ) + assert_matches_type(Agent, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_register_build_with_all_params(self, async_client: AsyncAgentex) -> None: + agent = await async_client.agents.register_build( + description="description", + name="name", + agent_input_type="text", + registration_metadata={"foo": "bar"}, + ) + assert_matches_type(Agent, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_register_build(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.with_raw_response.register_build( + description="description", + name="name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = await response.parse() + assert_matches_type(Agent, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_register_build(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.with_streaming_response.register_build( + description="description", + name="name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = await response.parse() + assert_matches_type(Agent, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve_by_name(self, async_client: AsyncAgentex) -> None: + agent = await async_client.agents.retrieve_by_name( + "agent_name", + ) + assert_matches_type(Agent, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve_by_name(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.with_raw_response.retrieve_by_name( + "agent_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = await response.parse() + assert_matches_type(Agent, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve_by_name(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.with_streaming_response.retrieve_by_name( + "agent_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = await response.parse() + assert_matches_type(Agent, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve_by_name(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_name` but received ''"): + await async_client.agents.with_raw_response.retrieve_by_name( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_rpc(self, async_client: AsyncAgentex) -> None: + agent = await async_client.agents.rpc( + agent_id="agent_id", + method="event/send", + params={}, + ) + assert_matches_type(AgentRpcResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_rpc_with_all_params(self, async_client: AsyncAgentex) -> None: + agent = await async_client.agents.rpc( + agent_id="agent_id", + method="event/send", + params={ + "name": "name", + "params": {"foo": "bar"}, + "task_metadata": {"foo": "bar"}, + }, + id=0, + jsonrpc="2.0", + ) + assert_matches_type(AgentRpcResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_rpc(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.with_raw_response.rpc( + agent_id="agent_id", + method="event/send", + params={}, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = await response.parse() + assert_matches_type(AgentRpcResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_rpc(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.with_streaming_response.rpc( + agent_id="agent_id", + method="event/send", + params={}, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = await response.parse() + assert_matches_type(AgentRpcResponse, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_rpc(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_id` but received ''"): + await async_client.agents.with_raw_response.rpc( + agent_id="", + method="event/send", + params={}, + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_rpc_by_name(self, async_client: AsyncAgentex) -> None: + agent = await async_client.agents.rpc_by_name( + agent_name="agent_name", + method="event/send", + params={}, + ) + assert_matches_type(AgentRpcResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_rpc_by_name_with_all_params(self, async_client: AsyncAgentex) -> None: + agent = await async_client.agents.rpc_by_name( + agent_name="agent_name", + method="event/send", + params={ + "name": "name", + "params": {"foo": "bar"}, + "task_metadata": {"foo": "bar"}, + }, + id=0, + jsonrpc="2.0", + ) + assert_matches_type(AgentRpcResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_rpc_by_name(self, async_client: AsyncAgentex) -> None: + response = await async_client.agents.with_raw_response.rpc_by_name( + agent_name="agent_name", + method="event/send", + params={}, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = await response.parse() + assert_matches_type(AgentRpcResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_rpc_by_name(self, async_client: AsyncAgentex) -> None: + async with async_client.agents.with_streaming_response.rpc_by_name( + agent_name="agent_name", + method="event/send", + params={}, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = await response.parse() + assert_matches_type(AgentRpcResponse, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_rpc_by_name(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `agent_name` but received ''"): + await async_client.agents.with_raw_response.rpc_by_name( + agent_name="", + method="event/send", + params={}, + ) diff --git a/tests/api_resources/test_checkpoints.py b/tests/api_resources/test_checkpoints.py new file mode 100644 index 000000000..3c13fd43a --- /dev/null +++ b/tests/api_resources/test_checkpoints.py @@ -0,0 +1,563 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, Optional, cast + +import pytest + +from agentex import Agentex, AsyncAgentex +from agentex.types import ( + CheckpointPutResponse, + CheckpointListResponse, + CheckpointGetTupleResponse, +) + +from ..utils import assert_matches_type + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestCheckpoints: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Agentex) -> None: + checkpoint = client.checkpoints.list( + thread_id="thread_id", + ) + assert_matches_type(CheckpointListResponse, checkpoint, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Agentex) -> None: + checkpoint = client.checkpoints.list( + thread_id="thread_id", + before_checkpoint_id="before_checkpoint_id", + checkpoint_ns="checkpoint_ns", + filter_metadata={"foo": "bar"}, + limit=1, + ) + assert_matches_type(CheckpointListResponse, checkpoint, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Agentex) -> None: + response = client.checkpoints.with_raw_response.list( + thread_id="thread_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + checkpoint = response.parse() + assert_matches_type(CheckpointListResponse, checkpoint, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Agentex) -> None: + with client.checkpoints.with_streaming_response.list( + thread_id="thread_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + checkpoint = response.parse() + assert_matches_type(CheckpointListResponse, checkpoint, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete_thread(self, client: Agentex) -> None: + checkpoint = client.checkpoints.delete_thread( + thread_id="thread_id", + ) + assert checkpoint is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete_thread(self, client: Agentex) -> None: + response = client.checkpoints.with_raw_response.delete_thread( + thread_id="thread_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + checkpoint = response.parse() + assert checkpoint is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete_thread(self, client: Agentex) -> None: + with client.checkpoints.with_streaming_response.delete_thread( + thread_id="thread_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + checkpoint = response.parse() + assert checkpoint is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_get_tuple(self, client: Agentex) -> None: + checkpoint = client.checkpoints.get_tuple( + thread_id="thread_id", + ) + assert_matches_type(Optional[CheckpointGetTupleResponse], checkpoint, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_get_tuple_with_all_params(self, client: Agentex) -> None: + checkpoint = client.checkpoints.get_tuple( + thread_id="thread_id", + checkpoint_id="checkpoint_id", + checkpoint_ns="checkpoint_ns", + ) + assert_matches_type(Optional[CheckpointGetTupleResponse], checkpoint, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_get_tuple(self, client: Agentex) -> None: + response = client.checkpoints.with_raw_response.get_tuple( + thread_id="thread_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + checkpoint = response.parse() + assert_matches_type(Optional[CheckpointGetTupleResponse], checkpoint, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_get_tuple(self, client: Agentex) -> None: + with client.checkpoints.with_streaming_response.get_tuple( + thread_id="thread_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + checkpoint = response.parse() + assert_matches_type(Optional[CheckpointGetTupleResponse], checkpoint, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_put(self, client: Agentex) -> None: + checkpoint = client.checkpoints.put( + checkpoint={"foo": "bar"}, + checkpoint_id="checkpoint_id", + thread_id="thread_id", + ) + assert_matches_type(CheckpointPutResponse, checkpoint, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_put_with_all_params(self, client: Agentex) -> None: + checkpoint = client.checkpoints.put( + checkpoint={"foo": "bar"}, + checkpoint_id="checkpoint_id", + thread_id="thread_id", + blobs=[ + { + "channel": "channel", + "type": "type", + "version": "version", + "blob": "blob", + } + ], + checkpoint_ns="checkpoint_ns", + metadata={"foo": "bar"}, + parent_checkpoint_id="parent_checkpoint_id", + ) + assert_matches_type(CheckpointPutResponse, checkpoint, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_put(self, client: Agentex) -> None: + response = client.checkpoints.with_raw_response.put( + checkpoint={"foo": "bar"}, + checkpoint_id="checkpoint_id", + thread_id="thread_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + checkpoint = response.parse() + assert_matches_type(CheckpointPutResponse, checkpoint, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_put(self, client: Agentex) -> None: + with client.checkpoints.with_streaming_response.put( + checkpoint={"foo": "bar"}, + checkpoint_id="checkpoint_id", + thread_id="thread_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + checkpoint = response.parse() + assert_matches_type(CheckpointPutResponse, checkpoint, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_put_writes(self, client: Agentex) -> None: + checkpoint = client.checkpoints.put_writes( + checkpoint_id="checkpoint_id", + thread_id="thread_id", + writes=[ + { + "blob": "blob", + "channel": "channel", + "idx": 0, + "task_id": "task_id", + } + ], + ) + assert checkpoint is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_put_writes_with_all_params(self, client: Agentex) -> None: + checkpoint = client.checkpoints.put_writes( + checkpoint_id="checkpoint_id", + thread_id="thread_id", + writes=[ + { + "blob": "blob", + "channel": "channel", + "idx": 0, + "task_id": "task_id", + "task_path": "task_path", + "type": "type", + } + ], + checkpoint_ns="checkpoint_ns", + upsert=True, + ) + assert checkpoint is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_put_writes(self, client: Agentex) -> None: + response = client.checkpoints.with_raw_response.put_writes( + checkpoint_id="checkpoint_id", + thread_id="thread_id", + writes=[ + { + "blob": "blob", + "channel": "channel", + "idx": 0, + "task_id": "task_id", + } + ], + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + checkpoint = response.parse() + assert checkpoint is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_put_writes(self, client: Agentex) -> None: + with client.checkpoints.with_streaming_response.put_writes( + checkpoint_id="checkpoint_id", + thread_id="thread_id", + writes=[ + { + "blob": "blob", + "channel": "channel", + "idx": 0, + "task_id": "task_id", + } + ], + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + checkpoint = response.parse() + assert checkpoint is None + + assert cast(Any, response.is_closed) is True + + +class TestAsyncCheckpoints: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncAgentex) -> None: + checkpoint = await async_client.checkpoints.list( + thread_id="thread_id", + ) + assert_matches_type(CheckpointListResponse, checkpoint, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncAgentex) -> None: + checkpoint = await async_client.checkpoints.list( + thread_id="thread_id", + before_checkpoint_id="before_checkpoint_id", + checkpoint_ns="checkpoint_ns", + filter_metadata={"foo": "bar"}, + limit=1, + ) + assert_matches_type(CheckpointListResponse, checkpoint, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncAgentex) -> None: + response = await async_client.checkpoints.with_raw_response.list( + thread_id="thread_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + checkpoint = await response.parse() + assert_matches_type(CheckpointListResponse, checkpoint, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncAgentex) -> None: + async with async_client.checkpoints.with_streaming_response.list( + thread_id="thread_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + checkpoint = await response.parse() + assert_matches_type(CheckpointListResponse, checkpoint, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete_thread(self, async_client: AsyncAgentex) -> None: + checkpoint = await async_client.checkpoints.delete_thread( + thread_id="thread_id", + ) + assert checkpoint is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete_thread(self, async_client: AsyncAgentex) -> None: + response = await async_client.checkpoints.with_raw_response.delete_thread( + thread_id="thread_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + checkpoint = await response.parse() + assert checkpoint is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete_thread(self, async_client: AsyncAgentex) -> None: + async with async_client.checkpoints.with_streaming_response.delete_thread( + thread_id="thread_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + checkpoint = await response.parse() + assert checkpoint is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_get_tuple(self, async_client: AsyncAgentex) -> None: + checkpoint = await async_client.checkpoints.get_tuple( + thread_id="thread_id", + ) + assert_matches_type(Optional[CheckpointGetTupleResponse], checkpoint, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_get_tuple_with_all_params(self, async_client: AsyncAgentex) -> None: + checkpoint = await async_client.checkpoints.get_tuple( + thread_id="thread_id", + checkpoint_id="checkpoint_id", + checkpoint_ns="checkpoint_ns", + ) + assert_matches_type(Optional[CheckpointGetTupleResponse], checkpoint, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_get_tuple(self, async_client: AsyncAgentex) -> None: + response = await async_client.checkpoints.with_raw_response.get_tuple( + thread_id="thread_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + checkpoint = await response.parse() + assert_matches_type(Optional[CheckpointGetTupleResponse], checkpoint, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_get_tuple(self, async_client: AsyncAgentex) -> None: + async with async_client.checkpoints.with_streaming_response.get_tuple( + thread_id="thread_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + checkpoint = await response.parse() + assert_matches_type(Optional[CheckpointGetTupleResponse], checkpoint, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_put(self, async_client: AsyncAgentex) -> None: + checkpoint = await async_client.checkpoints.put( + checkpoint={"foo": "bar"}, + checkpoint_id="checkpoint_id", + thread_id="thread_id", + ) + assert_matches_type(CheckpointPutResponse, checkpoint, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_put_with_all_params(self, async_client: AsyncAgentex) -> None: + checkpoint = await async_client.checkpoints.put( + checkpoint={"foo": "bar"}, + checkpoint_id="checkpoint_id", + thread_id="thread_id", + blobs=[ + { + "channel": "channel", + "type": "type", + "version": "version", + "blob": "blob", + } + ], + checkpoint_ns="checkpoint_ns", + metadata={"foo": "bar"}, + parent_checkpoint_id="parent_checkpoint_id", + ) + assert_matches_type(CheckpointPutResponse, checkpoint, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_put(self, async_client: AsyncAgentex) -> None: + response = await async_client.checkpoints.with_raw_response.put( + checkpoint={"foo": "bar"}, + checkpoint_id="checkpoint_id", + thread_id="thread_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + checkpoint = await response.parse() + assert_matches_type(CheckpointPutResponse, checkpoint, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_put(self, async_client: AsyncAgentex) -> None: + async with async_client.checkpoints.with_streaming_response.put( + checkpoint={"foo": "bar"}, + checkpoint_id="checkpoint_id", + thread_id="thread_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + checkpoint = await response.parse() + assert_matches_type(CheckpointPutResponse, checkpoint, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_put_writes(self, async_client: AsyncAgentex) -> None: + checkpoint = await async_client.checkpoints.put_writes( + checkpoint_id="checkpoint_id", + thread_id="thread_id", + writes=[ + { + "blob": "blob", + "channel": "channel", + "idx": 0, + "task_id": "task_id", + } + ], + ) + assert checkpoint is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_put_writes_with_all_params(self, async_client: AsyncAgentex) -> None: + checkpoint = await async_client.checkpoints.put_writes( + checkpoint_id="checkpoint_id", + thread_id="thread_id", + writes=[ + { + "blob": "blob", + "channel": "channel", + "idx": 0, + "task_id": "task_id", + "task_path": "task_path", + "type": "type", + } + ], + checkpoint_ns="checkpoint_ns", + upsert=True, + ) + assert checkpoint is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_put_writes(self, async_client: AsyncAgentex) -> None: + response = await async_client.checkpoints.with_raw_response.put_writes( + checkpoint_id="checkpoint_id", + thread_id="thread_id", + writes=[ + { + "blob": "blob", + "channel": "channel", + "idx": 0, + "task_id": "task_id", + } + ], + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + checkpoint = await response.parse() + assert checkpoint is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_put_writes(self, async_client: AsyncAgentex) -> None: + async with async_client.checkpoints.with_streaming_response.put_writes( + checkpoint_id="checkpoint_id", + thread_id="thread_id", + writes=[ + { + "blob": "blob", + "channel": "channel", + "idx": 0, + "task_id": "task_id", + } + ], + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + checkpoint = await response.parse() + assert checkpoint is None + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_deployment_history.py b/tests/api_resources/test_deployment_history.py new file mode 100644 index 000000000..1b98042b7 --- /dev/null +++ b/tests/api_resources/test_deployment_history.py @@ -0,0 +1,191 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from agentex import Agentex, AsyncAgentex +from agentex.types import DeploymentHistory, DeploymentHistoryListResponse + +from ..utils import assert_matches_type + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestDeploymentHistory: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve(self, client: Agentex) -> None: + deployment_history = client.deployment_history.retrieve( + "deployment_id", + ) + assert_matches_type(DeploymentHistory, deployment_history, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve(self, client: Agentex) -> None: + response = client.deployment_history.with_raw_response.retrieve( + "deployment_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + deployment_history = response.parse() + assert_matches_type(DeploymentHistory, deployment_history, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve(self, client: Agentex) -> None: + with client.deployment_history.with_streaming_response.retrieve( + "deployment_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + deployment_history = response.parse() + assert_matches_type(DeploymentHistory, deployment_history, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `deployment_id` but received ''"): + client.deployment_history.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Agentex) -> None: + deployment_history = client.deployment_history.list() + assert_matches_type(DeploymentHistoryListResponse, deployment_history, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Agentex) -> None: + deployment_history = client.deployment_history.list( + agent_id="agent_id", + agent_name="agent_name", + limit=0, + order_by="order_by", + order_direction="order_direction", + page_number=0, + ) + assert_matches_type(DeploymentHistoryListResponse, deployment_history, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Agentex) -> None: + response = client.deployment_history.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + deployment_history = response.parse() + assert_matches_type(DeploymentHistoryListResponse, deployment_history, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Agentex) -> None: + with client.deployment_history.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + deployment_history = response.parse() + assert_matches_type(DeploymentHistoryListResponse, deployment_history, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncDeploymentHistory: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve(self, async_client: AsyncAgentex) -> None: + deployment_history = await async_client.deployment_history.retrieve( + "deployment_id", + ) + assert_matches_type(DeploymentHistory, deployment_history, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncAgentex) -> None: + response = await async_client.deployment_history.with_raw_response.retrieve( + "deployment_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + deployment_history = await response.parse() + assert_matches_type(DeploymentHistory, deployment_history, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncAgentex) -> None: + async with async_client.deployment_history.with_streaming_response.retrieve( + "deployment_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + deployment_history = await response.parse() + assert_matches_type(DeploymentHistory, deployment_history, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `deployment_id` but received ''"): + await async_client.deployment_history.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncAgentex) -> None: + deployment_history = await async_client.deployment_history.list() + assert_matches_type(DeploymentHistoryListResponse, deployment_history, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncAgentex) -> None: + deployment_history = await async_client.deployment_history.list( + agent_id="agent_id", + agent_name="agent_name", + limit=0, + order_by="order_by", + order_direction="order_direction", + page_number=0, + ) + assert_matches_type(DeploymentHistoryListResponse, deployment_history, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncAgentex) -> None: + response = await async_client.deployment_history.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + deployment_history = await response.parse() + assert_matches_type(DeploymentHistoryListResponse, deployment_history, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncAgentex) -> None: + async with async_client.deployment_history.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + deployment_history = await response.parse() + assert_matches_type(DeploymentHistoryListResponse, deployment_history, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_events.py b/tests/api_resources/test_events.py new file mode 100644 index 000000000..7a0805b52 --- /dev/null +++ b/tests/api_resources/test_events.py @@ -0,0 +1,205 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from agentex import Agentex, AsyncAgentex +from agentex.types import Event, EventListResponse + +from ..utils import assert_matches_type + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestEvents: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve(self, client: Agentex) -> None: + event = client.events.retrieve( + "event_id", + ) + assert_matches_type(Event, event, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve(self, client: Agentex) -> None: + response = client.events.with_raw_response.retrieve( + "event_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + event = response.parse() + assert_matches_type(Event, event, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve(self, client: Agentex) -> None: + with client.events.with_streaming_response.retrieve( + "event_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + event = response.parse() + assert_matches_type(Event, event, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `event_id` but received ''"): + client.events.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Agentex) -> None: + event = client.events.list( + agent_id="agent_id", + task_id="task_id", + ) + assert_matches_type(EventListResponse, event, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Agentex) -> None: + event = client.events.list( + agent_id="agent_id", + task_id="task_id", + last_processed_event_id="last_processed_event_id", + limit=1, + ) + assert_matches_type(EventListResponse, event, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Agentex) -> None: + response = client.events.with_raw_response.list( + agent_id="agent_id", + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + event = response.parse() + assert_matches_type(EventListResponse, event, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Agentex) -> None: + with client.events.with_streaming_response.list( + agent_id="agent_id", + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + event = response.parse() + assert_matches_type(EventListResponse, event, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncEvents: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve(self, async_client: AsyncAgentex) -> None: + event = await async_client.events.retrieve( + "event_id", + ) + assert_matches_type(Event, event, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncAgentex) -> None: + response = await async_client.events.with_raw_response.retrieve( + "event_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + event = await response.parse() + assert_matches_type(Event, event, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncAgentex) -> None: + async with async_client.events.with_streaming_response.retrieve( + "event_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + event = await response.parse() + assert_matches_type(Event, event, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `event_id` but received ''"): + await async_client.events.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncAgentex) -> None: + event = await async_client.events.list( + agent_id="agent_id", + task_id="task_id", + ) + assert_matches_type(EventListResponse, event, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncAgentex) -> None: + event = await async_client.events.list( + agent_id="agent_id", + task_id="task_id", + last_processed_event_id="last_processed_event_id", + limit=1, + ) + assert_matches_type(EventListResponse, event, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncAgentex) -> None: + response = await async_client.events.with_raw_response.list( + agent_id="agent_id", + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + event = await response.parse() + assert_matches_type(EventListResponse, event, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncAgentex) -> None: + async with async_client.events.with_streaming_response.list( + agent_id="agent_id", + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + event = await response.parse() + assert_matches_type(EventListResponse, event, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_messages.py b/tests/api_resources/test_messages.py new file mode 100644 index 000000000..f93506eba --- /dev/null +++ b/tests/api_resources/test_messages.py @@ -0,0 +1,630 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from agentex import Agentex, AsyncAgentex +from agentex.types import ( + TaskMessage, + MessageListResponse, + MessageListPaginatedResponse, +) +from agentex._utils import parse_datetime + +from ..utils import assert_matches_type + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestMessages: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create(self, client: Agentex) -> None: + message = client.messages.create( + content={ + "author": "user", + "content": "content", + "type": "text", + }, + task_id="task_id", + ) + assert_matches_type(TaskMessage, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Agentex) -> None: + message = client.messages.create( + content={ + "author": "user", + "content": "content", + "attachments": [ + { + "file_id": "file_id", + "name": "name", + "size": 0, + "type": "type", + } + ], + "format": "markdown", + "style": "static", + "type": "text", + }, + task_id="task_id", + created_at=parse_datetime("2019-12-27T18:11:19.117Z"), + streaming_status="IN_PROGRESS", + ) + assert_matches_type(TaskMessage, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Agentex) -> None: + response = client.messages.with_raw_response.create( + content={ + "author": "user", + "content": "content", + "type": "text", + }, + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = response.parse() + assert_matches_type(TaskMessage, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Agentex) -> None: + with client.messages.with_streaming_response.create( + content={ + "author": "user", + "content": "content", + "type": "text", + }, + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = response.parse() + assert_matches_type(TaskMessage, message, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve(self, client: Agentex) -> None: + message = client.messages.retrieve( + "message_id", + ) + assert_matches_type(TaskMessage, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve(self, client: Agentex) -> None: + response = client.messages.with_raw_response.retrieve( + "message_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = response.parse() + assert_matches_type(TaskMessage, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve(self, client: Agentex) -> None: + with client.messages.with_streaming_response.retrieve( + "message_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = response.parse() + assert_matches_type(TaskMessage, message, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `message_id` but received ''"): + client.messages.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update(self, client: Agentex) -> None: + message = client.messages.update( + message_id="message_id", + content={ + "author": "user", + "content": "content", + "type": "text", + }, + task_id="task_id", + ) + assert_matches_type(TaskMessage, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update_with_all_params(self, client: Agentex) -> None: + message = client.messages.update( + message_id="message_id", + content={ + "author": "user", + "content": "content", + "attachments": [ + { + "file_id": "file_id", + "name": "name", + "size": 0, + "type": "type", + } + ], + "format": "markdown", + "style": "static", + "type": "text", + }, + task_id="task_id", + streaming_status="IN_PROGRESS", + ) + assert_matches_type(TaskMessage, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_update(self, client: Agentex) -> None: + response = client.messages.with_raw_response.update( + message_id="message_id", + content={ + "author": "user", + "content": "content", + "type": "text", + }, + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = response.parse() + assert_matches_type(TaskMessage, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_update(self, client: Agentex) -> None: + with client.messages.with_streaming_response.update( + message_id="message_id", + content={ + "author": "user", + "content": "content", + "type": "text", + }, + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = response.parse() + assert_matches_type(TaskMessage, message, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_update(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `message_id` but received ''"): + client.messages.with_raw_response.update( + message_id="", + content={ + "author": "user", + "content": "content", + "type": "text", + }, + task_id="task_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Agentex) -> None: + message = client.messages.list( + task_id="task_id", + ) + assert_matches_type(MessageListResponse, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Agentex) -> None: + message = client.messages.list( + task_id="task_id", + filters="filters", + limit=0, + order_by="order_by", + order_direction="order_direction", + page_number=0, + ) + assert_matches_type(MessageListResponse, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Agentex) -> None: + response = client.messages.with_raw_response.list( + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = response.parse() + assert_matches_type(MessageListResponse, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Agentex) -> None: + with client.messages.with_streaming_response.list( + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = response.parse() + assert_matches_type(MessageListResponse, message, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_paginated(self, client: Agentex) -> None: + message = client.messages.list_paginated( + task_id="task_id", + ) + assert_matches_type(MessageListPaginatedResponse, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_paginated_with_all_params(self, client: Agentex) -> None: + message = client.messages.list_paginated( + task_id="task_id", + cursor="cursor", + direction="older", + filters="filters", + limit=0, + ) + assert_matches_type(MessageListPaginatedResponse, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list_paginated(self, client: Agentex) -> None: + response = client.messages.with_raw_response.list_paginated( + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = response.parse() + assert_matches_type(MessageListPaginatedResponse, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list_paginated(self, client: Agentex) -> None: + with client.messages.with_streaming_response.list_paginated( + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = response.parse() + assert_matches_type(MessageListPaginatedResponse, message, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncMessages: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncAgentex) -> None: + message = await async_client.messages.create( + content={ + "author": "user", + "content": "content", + "type": "text", + }, + task_id="task_id", + ) + assert_matches_type(TaskMessage, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncAgentex) -> None: + message = await async_client.messages.create( + content={ + "author": "user", + "content": "content", + "attachments": [ + { + "file_id": "file_id", + "name": "name", + "size": 0, + "type": "type", + } + ], + "format": "markdown", + "style": "static", + "type": "text", + }, + task_id="task_id", + created_at=parse_datetime("2019-12-27T18:11:19.117Z"), + streaming_status="IN_PROGRESS", + ) + assert_matches_type(TaskMessage, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncAgentex) -> None: + response = await async_client.messages.with_raw_response.create( + content={ + "author": "user", + "content": "content", + "type": "text", + }, + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = await response.parse() + assert_matches_type(TaskMessage, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncAgentex) -> None: + async with async_client.messages.with_streaming_response.create( + content={ + "author": "user", + "content": "content", + "type": "text", + }, + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = await response.parse() + assert_matches_type(TaskMessage, message, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve(self, async_client: AsyncAgentex) -> None: + message = await async_client.messages.retrieve( + "message_id", + ) + assert_matches_type(TaskMessage, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncAgentex) -> None: + response = await async_client.messages.with_raw_response.retrieve( + "message_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = await response.parse() + assert_matches_type(TaskMessage, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncAgentex) -> None: + async with async_client.messages.with_streaming_response.retrieve( + "message_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = await response.parse() + assert_matches_type(TaskMessage, message, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `message_id` but received ''"): + await async_client.messages.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update(self, async_client: AsyncAgentex) -> None: + message = await async_client.messages.update( + message_id="message_id", + content={ + "author": "user", + "content": "content", + "type": "text", + }, + task_id="task_id", + ) + assert_matches_type(TaskMessage, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update_with_all_params(self, async_client: AsyncAgentex) -> None: + message = await async_client.messages.update( + message_id="message_id", + content={ + "author": "user", + "content": "content", + "attachments": [ + { + "file_id": "file_id", + "name": "name", + "size": 0, + "type": "type", + } + ], + "format": "markdown", + "style": "static", + "type": "text", + }, + task_id="task_id", + streaming_status="IN_PROGRESS", + ) + assert_matches_type(TaskMessage, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_update(self, async_client: AsyncAgentex) -> None: + response = await async_client.messages.with_raw_response.update( + message_id="message_id", + content={ + "author": "user", + "content": "content", + "type": "text", + }, + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = await response.parse() + assert_matches_type(TaskMessage, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_update(self, async_client: AsyncAgentex) -> None: + async with async_client.messages.with_streaming_response.update( + message_id="message_id", + content={ + "author": "user", + "content": "content", + "type": "text", + }, + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = await response.parse() + assert_matches_type(TaskMessage, message, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_update(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `message_id` but received ''"): + await async_client.messages.with_raw_response.update( + message_id="", + content={ + "author": "user", + "content": "content", + "type": "text", + }, + task_id="task_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncAgentex) -> None: + message = await async_client.messages.list( + task_id="task_id", + ) + assert_matches_type(MessageListResponse, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncAgentex) -> None: + message = await async_client.messages.list( + task_id="task_id", + filters="filters", + limit=0, + order_by="order_by", + order_direction="order_direction", + page_number=0, + ) + assert_matches_type(MessageListResponse, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncAgentex) -> None: + response = await async_client.messages.with_raw_response.list( + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = await response.parse() + assert_matches_type(MessageListResponse, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncAgentex) -> None: + async with async_client.messages.with_streaming_response.list( + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = await response.parse() + assert_matches_type(MessageListResponse, message, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_paginated(self, async_client: AsyncAgentex) -> None: + message = await async_client.messages.list_paginated( + task_id="task_id", + ) + assert_matches_type(MessageListPaginatedResponse, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_paginated_with_all_params(self, async_client: AsyncAgentex) -> None: + message = await async_client.messages.list_paginated( + task_id="task_id", + cursor="cursor", + direction="older", + filters="filters", + limit=0, + ) + assert_matches_type(MessageListPaginatedResponse, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list_paginated(self, async_client: AsyncAgentex) -> None: + response = await async_client.messages.with_raw_response.list_paginated( + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = await response.parse() + assert_matches_type(MessageListPaginatedResponse, message, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list_paginated(self, async_client: AsyncAgentex) -> None: + async with async_client.messages.with_streaming_response.list_paginated( + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = await response.parse() + assert_matches_type(MessageListPaginatedResponse, message, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_spans.py b/tests/api_resources/test_spans.py new file mode 100644 index 000000000..7cccec2ad --- /dev/null +++ b/tests/api_resources/test_spans.py @@ -0,0 +1,424 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from agentex import Agentex, AsyncAgentex +from agentex.types import Span, SpanListResponse +from agentex._utils import parse_datetime + +from ..utils import assert_matches_type + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestSpans: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create(self, client: Agentex) -> None: + span = client.spans.create( + name="name", + start_time=parse_datetime("2019-12-27T18:11:19.117Z"), + trace_id="trace_id", + ) + assert_matches_type(Span, span, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: Agentex) -> None: + span = client.spans.create( + name="name", + start_time=parse_datetime("2019-12-27T18:11:19.117Z"), + trace_id="trace_id", + id="id", + data={"foo": "bar"}, + end_time=parse_datetime("2019-12-27T18:11:19.117Z"), + input={"foo": "bar"}, + output={"foo": "bar"}, + parent_id="parent_id", + task_id="task_id", + ) + assert_matches_type(Span, span, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Agentex) -> None: + response = client.spans.with_raw_response.create( + name="name", + start_time=parse_datetime("2019-12-27T18:11:19.117Z"), + trace_id="trace_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + span = response.parse() + assert_matches_type(Span, span, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Agentex) -> None: + with client.spans.with_streaming_response.create( + name="name", + start_time=parse_datetime("2019-12-27T18:11:19.117Z"), + trace_id="trace_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + span = response.parse() + assert_matches_type(Span, span, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve(self, client: Agentex) -> None: + span = client.spans.retrieve( + "span_id", + ) + assert_matches_type(Span, span, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve(self, client: Agentex) -> None: + response = client.spans.with_raw_response.retrieve( + "span_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + span = response.parse() + assert_matches_type(Span, span, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve(self, client: Agentex) -> None: + with client.spans.with_streaming_response.retrieve( + "span_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + span = response.parse() + assert_matches_type(Span, span, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `span_id` but received ''"): + client.spans.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update(self, client: Agentex) -> None: + span = client.spans.update( + span_id="span_id", + ) + assert_matches_type(Span, span, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update_with_all_params(self, client: Agentex) -> None: + span = client.spans.update( + span_id="span_id", + data={"foo": "bar"}, + end_time=parse_datetime("2019-12-27T18:11:19.117Z"), + input={"foo": "bar"}, + name="name", + output={"foo": "bar"}, + parent_id="parent_id", + start_time=parse_datetime("2019-12-27T18:11:19.117Z"), + task_id="task_id", + trace_id="trace_id", + ) + assert_matches_type(Span, span, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_update(self, client: Agentex) -> None: + response = client.spans.with_raw_response.update( + span_id="span_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + span = response.parse() + assert_matches_type(Span, span, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_update(self, client: Agentex) -> None: + with client.spans.with_streaming_response.update( + span_id="span_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + span = response.parse() + assert_matches_type(Span, span, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_update(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `span_id` but received ''"): + client.spans.with_raw_response.update( + span_id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Agentex) -> None: + span = client.spans.list() + assert_matches_type(SpanListResponse, span, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Agentex) -> None: + span = client.spans.list( + limit=1, + order_by="order_by", + order_direction="order_direction", + page_number=1, + task_id="task_id", + trace_id="trace_id", + ) + assert_matches_type(SpanListResponse, span, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Agentex) -> None: + response = client.spans.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + span = response.parse() + assert_matches_type(SpanListResponse, span, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Agentex) -> None: + with client.spans.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + span = response.parse() + assert_matches_type(SpanListResponse, span, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncSpans: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncAgentex) -> None: + span = await async_client.spans.create( + name="name", + start_time=parse_datetime("2019-12-27T18:11:19.117Z"), + trace_id="trace_id", + ) + assert_matches_type(Span, span, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncAgentex) -> None: + span = await async_client.spans.create( + name="name", + start_time=parse_datetime("2019-12-27T18:11:19.117Z"), + trace_id="trace_id", + id="id", + data={"foo": "bar"}, + end_time=parse_datetime("2019-12-27T18:11:19.117Z"), + input={"foo": "bar"}, + output={"foo": "bar"}, + parent_id="parent_id", + task_id="task_id", + ) + assert_matches_type(Span, span, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncAgentex) -> None: + response = await async_client.spans.with_raw_response.create( + name="name", + start_time=parse_datetime("2019-12-27T18:11:19.117Z"), + trace_id="trace_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + span = await response.parse() + assert_matches_type(Span, span, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncAgentex) -> None: + async with async_client.spans.with_streaming_response.create( + name="name", + start_time=parse_datetime("2019-12-27T18:11:19.117Z"), + trace_id="trace_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + span = await response.parse() + assert_matches_type(Span, span, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve(self, async_client: AsyncAgentex) -> None: + span = await async_client.spans.retrieve( + "span_id", + ) + assert_matches_type(Span, span, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncAgentex) -> None: + response = await async_client.spans.with_raw_response.retrieve( + "span_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + span = await response.parse() + assert_matches_type(Span, span, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncAgentex) -> None: + async with async_client.spans.with_streaming_response.retrieve( + "span_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + span = await response.parse() + assert_matches_type(Span, span, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `span_id` but received ''"): + await async_client.spans.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update(self, async_client: AsyncAgentex) -> None: + span = await async_client.spans.update( + span_id="span_id", + ) + assert_matches_type(Span, span, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update_with_all_params(self, async_client: AsyncAgentex) -> None: + span = await async_client.spans.update( + span_id="span_id", + data={"foo": "bar"}, + end_time=parse_datetime("2019-12-27T18:11:19.117Z"), + input={"foo": "bar"}, + name="name", + output={"foo": "bar"}, + parent_id="parent_id", + start_time=parse_datetime("2019-12-27T18:11:19.117Z"), + task_id="task_id", + trace_id="trace_id", + ) + assert_matches_type(Span, span, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_update(self, async_client: AsyncAgentex) -> None: + response = await async_client.spans.with_raw_response.update( + span_id="span_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + span = await response.parse() + assert_matches_type(Span, span, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_update(self, async_client: AsyncAgentex) -> None: + async with async_client.spans.with_streaming_response.update( + span_id="span_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + span = await response.parse() + assert_matches_type(Span, span, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_update(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `span_id` but received ''"): + await async_client.spans.with_raw_response.update( + span_id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncAgentex) -> None: + span = await async_client.spans.list() + assert_matches_type(SpanListResponse, span, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncAgentex) -> None: + span = await async_client.spans.list( + limit=1, + order_by="order_by", + order_direction="order_direction", + page_number=1, + task_id="task_id", + trace_id="trace_id", + ) + assert_matches_type(SpanListResponse, span, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncAgentex) -> None: + response = await async_client.spans.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + span = await response.parse() + assert_matches_type(SpanListResponse, span, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncAgentex) -> None: + async with async_client.spans.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + span = await response.parse() + assert_matches_type(SpanListResponse, span, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_states.py b/tests/api_resources/test_states.py new file mode 100644 index 000000000..b5275201f --- /dev/null +++ b/tests/api_resources/test_states.py @@ -0,0 +1,447 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from agentex import Agentex, AsyncAgentex +from agentex.types import State, StateListResponse + +from ..utils import assert_matches_type + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestStates: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create(self, client: Agentex) -> None: + state = client.states.create( + agent_id="agent_id", + state={"foo": "bar"}, + task_id="task_id", + ) + assert_matches_type(State, state, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: Agentex) -> None: + response = client.states.with_raw_response.create( + agent_id="agent_id", + state={"foo": "bar"}, + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + state = response.parse() + assert_matches_type(State, state, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: Agentex) -> None: + with client.states.with_streaming_response.create( + agent_id="agent_id", + state={"foo": "bar"}, + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + state = response.parse() + assert_matches_type(State, state, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve(self, client: Agentex) -> None: + state = client.states.retrieve( + "state_id", + ) + assert_matches_type(State, state, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve(self, client: Agentex) -> None: + response = client.states.with_raw_response.retrieve( + "state_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + state = response.parse() + assert_matches_type(State, state, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve(self, client: Agentex) -> None: + with client.states.with_streaming_response.retrieve( + "state_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + state = response.parse() + assert_matches_type(State, state, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `state_id` but received ''"): + client.states.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update(self, client: Agentex) -> None: + state = client.states.update( + state_id="state_id", + state={"foo": "bar"}, + ) + assert_matches_type(State, state, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_update(self, client: Agentex) -> None: + response = client.states.with_raw_response.update( + state_id="state_id", + state={"foo": "bar"}, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + state = response.parse() + assert_matches_type(State, state, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_update(self, client: Agentex) -> None: + with client.states.with_streaming_response.update( + state_id="state_id", + state={"foo": "bar"}, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + state = response.parse() + assert_matches_type(State, state, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_update(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `state_id` but received ''"): + client.states.with_raw_response.update( + state_id="", + state={"foo": "bar"}, + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Agentex) -> None: + state = client.states.list() + assert_matches_type(StateListResponse, state, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Agentex) -> None: + state = client.states.list( + agent_id="agent_id", + limit=1, + order_by="order_by", + order_direction="order_direction", + page_number=1, + task_id="task_id", + ) + assert_matches_type(StateListResponse, state, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Agentex) -> None: + response = client.states.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + state = response.parse() + assert_matches_type(StateListResponse, state, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Agentex) -> None: + with client.states.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + state = response.parse() + assert_matches_type(StateListResponse, state, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete(self, client: Agentex) -> None: + state = client.states.delete( + "state_id", + ) + assert_matches_type(State, state, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: Agentex) -> None: + response = client.states.with_raw_response.delete( + "state_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + state = response.parse() + assert_matches_type(State, state, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: Agentex) -> None: + with client.states.with_streaming_response.delete( + "state_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + state = response.parse() + assert_matches_type(State, state, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_delete(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `state_id` but received ''"): + client.states.with_raw_response.delete( + "", + ) + + +class TestAsyncStates: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncAgentex) -> None: + state = await async_client.states.create( + agent_id="agent_id", + state={"foo": "bar"}, + task_id="task_id", + ) + assert_matches_type(State, state, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncAgentex) -> None: + response = await async_client.states.with_raw_response.create( + agent_id="agent_id", + state={"foo": "bar"}, + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + state = await response.parse() + assert_matches_type(State, state, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncAgentex) -> None: + async with async_client.states.with_streaming_response.create( + agent_id="agent_id", + state={"foo": "bar"}, + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + state = await response.parse() + assert_matches_type(State, state, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve(self, async_client: AsyncAgentex) -> None: + state = await async_client.states.retrieve( + "state_id", + ) + assert_matches_type(State, state, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncAgentex) -> None: + response = await async_client.states.with_raw_response.retrieve( + "state_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + state = await response.parse() + assert_matches_type(State, state, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncAgentex) -> None: + async with async_client.states.with_streaming_response.retrieve( + "state_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + state = await response.parse() + assert_matches_type(State, state, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `state_id` but received ''"): + await async_client.states.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update(self, async_client: AsyncAgentex) -> None: + state = await async_client.states.update( + state_id="state_id", + state={"foo": "bar"}, + ) + assert_matches_type(State, state, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_update(self, async_client: AsyncAgentex) -> None: + response = await async_client.states.with_raw_response.update( + state_id="state_id", + state={"foo": "bar"}, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + state = await response.parse() + assert_matches_type(State, state, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_update(self, async_client: AsyncAgentex) -> None: + async with async_client.states.with_streaming_response.update( + state_id="state_id", + state={"foo": "bar"}, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + state = await response.parse() + assert_matches_type(State, state, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_update(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `state_id` but received ''"): + await async_client.states.with_raw_response.update( + state_id="", + state={"foo": "bar"}, + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncAgentex) -> None: + state = await async_client.states.list() + assert_matches_type(StateListResponse, state, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncAgentex) -> None: + state = await async_client.states.list( + agent_id="agent_id", + limit=1, + order_by="order_by", + order_direction="order_direction", + page_number=1, + task_id="task_id", + ) + assert_matches_type(StateListResponse, state, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncAgentex) -> None: + response = await async_client.states.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + state = await response.parse() + assert_matches_type(StateListResponse, state, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncAgentex) -> None: + async with async_client.states.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + state = await response.parse() + assert_matches_type(StateListResponse, state, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncAgentex) -> None: + state = await async_client.states.delete( + "state_id", + ) + assert_matches_type(State, state, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncAgentex) -> None: + response = await async_client.states.with_raw_response.delete( + "state_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + state = await response.parse() + assert_matches_type(State, state, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncAgentex) -> None: + async with async_client.states.with_streaming_response.delete( + "state_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + state = await response.parse() + assert_matches_type(State, state, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_delete(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `state_id` but received ''"): + await async_client.states.with_raw_response.delete( + "", + ) diff --git a/tests/api_resources/test_tasks.py b/tests/api_resources/test_tasks.py new file mode 100644 index 000000000..37df8f0f3 --- /dev/null +++ b/tests/api_resources/test_tasks.py @@ -0,0 +1,1580 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from agentex import Agentex, AsyncAgentex +from agentex.types import ( + Task, + TaskListResponse, + TaskRetrieveResponse, + TaskQueryWorkflowResponse, + TaskRetrieveByNameResponse, +) +from agentex.types.shared import DeleteResponse + +from ..utils import assert_matches_type + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestTasks: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve(self, client: Agentex) -> None: + task = client.tasks.retrieve( + task_id="task_id", + ) + assert_matches_type(TaskRetrieveResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve_with_all_params(self, client: Agentex) -> None: + task = client.tasks.retrieve( + task_id="task_id", + relationships=["agents"], + ) + assert_matches_type(TaskRetrieveResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve(self, client: Agentex) -> None: + response = client.tasks.with_raw_response.retrieve( + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = response.parse() + assert_matches_type(TaskRetrieveResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve(self, client: Agentex) -> None: + with client.tasks.with_streaming_response.retrieve( + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = response.parse() + assert_matches_type(TaskRetrieveResponse, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_id` but received ''"): + client.tasks.with_raw_response.retrieve( + task_id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Agentex) -> None: + task = client.tasks.list() + assert_matches_type(TaskListResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Agentex) -> None: + task = client.tasks.list( + agent_id="agent_id", + agent_name="agent_name", + limit=0, + order_by="order_by", + order_direction="order_direction", + page_number=0, + relationships=["agents"], + status="CANCELED", + task_metadata="task_metadata", + ) + assert_matches_type(TaskListResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Agentex) -> None: + response = client.tasks.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = response.parse() + assert_matches_type(TaskListResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Agentex) -> None: + with client.tasks.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = response.parse() + assert_matches_type(TaskListResponse, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete(self, client: Agentex) -> None: + task = client.tasks.delete( + "task_id", + ) + assert_matches_type(DeleteResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: Agentex) -> None: + response = client.tasks.with_raw_response.delete( + "task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = response.parse() + assert_matches_type(DeleteResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: Agentex) -> None: + with client.tasks.with_streaming_response.delete( + "task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = response.parse() + assert_matches_type(DeleteResponse, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_delete(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_id` but received ''"): + client.tasks.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_cancel(self, client: Agentex) -> None: + task = client.tasks.cancel( + task_id="task_id", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_cancel_with_all_params(self, client: Agentex) -> None: + task = client.tasks.cancel( + task_id="task_id", + reason="reason", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_cancel(self, client: Agentex) -> None: + response = client.tasks.with_raw_response.cancel( + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = response.parse() + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_cancel(self, client: Agentex) -> None: + with client.tasks.with_streaming_response.cancel( + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = response.parse() + assert_matches_type(Task, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_cancel(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_id` but received ''"): + client.tasks.with_raw_response.cancel( + task_id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_complete(self, client: Agentex) -> None: + task = client.tasks.complete( + task_id="task_id", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_complete_with_all_params(self, client: Agentex) -> None: + task = client.tasks.complete( + task_id="task_id", + reason="reason", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_complete(self, client: Agentex) -> None: + response = client.tasks.with_raw_response.complete( + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = response.parse() + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_complete(self, client: Agentex) -> None: + with client.tasks.with_streaming_response.complete( + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = response.parse() + assert_matches_type(Task, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_complete(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_id` but received ''"): + client.tasks.with_raw_response.complete( + task_id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete_by_name(self, client: Agentex) -> None: + task = client.tasks.delete_by_name( + "task_name", + ) + assert_matches_type(DeleteResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete_by_name(self, client: Agentex) -> None: + response = client.tasks.with_raw_response.delete_by_name( + "task_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = response.parse() + assert_matches_type(DeleteResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete_by_name(self, client: Agentex) -> None: + with client.tasks.with_streaming_response.delete_by_name( + "task_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = response.parse() + assert_matches_type(DeleteResponse, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_delete_by_name(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_name` but received ''"): + client.tasks.with_raw_response.delete_by_name( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_fail(self, client: Agentex) -> None: + task = client.tasks.fail( + task_id="task_id", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_fail_with_all_params(self, client: Agentex) -> None: + task = client.tasks.fail( + task_id="task_id", + reason="reason", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_fail(self, client: Agentex) -> None: + response = client.tasks.with_raw_response.fail( + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = response.parse() + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_fail(self, client: Agentex) -> None: + with client.tasks.with_streaming_response.fail( + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = response.parse() + assert_matches_type(Task, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_fail(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_id` but received ''"): + client.tasks.with_raw_response.fail( + task_id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_interrupt(self, client: Agentex) -> None: + task = client.tasks.interrupt( + task_id="task_id", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_interrupt_with_all_params(self, client: Agentex) -> None: + task = client.tasks.interrupt( + task_id="task_id", + reason="reason", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_interrupt(self, client: Agentex) -> None: + response = client.tasks.with_raw_response.interrupt( + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = response.parse() + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_interrupt(self, client: Agentex) -> None: + with client.tasks.with_streaming_response.interrupt( + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = response.parse() + assert_matches_type(Task, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_interrupt(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_id` but received ''"): + client.tasks.with_raw_response.interrupt( + task_id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_query_workflow(self, client: Agentex) -> None: + task = client.tasks.query_workflow( + query_name="query_name", + task_id="task_id", + ) + assert_matches_type(TaskQueryWorkflowResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_query_workflow(self, client: Agentex) -> None: + response = client.tasks.with_raw_response.query_workflow( + query_name="query_name", + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = response.parse() + assert_matches_type(TaskQueryWorkflowResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_query_workflow(self, client: Agentex) -> None: + with client.tasks.with_streaming_response.query_workflow( + query_name="query_name", + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = response.parse() + assert_matches_type(TaskQueryWorkflowResponse, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_query_workflow(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_id` but received ''"): + client.tasks.with_raw_response.query_workflow( + query_name="query_name", + task_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `query_name` but received ''"): + client.tasks.with_raw_response.query_workflow( + query_name="", + task_id="task_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve_by_name(self, client: Agentex) -> None: + task = client.tasks.retrieve_by_name( + task_name="task_name", + ) + assert_matches_type(TaskRetrieveByNameResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve_by_name_with_all_params(self, client: Agentex) -> None: + task = client.tasks.retrieve_by_name( + task_name="task_name", + relationships=["agents"], + ) + assert_matches_type(TaskRetrieveByNameResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve_by_name(self, client: Agentex) -> None: + response = client.tasks.with_raw_response.retrieve_by_name( + task_name="task_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = response.parse() + assert_matches_type(TaskRetrieveByNameResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve_by_name(self, client: Agentex) -> None: + with client.tasks.with_streaming_response.retrieve_by_name( + task_name="task_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = response.parse() + assert_matches_type(TaskRetrieveByNameResponse, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve_by_name(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_name` but received ''"): + client.tasks.with_raw_response.retrieve_by_name( + task_name="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_stream_events(self, client: Agentex) -> None: + task_stream = client.tasks.stream_events( + "task_id", + ) + task_stream.response.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_stream_events(self, client: Agentex) -> None: + response = client.tasks.with_raw_response.stream_events( + "task_id", + ) + + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + stream = response.parse() + stream.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_stream_events(self, client: Agentex) -> None: + with client.tasks.with_streaming_response.stream_events( + "task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + stream = response.parse() + stream.close() + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_stream_events(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_id` but received ''"): + client.tasks.with_raw_response.stream_events( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_stream_events_by_name(self, client: Agentex) -> None: + task_stream = client.tasks.stream_events_by_name( + "task_name", + ) + task_stream.response.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_stream_events_by_name(self, client: Agentex) -> None: + response = client.tasks.with_raw_response.stream_events_by_name( + "task_name", + ) + + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + stream = response.parse() + stream.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_stream_events_by_name(self, client: Agentex) -> None: + with client.tasks.with_streaming_response.stream_events_by_name( + "task_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + stream = response.parse() + stream.close() + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_stream_events_by_name(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_name` but received ''"): + client.tasks.with_raw_response.stream_events_by_name( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_terminate(self, client: Agentex) -> None: + task = client.tasks.terminate( + task_id="task_id", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_terminate_with_all_params(self, client: Agentex) -> None: + task = client.tasks.terminate( + task_id="task_id", + reason="reason", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_terminate(self, client: Agentex) -> None: + response = client.tasks.with_raw_response.terminate( + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = response.parse() + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_terminate(self, client: Agentex) -> None: + with client.tasks.with_streaming_response.terminate( + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = response.parse() + assert_matches_type(Task, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_terminate(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_id` but received ''"): + client.tasks.with_raw_response.terminate( + task_id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_timeout(self, client: Agentex) -> None: + task = client.tasks.timeout( + task_id="task_id", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_timeout_with_all_params(self, client: Agentex) -> None: + task = client.tasks.timeout( + task_id="task_id", + reason="reason", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_timeout(self, client: Agentex) -> None: + response = client.tasks.with_raw_response.timeout( + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = response.parse() + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_timeout(self, client: Agentex) -> None: + with client.tasks.with_streaming_response.timeout( + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = response.parse() + assert_matches_type(Task, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_timeout(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_id` but received ''"): + client.tasks.with_raw_response.timeout( + task_id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update_by_id(self, client: Agentex) -> None: + task = client.tasks.update_by_id( + task_id="task_id", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update_by_id_with_all_params(self, client: Agentex) -> None: + task = client.tasks.update_by_id( + task_id="task_id", + merge_params={"foo": "bar"}, + task_metadata={"foo": "bar"}, + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_update_by_id(self, client: Agentex) -> None: + response = client.tasks.with_raw_response.update_by_id( + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = response.parse() + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_update_by_id(self, client: Agentex) -> None: + with client.tasks.with_streaming_response.update_by_id( + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = response.parse() + assert_matches_type(Task, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_update_by_id(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_id` but received ''"): + client.tasks.with_raw_response.update_by_id( + task_id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update_by_name(self, client: Agentex) -> None: + task = client.tasks.update_by_name( + task_name="task_name", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update_by_name_with_all_params(self, client: Agentex) -> None: + task = client.tasks.update_by_name( + task_name="task_name", + merge_params={"foo": "bar"}, + task_metadata={"foo": "bar"}, + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_update_by_name(self, client: Agentex) -> None: + response = client.tasks.with_raw_response.update_by_name( + task_name="task_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = response.parse() + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_update_by_name(self, client: Agentex) -> None: + with client.tasks.with_streaming_response.update_by_name( + task_name="task_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = response.parse() + assert_matches_type(Task, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_update_by_name(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_name` but received ''"): + client.tasks.with_raw_response.update_by_name( + task_name="", + ) + + +class TestAsyncTasks: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.retrieve( + task_id="task_id", + ) + assert_matches_type(TaskRetrieveResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve_with_all_params(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.retrieve( + task_id="task_id", + relationships=["agents"], + ) + assert_matches_type(TaskRetrieveResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncAgentex) -> None: + response = await async_client.tasks.with_raw_response.retrieve( + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = await response.parse() + assert_matches_type(TaskRetrieveResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncAgentex) -> None: + async with async_client.tasks.with_streaming_response.retrieve( + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = await response.parse() + assert_matches_type(TaskRetrieveResponse, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_id` but received ''"): + await async_client.tasks.with_raw_response.retrieve( + task_id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.list() + assert_matches_type(TaskListResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.list( + agent_id="agent_id", + agent_name="agent_name", + limit=0, + order_by="order_by", + order_direction="order_direction", + page_number=0, + relationships=["agents"], + status="CANCELED", + task_metadata="task_metadata", + ) + assert_matches_type(TaskListResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncAgentex) -> None: + response = await async_client.tasks.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = await response.parse() + assert_matches_type(TaskListResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncAgentex) -> None: + async with async_client.tasks.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = await response.parse() + assert_matches_type(TaskListResponse, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.delete( + "task_id", + ) + assert_matches_type(DeleteResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncAgentex) -> None: + response = await async_client.tasks.with_raw_response.delete( + "task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = await response.parse() + assert_matches_type(DeleteResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncAgentex) -> None: + async with async_client.tasks.with_streaming_response.delete( + "task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = await response.parse() + assert_matches_type(DeleteResponse, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_delete(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_id` but received ''"): + await async_client.tasks.with_raw_response.delete( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_cancel(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.cancel( + task_id="task_id", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_cancel_with_all_params(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.cancel( + task_id="task_id", + reason="reason", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_cancel(self, async_client: AsyncAgentex) -> None: + response = await async_client.tasks.with_raw_response.cancel( + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = await response.parse() + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_cancel(self, async_client: AsyncAgentex) -> None: + async with async_client.tasks.with_streaming_response.cancel( + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = await response.parse() + assert_matches_type(Task, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_cancel(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_id` but received ''"): + await async_client.tasks.with_raw_response.cancel( + task_id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_complete(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.complete( + task_id="task_id", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_complete_with_all_params(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.complete( + task_id="task_id", + reason="reason", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_complete(self, async_client: AsyncAgentex) -> None: + response = await async_client.tasks.with_raw_response.complete( + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = await response.parse() + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_complete(self, async_client: AsyncAgentex) -> None: + async with async_client.tasks.with_streaming_response.complete( + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = await response.parse() + assert_matches_type(Task, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_complete(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_id` but received ''"): + await async_client.tasks.with_raw_response.complete( + task_id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete_by_name(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.delete_by_name( + "task_name", + ) + assert_matches_type(DeleteResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete_by_name(self, async_client: AsyncAgentex) -> None: + response = await async_client.tasks.with_raw_response.delete_by_name( + "task_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = await response.parse() + assert_matches_type(DeleteResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete_by_name(self, async_client: AsyncAgentex) -> None: + async with async_client.tasks.with_streaming_response.delete_by_name( + "task_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = await response.parse() + assert_matches_type(DeleteResponse, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_delete_by_name(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_name` but received ''"): + await async_client.tasks.with_raw_response.delete_by_name( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_fail(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.fail( + task_id="task_id", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_fail_with_all_params(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.fail( + task_id="task_id", + reason="reason", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_fail(self, async_client: AsyncAgentex) -> None: + response = await async_client.tasks.with_raw_response.fail( + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = await response.parse() + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_fail(self, async_client: AsyncAgentex) -> None: + async with async_client.tasks.with_streaming_response.fail( + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = await response.parse() + assert_matches_type(Task, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_fail(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_id` but received ''"): + await async_client.tasks.with_raw_response.fail( + task_id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_interrupt(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.interrupt( + task_id="task_id", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_interrupt_with_all_params(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.interrupt( + task_id="task_id", + reason="reason", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_interrupt(self, async_client: AsyncAgentex) -> None: + response = await async_client.tasks.with_raw_response.interrupt( + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = await response.parse() + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_interrupt(self, async_client: AsyncAgentex) -> None: + async with async_client.tasks.with_streaming_response.interrupt( + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = await response.parse() + assert_matches_type(Task, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_interrupt(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_id` but received ''"): + await async_client.tasks.with_raw_response.interrupt( + task_id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_query_workflow(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.query_workflow( + query_name="query_name", + task_id="task_id", + ) + assert_matches_type(TaskQueryWorkflowResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_query_workflow(self, async_client: AsyncAgentex) -> None: + response = await async_client.tasks.with_raw_response.query_workflow( + query_name="query_name", + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = await response.parse() + assert_matches_type(TaskQueryWorkflowResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_query_workflow(self, async_client: AsyncAgentex) -> None: + async with async_client.tasks.with_streaming_response.query_workflow( + query_name="query_name", + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = await response.parse() + assert_matches_type(TaskQueryWorkflowResponse, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_query_workflow(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_id` but received ''"): + await async_client.tasks.with_raw_response.query_workflow( + query_name="query_name", + task_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `query_name` but received ''"): + await async_client.tasks.with_raw_response.query_workflow( + query_name="", + task_id="task_id", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve_by_name(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.retrieve_by_name( + task_name="task_name", + ) + assert_matches_type(TaskRetrieveByNameResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve_by_name_with_all_params(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.retrieve_by_name( + task_name="task_name", + relationships=["agents"], + ) + assert_matches_type(TaskRetrieveByNameResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve_by_name(self, async_client: AsyncAgentex) -> None: + response = await async_client.tasks.with_raw_response.retrieve_by_name( + task_name="task_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = await response.parse() + assert_matches_type(TaskRetrieveByNameResponse, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve_by_name(self, async_client: AsyncAgentex) -> None: + async with async_client.tasks.with_streaming_response.retrieve_by_name( + task_name="task_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = await response.parse() + assert_matches_type(TaskRetrieveByNameResponse, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve_by_name(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_name` but received ''"): + await async_client.tasks.with_raw_response.retrieve_by_name( + task_name="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_stream_events(self, async_client: AsyncAgentex) -> None: + task_stream = await async_client.tasks.stream_events( + "task_id", + ) + await task_stream.response.aclose() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_stream_events(self, async_client: AsyncAgentex) -> None: + response = await async_client.tasks.with_raw_response.stream_events( + "task_id", + ) + + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + stream = await response.parse() + await stream.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_stream_events(self, async_client: AsyncAgentex) -> None: + async with async_client.tasks.with_streaming_response.stream_events( + "task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + stream = await response.parse() + await stream.close() + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_stream_events(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_id` but received ''"): + await async_client.tasks.with_raw_response.stream_events( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_stream_events_by_name(self, async_client: AsyncAgentex) -> None: + task_stream = await async_client.tasks.stream_events_by_name( + "task_name", + ) + await task_stream.response.aclose() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_stream_events_by_name(self, async_client: AsyncAgentex) -> None: + response = await async_client.tasks.with_raw_response.stream_events_by_name( + "task_name", + ) + + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + stream = await response.parse() + await stream.close() + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_stream_events_by_name(self, async_client: AsyncAgentex) -> None: + async with async_client.tasks.with_streaming_response.stream_events_by_name( + "task_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + stream = await response.parse() + await stream.close() + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_stream_events_by_name(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_name` but received ''"): + await async_client.tasks.with_raw_response.stream_events_by_name( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_terminate(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.terminate( + task_id="task_id", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_terminate_with_all_params(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.terminate( + task_id="task_id", + reason="reason", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_terminate(self, async_client: AsyncAgentex) -> None: + response = await async_client.tasks.with_raw_response.terminate( + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = await response.parse() + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_terminate(self, async_client: AsyncAgentex) -> None: + async with async_client.tasks.with_streaming_response.terminate( + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = await response.parse() + assert_matches_type(Task, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_terminate(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_id` but received ''"): + await async_client.tasks.with_raw_response.terminate( + task_id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_timeout(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.timeout( + task_id="task_id", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_timeout_with_all_params(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.timeout( + task_id="task_id", + reason="reason", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_timeout(self, async_client: AsyncAgentex) -> None: + response = await async_client.tasks.with_raw_response.timeout( + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = await response.parse() + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_timeout(self, async_client: AsyncAgentex) -> None: + async with async_client.tasks.with_streaming_response.timeout( + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = await response.parse() + assert_matches_type(Task, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_timeout(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_id` but received ''"): + await async_client.tasks.with_raw_response.timeout( + task_id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update_by_id(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.update_by_id( + task_id="task_id", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update_by_id_with_all_params(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.update_by_id( + task_id="task_id", + merge_params={"foo": "bar"}, + task_metadata={"foo": "bar"}, + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_update_by_id(self, async_client: AsyncAgentex) -> None: + response = await async_client.tasks.with_raw_response.update_by_id( + task_id="task_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = await response.parse() + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_update_by_id(self, async_client: AsyncAgentex) -> None: + async with async_client.tasks.with_streaming_response.update_by_id( + task_id="task_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = await response.parse() + assert_matches_type(Task, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_update_by_id(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_id` but received ''"): + await async_client.tasks.with_raw_response.update_by_id( + task_id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update_by_name(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.update_by_name( + task_name="task_name", + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update_by_name_with_all_params(self, async_client: AsyncAgentex) -> None: + task = await async_client.tasks.update_by_name( + task_name="task_name", + merge_params={"foo": "bar"}, + task_metadata={"foo": "bar"}, + ) + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_update_by_name(self, async_client: AsyncAgentex) -> None: + response = await async_client.tasks.with_raw_response.update_by_name( + task_name="task_name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + task = await response.parse() + assert_matches_type(Task, task, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_update_by_name(self, async_client: AsyncAgentex) -> None: + async with async_client.tasks.with_streaming_response.update_by_name( + task_name="task_name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + task = await response.parse() + assert_matches_type(Task, task, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_update_by_name(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `task_name` but received ''"): + await async_client.tasks.with_raw_response.update_by_name( + task_name="", + ) diff --git a/tests/api_resources/test_tracker.py b/tests/api_resources/test_tracker.py new file mode 100644 index 000000000..d56f4b6db --- /dev/null +++ b/tests/api_resources/test_tracker.py @@ -0,0 +1,297 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from agentex import Agentex, AsyncAgentex +from agentex.types import AgentTaskTracker, TrackerListResponse + +from ..utils import assert_matches_type + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestTracker: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_retrieve(self, client: Agentex) -> None: + tracker = client.tracker.retrieve( + "tracker_id", + ) + assert_matches_type(AgentTaskTracker, tracker, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_retrieve(self, client: Agentex) -> None: + response = client.tracker.with_raw_response.retrieve( + "tracker_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + tracker = response.parse() + assert_matches_type(AgentTaskTracker, tracker, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_retrieve(self, client: Agentex) -> None: + with client.tracker.with_streaming_response.retrieve( + "tracker_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + tracker = response.parse() + assert_matches_type(AgentTaskTracker, tracker, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_retrieve(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `tracker_id` but received ''"): + client.tracker.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update(self, client: Agentex) -> None: + tracker = client.tracker.update( + tracker_id="tracker_id", + ) + assert_matches_type(AgentTaskTracker, tracker, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update_with_all_params(self, client: Agentex) -> None: + tracker = client.tracker.update( + tracker_id="tracker_id", + last_processed_event_id="last_processed_event_id", + status="status", + status_reason="status_reason", + ) + assert_matches_type(AgentTaskTracker, tracker, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_update(self, client: Agentex) -> None: + response = client.tracker.with_raw_response.update( + tracker_id="tracker_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + tracker = response.parse() + assert_matches_type(AgentTaskTracker, tracker, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_update(self, client: Agentex) -> None: + with client.tracker.with_streaming_response.update( + tracker_id="tracker_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + tracker = response.parse() + assert_matches_type(AgentTaskTracker, tracker, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_update(self, client: Agentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `tracker_id` but received ''"): + client.tracker.with_raw_response.update( + tracker_id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Agentex) -> None: + tracker = client.tracker.list() + assert_matches_type(TrackerListResponse, tracker, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Agentex) -> None: + tracker = client.tracker.list( + agent_id="agent_id", + limit=1, + order_by="order_by", + order_direction="order_direction", + page_number=1, + task_id="task_id", + ) + assert_matches_type(TrackerListResponse, tracker, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Agentex) -> None: + response = client.tracker.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + tracker = response.parse() + assert_matches_type(TrackerListResponse, tracker, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Agentex) -> None: + with client.tracker.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + tracker = response.parse() + assert_matches_type(TrackerListResponse, tracker, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncTracker: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_retrieve(self, async_client: AsyncAgentex) -> None: + tracker = await async_client.tracker.retrieve( + "tracker_id", + ) + assert_matches_type(AgentTaskTracker, tracker, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncAgentex) -> None: + response = await async_client.tracker.with_raw_response.retrieve( + "tracker_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + tracker = await response.parse() + assert_matches_type(AgentTaskTracker, tracker, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncAgentex) -> None: + async with async_client.tracker.with_streaming_response.retrieve( + "tracker_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + tracker = await response.parse() + assert_matches_type(AgentTaskTracker, tracker, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `tracker_id` but received ''"): + await async_client.tracker.with_raw_response.retrieve( + "", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update(self, async_client: AsyncAgentex) -> None: + tracker = await async_client.tracker.update( + tracker_id="tracker_id", + ) + assert_matches_type(AgentTaskTracker, tracker, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update_with_all_params(self, async_client: AsyncAgentex) -> None: + tracker = await async_client.tracker.update( + tracker_id="tracker_id", + last_processed_event_id="last_processed_event_id", + status="status", + status_reason="status_reason", + ) + assert_matches_type(AgentTaskTracker, tracker, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_update(self, async_client: AsyncAgentex) -> None: + response = await async_client.tracker.with_raw_response.update( + tracker_id="tracker_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + tracker = await response.parse() + assert_matches_type(AgentTaskTracker, tracker, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_update(self, async_client: AsyncAgentex) -> None: + async with async_client.tracker.with_streaming_response.update( + tracker_id="tracker_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + tracker = await response.parse() + assert_matches_type(AgentTaskTracker, tracker, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_update(self, async_client: AsyncAgentex) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `tracker_id` but received ''"): + await async_client.tracker.with_raw_response.update( + tracker_id="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncAgentex) -> None: + tracker = await async_client.tracker.list() + assert_matches_type(TrackerListResponse, tracker, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncAgentex) -> None: + tracker = await async_client.tracker.list( + agent_id="agent_id", + limit=1, + order_by="order_by", + order_direction="order_direction", + page_number=1, + task_id="task_id", + ) + assert_matches_type(TrackerListResponse, tracker, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncAgentex) -> None: + response = await async_client.tracker.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + tracker = await response.parse() + assert_matches_type(TrackerListResponse, tracker, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncAgentex) -> None: + async with async_client.tracker.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + tracker = await response.parse() + assert_matches_type(TrackerListResponse, tracker, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_webhooks.py b/tests/api_resources/test_webhooks.py new file mode 100644 index 000000000..ff32dd719 --- /dev/null +++ b/tests/api_resources/test_webhooks.py @@ -0,0 +1,131 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from agentex import Agentex, AsyncAgentex +from agentex.types import WebhookCreateWebhookTriggerResponse + +from ..utils import assert_matches_type + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestWebhooks: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_webhook_trigger(self, client: Agentex) -> None: + webhook = client.webhooks.create_webhook_trigger( + agent_name="agent_name", + forward_path="forward_path", + name="name", + ) + assert_matches_type(WebhookCreateWebhookTriggerResponse, webhook, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_webhook_trigger_with_all_params(self, client: Agentex) -> None: + webhook = client.webhooks.create_webhook_trigger( + agent_name="agent_name", + forward_path="forward_path", + name="name", + base_url="base_url", + secret="secret", + source="internal", + ) + assert_matches_type(WebhookCreateWebhookTriggerResponse, webhook, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create_webhook_trigger(self, client: Agentex) -> None: + response = client.webhooks.with_raw_response.create_webhook_trigger( + agent_name="agent_name", + forward_path="forward_path", + name="name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + webhook = response.parse() + assert_matches_type(WebhookCreateWebhookTriggerResponse, webhook, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create_webhook_trigger(self, client: Agentex) -> None: + with client.webhooks.with_streaming_response.create_webhook_trigger( + agent_name="agent_name", + forward_path="forward_path", + name="name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + webhook = response.parse() + assert_matches_type(WebhookCreateWebhookTriggerResponse, webhook, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncWebhooks: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_webhook_trigger(self, async_client: AsyncAgentex) -> None: + webhook = await async_client.webhooks.create_webhook_trigger( + agent_name="agent_name", + forward_path="forward_path", + name="name", + ) + assert_matches_type(WebhookCreateWebhookTriggerResponse, webhook, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_webhook_trigger_with_all_params(self, async_client: AsyncAgentex) -> None: + webhook = await async_client.webhooks.create_webhook_trigger( + agent_name="agent_name", + forward_path="forward_path", + name="name", + base_url="base_url", + secret="secret", + source="internal", + ) + assert_matches_type(WebhookCreateWebhookTriggerResponse, webhook, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create_webhook_trigger(self, async_client: AsyncAgentex) -> None: + response = await async_client.webhooks.with_raw_response.create_webhook_trigger( + agent_name="agent_name", + forward_path="forward_path", + name="name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + webhook = await response.parse() + assert_matches_type(WebhookCreateWebhookTriggerResponse, webhook, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create_webhook_trigger(self, async_client: AsyncAgentex) -> None: + async with async_client.webhooks.with_streaming_response.create_webhook_trigger( + agent_name="agent_name", + forward_path="forward_path", + name="name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + webhook = await response.parse() + assert_matches_type(WebhookCreateWebhookTriggerResponse, webhook, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/tests/compat/__init__.py b/tests/compat/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/compat/refresh_specs.py b/tests/compat/refresh_specs.py new file mode 100644 index 000000000..396d4afa5 --- /dev/null +++ b/tests/compat/refresh_specs.py @@ -0,0 +1,34 @@ +"""Re-vendor the server OpenAPI specs at the SHAs pinned in manifest.json. +Usage: `python tests/compat/refresh_specs.py` (needs `gh` auth for the source repo).""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +_DIR = Path(__file__).parent / "server_specs" + + +def main() -> None: + manifest = json.loads((_DIR / "manifest.json").read_text()) + repo, path = manifest["source_repo"], manifest["source_path"] + for entry in manifest["specs"]: + content = subprocess.run( + [ + "gh", + "api", + f"repos/{repo}/contents/{path}?ref={entry['sha']}", + "-H", + "Accept: application/vnd.github.raw", + ], + check=True, + capture_output=True, + text=True, + ).stdout + (_DIR / entry["file"]).write_text(content) + print(f"wrote {entry['file']} from {repo}@{entry['sha'][:12]}") + + +if __name__ == "__main__": + main() diff --git a/tests/compat/server_specs/current.yaml b/tests/compat/server_specs/current.yaml new file mode 100644 index 000000000..b122020e4 --- /dev/null +++ b/tests/compat/server_specs/current.yaml @@ -0,0 +1,6741 @@ +openapi: 3.1.0 +info: + title: Agentex API + version: 0.1.0 +paths: + /agents/{agent_id}: + get: + tags: + - Agents + summary: Get Agent by ID + description: Get an agent by its unique ID. + operationId: get_agent_by_id_agents__agent_id__get + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Agent' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Agents + summary: Delete Agent by ID + description: Delete an agent by its unique ID. + operationId: delete_agent_by_id_agents__agent_id__delete + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/name/{agent_name}: + get: + tags: + - Agents + summary: Get Agent by Name + description: Get an agent by its unique name. + operationId: get_agent_by_name_agents_name__agent_name__get + parameters: + - name: agent_name + in: path + required: true + schema: + type: string + title: Agent Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Agent' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Agents + summary: Delete Agent by Name + description: Delete an agent by its unique name. + operationId: delete_agent_by_name_agents_name__agent_name__delete + parameters: + - name: agent_name + in: path + required: true + schema: + type: string + title: Agent Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents: + get: + tags: + - Agents + summary: List Agents + description: List all registered agents, optionally filtered by query parameters. + operationId: list_agents_agents_get + parameters: + - name: task_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: Task ID + title: Task Id + description: Task ID + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + description: Limit + default: 50 + title: Limit + description: Limit + - name: page_number + in: query + required: false + schema: + type: integer + minimum: 1 + description: Page number + default: 1 + title: Page Number + description: Page number + - name: order_by + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: Field to order by + title: Order By + description: Field to order by + - name: order_direction + in: query + required: false + schema: + type: string + description: Order direction (asc or desc) + default: desc + title: Order Direction + description: Order direction (asc or desc) + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Agent' + title: Response List Agents Agents Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/register: + post: + tags: + - Agents + summary: Register Agent + description: Register a new agent or update an existing one. + operationId: register_agent_agents_register_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterAgentRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterAgentResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/register-build: + post: + tags: + - Agents + summary: Register Build + description: Register an agent at build time, before it is deployed, so it can + be permissioned and shared prior to deploy. Idempotent by name. + operationId: register_build_agents_register_build_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterBuildRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Agent' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/forward/name/{agent_name}/{path}: + get: + tags: + - Agents + summary: Forward GET request to agent by name + description: Forward a GET request to an agent by its name. + operationId: forward_get_request_to_agent_agents_forward_name__agent_name___path__get + parameters: + - name: agent_name + in: path + required: true + schema: + type: string + title: Agent Name + - name: path + in: path + required: true + schema: + type: string + title: Path + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + post: + tags: + - Agents + summary: Forward POST request to agent by name + description: Forward a POST request to an agent by its name. + operationId: forward_post_request_to_agent_agents_forward_name__agent_name___path__post + parameters: + - name: agent_name + in: path + required: true + schema: + type: string + title: Agent Name + - name: path + in: path + required: true + schema: + type: string + title: Path + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/{agent_id}/rpc: + post: + tags: + - Agents + summary: Handle Agent RPC by ID + description: Handle JSON-RPC requests for an agent by its unique ID. + operationId: handle_agent_rpc_by_id_agents__agent_id__rpc_post + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AgentRPCRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AgentRPCResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/name/{agent_name}/rpc: + post: + tags: + - Agents + summary: Handle Agent RPC by Name + description: Handle JSON-RPC requests for an agent by its unique name. + operationId: handle_agent_rpc_by_name_agents_name__agent_name__rpc_post + parameters: + - name: agent_name + in: path + required: true + schema: + type: string + title: Agent Name + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AgentRPCRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AgentRPCResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/{task_id}: + get: + tags: + - Tasks + summary: Get Task by ID + description: Get a task by its unique ID. + operationId: get_task_tasks__task_id__get + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + - name: relationships + in: query + required: false + schema: + type: array + items: + $ref: '#/components/schemas/TaskRelationships' + title: Relationships + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/TaskResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Tasks + summary: Delete Task by ID + description: Delete a task by its unique ID. + operationId: delete_task_tasks__task_id__delete + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + put: + tags: + - Tasks + summary: Update Task by ID + description: Update mutable fields for a task by its unique ID. + operationId: update_task_tasks__task_id__put + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateTaskRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Task' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/name/{task_name}: + get: + tags: + - Tasks + summary: Get Task by Name + description: Get a task by its unique name. + operationId: get_task_by_name_tasks_name__task_name__get + parameters: + - name: task_name + in: path + required: true + schema: + type: string + title: Task Name + - name: relationships + in: query + required: false + schema: + type: array + items: + $ref: '#/components/schemas/TaskRelationships' + title: Relationships + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/TaskResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Tasks + summary: Delete Task by Name + description: Delete a task by its unique name. + operationId: delete_task_by_name_tasks_name__task_name__delete + parameters: + - name: task_name + in: path + required: true + schema: + type: string + title: Task Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + put: + tags: + - Tasks + summary: Update Task by Name + description: Update mutable fields for a task by its unique Name. + operationId: update_task_by_name_tasks_name__task_name__put + parameters: + - name: task_name + in: path + required: true + schema: + type: string + title: Task Name + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateTaskRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Task' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks: + get: + tags: + - Tasks + summary: List Tasks + description: List all tasks. + operationId: list_tasks_tasks_get + parameters: + - name: agent_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Agent Id + - name: agent_name + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Agent Name + - name: status + in: query + required: false + schema: + anyOf: + - $ref: '#/components/schemas/TaskStatus' + - type: 'null' + description: Filter tasks by status (e.g. RUNNING, COMPLETED). + title: Status + description: Filter tasks by status (e.g. RUNNING, COMPLETED). + - name: task_metadata + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: 'JSON-encoded object used to filter tasks via JSONB containment. + Example: {"created_by_user_id": "abc-123"}.' + title: Task Metadata + description: 'JSON-encoded object used to filter tasks via JSONB containment. + Example: {"created_by_user_id": "abc-123"}.' + - name: limit + in: query + required: false + schema: + type: integer + default: 50 + title: Limit + - name: page_number + in: query + required: false + schema: + type: integer + default: 1 + title: Page Number + - name: order_by + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Order By + - name: order_direction + in: query + required: false + schema: + type: string + default: desc + title: Order Direction + - name: relationships + in: query + required: false + schema: + type: array + items: + $ref: '#/components/schemas/TaskRelationships' + title: Relationships + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TaskResponse' + title: Response List Tasks Tasks Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/{task_id}/complete: + post: + tags: + - Tasks + summary: Complete Task + description: Mark a running task as completed. + operationId: complete_task_tasks__task_id__complete_post + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + requestBody: + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/TaskStatusReasonRequest' + - type: 'null' + title: Request + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Task' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/{task_id}/fail: + post: + tags: + - Tasks + summary: Fail Task + description: Mark a running task as failed. + operationId: fail_task_tasks__task_id__fail_post + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + requestBody: + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/TaskStatusReasonRequest' + - type: 'null' + title: Request + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Task' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/{task_id}/cancel: + post: + tags: + - Tasks + summary: Cancel Task + description: Mark a running task as canceled. + operationId: cancel_task_tasks__task_id__cancel_post + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + requestBody: + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/TaskStatusReasonRequest' + - type: 'null' + title: Request + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Task' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/{task_id}/terminate: + post: + tags: + - Tasks + summary: Terminate Task + description: Mark a running task as terminated. + operationId: terminate_task_tasks__task_id__terminate_post + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + requestBody: + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/TaskStatusReasonRequest' + - type: 'null' + title: Request + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Task' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/{task_id}/timeout: + post: + tags: + - Tasks + summary: Timeout Task + description: Mark a running task as timed out. + operationId: timeout_task_tasks__task_id__timeout_post + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + requestBody: + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/TaskStatusReasonRequest' + - type: 'null' + title: Request + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Task' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/{task_id}/stream: + get: + tags: + - Tasks + summary: Stream Task Events by ID + description: Stream events for a task by its unique ID. + operationId: stream_task_events_tasks__task_id__stream_get + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/name/{task_name}/stream: + get: + tags: + - Tasks + summary: Stream Task Events by Name + description: Stream events for a task by its unique name. + operationId: stream_task_events_by_name_tasks_name__task_name__stream_get + parameters: + - name: task_name + in: path + required: true + schema: + type: string + title: Task Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/{task_id}/query/{query_name}: + get: + tags: + - Tasks + summary: Query Task Workflow + description: Query a Temporal workflow associated with a task for its current + state. + operationId: query_task_workflow_tasks__task_id__query__query_name__get + parameters: + - name: query_name + in: path + required: true + schema: + type: string + title: Query Name + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Query Task Workflow Tasks Task Id Query Query Name Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /messages/batch: + put: + tags: + - Messages + summary: Batch Update Messages + operationId: batch_update_messages_messages_batch_put + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/BatchUpdateTaskMessagesRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + items: + $ref: '#/components/schemas/TaskMessage' + type: array + title: Response Batch Update Messages Messages Batch Put + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + post: + tags: + - Messages + summary: Batch Create Messages + operationId: batch_create_messages_messages_batch_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/BatchCreateTaskMessagesRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + items: + $ref: '#/components/schemas/TaskMessage' + type: array + title: Response Batch Create Messages Messages Batch Post + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /messages: + post: + tags: + - Messages + summary: Create Message + operationId: create_message_messages_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateTaskMessageRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/TaskMessage' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Messages + summary: List Messages + description: 'List messages for a task with offset-based pagination. + + + For cursor-based pagination with infinite scroll support, use /messages/paginated.' + operationId: list_messages_messages_get + parameters: + - name: limit + in: query + required: false + schema: + type: integer + default: 50 + title: Limit + - name: page_number + in: query + required: false + schema: + type: integer + default: 1 + title: Page Number + - name: order_by + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Order By + - name: order_direction + in: query + required: false + schema: + type: string + default: desc + title: Order Direction + - name: filters + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: "JSON-encoded array of TaskMessageEntityFilter objects.\n\n\ + Schema: {\n \"$defs\": {\n \"DataContentEntityOptional\": {\n \ + \ \"properties\": {\n \"type\": {\n \"anyOf\": [\n \ + \ {\n \"const\": \"data\",\n \"type\"\ + : \"string\"\n },\n {\n \"type\": \"\ + null\"\n }\n ],\n \"default\": null,\n \ + \ \"description\": \"The type of the message, in this case `data`.\"\ + ,\n \"title\": \"Type\"\n },\n \"author\": {\n\ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The role of the messages author, in this case `system`,\ + \ `user`, `assistant`, or `tool`.\"\n },\n \"style\": {\n\ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The style of the message. This is used by the client\ + \ to determine how to display the message.\"\n },\n \"data\"\ + : {\n \"anyOf\": [\n {\n \"additionalProperties\"\ + : true,\n \"type\": \"object\"\n },\n \ + \ {\n \"type\": \"null\"\n }\n ],\n\ + \ \"default\": null,\n \"description\": \"The contents\ + \ of the data message.\",\n \"title\": \"Data\"\n }\n\ + \ },\n \"title\": \"DataContentEntityOptional\",\n \"type\"\ + : \"object\"\n },\n \"FileAttachmentEntity\": {\n \"description\"\ + : \"Represents a file attachment in messages.\",\n \"properties\"\ + : {\n \"file_id\": {\n \"description\": \"The unique ID\ + \ of the attached file\",\n \"title\": \"File Id\",\n \ + \ \"type\": \"string\"\n },\n \"name\": {\n \"\ + description\": \"The name of the file\",\n \"title\": \"Name\"\ + ,\n \"type\": \"string\"\n },\n \"size\": {\n \ + \ \"description\": \"The size of the file in bytes\",\n \ + \ \"title\": \"Size\",\n \"type\": \"integer\"\n },\n\ + \ \"type\": {\n \"description\": \"The MIME type or content\ + \ type of the file\",\n \"title\": \"Type\",\n \"type\"\ + : \"string\"\n }\n },\n \"required\": [\n \"file_id\"\ + ,\n \"name\",\n \"size\",\n \"type\"\n ],\n\ + \ \"title\": \"FileAttachmentEntity\",\n \"type\": \"object\"\ + \n },\n \"MessageAuthor\": {\n \"enum\": [\n \"user\"\ + ,\n \"agent\"\n ],\n \"title\": \"MessageAuthor\",\n\ + \ \"type\": \"string\"\n },\n \"MessageStyle\": {\n \"\ + enum\": [\n \"static\",\n \"active\"\n ],\n \"\ + title\": \"MessageStyle\",\n \"type\": \"string\"\n },\n \"\ + ReasoningContentEntityOptional\": {\n \"properties\": {\n \ + \ \"type\": {\n \"anyOf\": [\n {\n \"\ + const\": \"reasoning\",\n \"type\": \"string\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The type of the message, in this case `reasoning`.\",\n \"\ + title\": \"Type\"\n },\n \"author\": {\n \"anyOf\"\ + : [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\n\ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The role of the messages author, in this case `system`,\ + \ `user`, `assistant`, or `tool`.\"\n },\n \"style\": {\n\ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The style of the message. This is used by the client\ + \ to determine how to display the message.\"\n },\n \"summary\"\ + : {\n \"anyOf\": [\n {\n \"items\": {\n\ + \ \"type\": \"string\"\n },\n \ + \ \"type\": \"array\"\n },\n {\n \"\ + type\": \"null\"\n }\n ],\n \"default\":\ + \ null,\n \"description\": \"A list of short reasoning summaries\"\ + ,\n \"title\": \"Summary\"\n },\n \"content\":\ + \ {\n \"anyOf\": [\n {\n \"items\": {\n\ + \ \"type\": \"string\"\n },\n \ + \ \"type\": \"array\"\n },\n {\n \"\ + type\": \"null\"\n }\n ],\n \"default\":\ + \ null,\n \"description\": \"The reasoning content or chain-of-thought\ + \ text\",\n \"title\": \"Content\"\n }\n },\n \ + \ \"title\": \"ReasoningContentEntityOptional\",\n \"type\": \"\ + object\"\n },\n \"TextContentEntityOptional\": {\n \"properties\"\ + : {\n \"type\": {\n \"anyOf\": [\n {\n \ + \ \"const\": \"text\",\n \"type\": \"string\"\n\ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The type of the message, in this case `text`.\",\n \ + \ \"title\": \"Type\"\n },\n \"author\": {\n \ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The role of the messages author, in this case `system`,\ + \ `user`, `assistant`, or `tool`.\"\n },\n \"style\": {\n\ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The style of the message. This is used by the client\ + \ to determine how to display the message.\"\n },\n \"format\"\ + : {\n \"anyOf\": [\n {\n \"$ref\": \"\ + #/$defs/TextFormat\"\n },\n {\n \"\ + type\": \"null\"\n }\n ],\n \"default\":\ + \ null,\n \"description\": \"The format of the message. This\ + \ is used by the client to determine how to display the message.\"\n \ + \ },\n \"content\": {\n \"anyOf\": [\n \ + \ {\n \"type\": \"string\"\n },\n \ + \ {\n \"type\": \"null\"\n }\n ],\n\ + \ \"default\": null,\n \"description\": \"The contents\ + \ of the text message.\",\n \"title\": \"Content\"\n },\n\ + \ \"attachments\": {\n \"anyOf\": [\n {\n \ + \ \"items\": {\n \"$ref\": \"#/$defs/FileAttachmentEntity\"\ + \n },\n \"type\": \"array\"\n },\n\ + \ {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"\ + Optional list of file attachments with structured metadata.\",\n \ + \ \"title\": \"Attachments\"\n }\n },\n \"title\"\ + : \"TextContentEntityOptional\",\n \"type\": \"object\"\n },\n\ + \ \"TextFormat\": {\n \"enum\": [\n \"markdown\",\n \ + \ \"plain\",\n \"code\"\n ],\n \"title\": \"TextFormat\"\ + ,\n \"type\": \"string\"\n },\n \"ToolRequestContentEntityOptional\"\ + : {\n \"properties\": {\n \"type\": {\n \"anyOf\"\ + : [\n {\n \"const\": \"tool_request\",\n \ + \ \"type\": \"string\"\n },\n {\n \ + \ \"type\": \"null\"\n }\n ],\n \"\ + default\": null,\n \"description\": \"The type of the message,\ + \ in this case `tool_request`.\",\n \"title\": \"Type\"\n \ + \ },\n \"author\": {\n \"anyOf\": [\n {\n\ + \ \"$ref\": \"#/$defs/MessageAuthor\"\n },\n \ + \ {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"\ + The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\"\ + : [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\n\ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The style of the message. This is used by the client\ + \ to determine how to display the message.\"\n },\n \"tool_call_id\"\ + : {\n \"anyOf\": [\n {\n \"type\": \"\ + string\"\n },\n {\n \"type\": \"null\"\ + \n }\n ],\n \"default\": null,\n \ + \ \"description\": \"The ID of the tool call that is being requested.\"\ + ,\n \"title\": \"Tool Call Id\"\n },\n \"name\"\ + : {\n \"anyOf\": [\n {\n \"type\": \"\ + string\"\n },\n {\n \"type\": \"null\"\ + \n }\n ],\n \"default\": null,\n \ + \ \"description\": \"The name of the tool that is being requested.\"\ + ,\n \"title\": \"Name\"\n },\n \"arguments\": {\n\ + \ \"anyOf\": [\n {\n \"additionalProperties\"\ + : true,\n \"type\": \"object\"\n },\n \ + \ {\n \"type\": \"null\"\n }\n ],\n\ + \ \"default\": null,\n \"description\": \"The arguments\ + \ to the tool.\",\n \"title\": \"Arguments\"\n }\n \ + \ },\n \"title\": \"ToolRequestContentEntityOptional\",\n \ + \ \"type\": \"object\"\n },\n \"ToolResponseContentEntityOptional\"\ + : {\n \"properties\": {\n \"type\": {\n \"anyOf\"\ + : [\n {\n \"const\": \"tool_response\",\n \ + \ \"type\": \"string\"\n },\n {\n \ + \ \"type\": \"null\"\n }\n ],\n \"\ + default\": null,\n \"description\": \"The type of the message,\ + \ in this case `tool_response`.\",\n \"title\": \"Type\"\n \ + \ },\n \"author\": {\n \"anyOf\": [\n \ + \ {\n \"$ref\": \"#/$defs/MessageAuthor\"\n },\n\ + \ {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"\ + The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\"\ + : [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\n\ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The style of the message. This is used by the client\ + \ to determine how to display the message.\"\n },\n \"tool_call_id\"\ + : {\n \"anyOf\": [\n {\n \"type\": \"\ + string\"\n },\n {\n \"type\": \"null\"\ + \n }\n ],\n \"default\": null,\n \ + \ \"description\": \"The ID of the tool call that is being responded\ + \ to.\",\n \"title\": \"Tool Call Id\"\n },\n \"\ + name\": {\n \"anyOf\": [\n {\n \"type\"\ + : \"string\"\n },\n {\n \"type\": \"\ + null\"\n }\n ],\n \"default\": null,\n \ + \ \"description\": \"The name of the tool that is being responded\ + \ to.\",\n \"title\": \"Name\"\n },\n \"content\"\ + : {\n \"anyOf\": [\n {},\n {\n \ + \ \"type\": \"null\"\n }\n ],\n \"default\"\ + : null,\n \"description\": \"The result of the tool.\",\n \ + \ \"title\": \"Content\"\n }\n },\n \"title\":\ + \ \"ToolResponseContentEntityOptional\",\n \"type\": \"object\"\n\ + \ }\n },\n \"description\": \"Filter model for TaskMessage - all\ + \ fields optional for flexible filtering.\\n\\nThe `exclude` field determines\ + \ whether this filter is inclusionary or exclusionary.\\nWhen multiple\ + \ filters are provided:\\n- Inclusionary filters (exclude=False) are OR'd\ + \ together\\n- Exclusionary filters (exclude=True) are OR'd together and\ + \ negated with $nor\\n- The two groups are AND'd: (include1 OR include2)\ + \ AND NOT (exclude1 OR exclude2)\",\n \"properties\": {\n \"content\"\ + : {\n \"anyOf\": [\n {\n \"$ref\": \"#/$defs/ToolRequestContentEntityOptional\"\ + \n },\n {\n \"$ref\": \"#/$defs/DataContentEntityOptional\"\ + \n },\n {\n \"$ref\": \"#/$defs/TextContentEntityOptional\"\ + \n },\n {\n \"$ref\": \"#/$defs/ToolResponseContentEntityOptional\"\ + \n },\n {\n \"$ref\": \"#/$defs/ReasoningContentEntityOptional\"\ + \n },\n {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"Filter by message\ + \ content\",\n \"title\": \"Content\"\n },\n \"streaming_status\"\ + : {\n \"anyOf\": [\n {\n \"enum\": [\n \ + \ \"IN_PROGRESS\",\n \"DONE\"\n ],\n \"\ + type\": \"string\"\n },\n {\n \"type\": \"null\"\ + \n }\n ],\n \"default\": null,\n \"description\"\ + : \"Filter by streaming status\",\n \"title\": \"Streaming Status\"\ + \n },\n \"exclude\": {\n \"default\": false,\n \"description\"\ + : \"If true, this filter excludes matching messages\",\n \"title\"\ + : \"Exclude\",\n \"type\": \"boolean\"\n }\n },\n \"title\"\ + : \"TaskMessageEntityFilter\",\n \"type\": \"object\"\n}\n\nEach filter\ + \ can include:\n- `content`: Filter by message content (type, author,\ + \ data fields)\n- `streaming_status`: Filter by status (\"IN_PROGRESS\"\ + \ or \"DONE\")\n- `exclude`: If true, excludes matching messages (default:\ + \ false)\n\nMultiple filters are combined: inclusionary filters (exclude=false)\ + \ are OR'd together,\nexclusionary filters (exclude=true) are OR'd and\ + \ negated, then both groups are AND'd.\n" + title: Filters + description: "JSON-encoded array of TaskMessageEntityFilter objects.\n\nSchema:\ + \ {\n \"$defs\": {\n \"DataContentEntityOptional\": {\n \"properties\"\ + : {\n \"type\": {\n \"anyOf\": [\n {\n \ + \ \"const\": \"data\",\n \"type\": \"string\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The type of the message, in this case `data`.\",\n \"title\"\ + : \"Type\"\n },\n \"author\": {\n \"anyOf\": [\n\ + \ {\n \"$ref\": \"#/$defs/MessageAuthor\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ + \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The style of the message. This is used by the client to determine how\ + \ to display the message.\"\n },\n \"data\": {\n \ + \ \"anyOf\": [\n {\n \"additionalProperties\": true,\n\ + \ \"type\": \"object\"\n },\n {\n \ + \ \"type\": \"null\"\n }\n ],\n \"\ + default\": null,\n \"description\": \"The contents of the data\ + \ message.\",\n \"title\": \"Data\"\n }\n },\n \ + \ \"title\": \"DataContentEntityOptional\",\n \"type\": \"object\"\ + \n },\n \"FileAttachmentEntity\": {\n \"description\": \"Represents\ + \ a file attachment in messages.\",\n \"properties\": {\n \"\ + file_id\": {\n \"description\": \"The unique ID of the attached\ + \ file\",\n \"title\": \"File Id\",\n \"type\": \"string\"\ + \n },\n \"name\": {\n \"description\": \"The name\ + \ of the file\",\n \"title\": \"Name\",\n \"type\": \"\ + string\"\n },\n \"size\": {\n \"description\": \"\ + The size of the file in bytes\",\n \"title\": \"Size\",\n \ + \ \"type\": \"integer\"\n },\n \"type\": {\n \ + \ \"description\": \"The MIME type or content type of the file\",\n \ + \ \"title\": \"Type\",\n \"type\": \"string\"\n }\n\ + \ },\n \"required\": [\n \"file_id\",\n \"name\"\ + ,\n \"size\",\n \"type\"\n ],\n \"title\": \"FileAttachmentEntity\"\ + ,\n \"type\": \"object\"\n },\n \"MessageAuthor\": {\n \"\ + enum\": [\n \"user\",\n \"agent\"\n ],\n \"title\"\ + : \"MessageAuthor\",\n \"type\": \"string\"\n },\n \"MessageStyle\"\ + : {\n \"enum\": [\n \"static\",\n \"active\"\n ],\n\ + \ \"title\": \"MessageStyle\",\n \"type\": \"string\"\n },\n\ + \ \"ReasoningContentEntityOptional\": {\n \"properties\": {\n \ + \ \"type\": {\n \"anyOf\": [\n {\n \ + \ \"const\": \"reasoning\",\n \"type\": \"string\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The type of the message, in this case `reasoning`.\",\n \"\ + title\": \"Type\"\n },\n \"author\": {\n \"anyOf\"\ + : [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\n\ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ + \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The style of the message. This is used by the client to determine how\ + \ to display the message.\"\n },\n \"summary\": {\n \ + \ \"anyOf\": [\n {\n \"items\": {\n \ + \ \"type\": \"string\"\n },\n \"type\":\ + \ \"array\"\n },\n {\n \"type\": \"null\"\ + \n }\n ],\n \"default\": null,\n \"\ + description\": \"A list of short reasoning summaries\",\n \"title\"\ + : \"Summary\"\n },\n \"content\": {\n \"anyOf\":\ + \ [\n {\n \"items\": {\n \"type\"\ + : \"string\"\n },\n \"type\": \"array\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The reasoning content or chain-of-thought text\",\n \"title\"\ + : \"Content\"\n }\n },\n \"title\": \"ReasoningContentEntityOptional\"\ + ,\n \"type\": \"object\"\n },\n \"TextContentEntityOptional\"\ + : {\n \"properties\": {\n \"type\": {\n \"anyOf\":\ + \ [\n {\n \"const\": \"text\",\n \"\ + type\": \"string\"\n },\n {\n \"type\"\ + : \"null\"\n }\n ],\n \"default\": null,\n\ + \ \"description\": \"The type of the message, in this case `text`.\"\ + ,\n \"title\": \"Type\"\n },\n \"author\": {\n \ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ + \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The style of the message. This is used by the client to determine how\ + \ to display the message.\"\n },\n \"format\": {\n \ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/TextFormat\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The format of the message. This is used by the client to determine how\ + \ to display the message.\"\n },\n \"content\": {\n \ + \ \"anyOf\": [\n {\n \"type\": \"string\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The contents of the text message.\",\n \"title\": \"Content\"\ + \n },\n \"attachments\": {\n \"anyOf\": [\n \ + \ {\n \"items\": {\n \"$ref\": \"#/$defs/FileAttachmentEntity\"\ + \n },\n \"type\": \"array\"\n },\n\ + \ {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"Optional\ + \ list of file attachments with structured metadata.\",\n \"title\"\ + : \"Attachments\"\n }\n },\n \"title\": \"TextContentEntityOptional\"\ + ,\n \"type\": \"object\"\n },\n \"TextFormat\": {\n \"enum\"\ + : [\n \"markdown\",\n \"plain\",\n \"code\"\n \ + \ ],\n \"title\": \"TextFormat\",\n \"type\": \"string\"\n \ + \ },\n \"ToolRequestContentEntityOptional\": {\n \"properties\"\ + : {\n \"type\": {\n \"anyOf\": [\n {\n \ + \ \"const\": \"tool_request\",\n \"type\": \"string\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The type of the message, in this case `tool_request`.\",\n \ + \ \"title\": \"Type\"\n },\n \"author\": {\n \"anyOf\"\ + : [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\n\ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ + \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The style of the message. This is used by the client to determine how\ + \ to display the message.\"\n },\n \"tool_call_id\": {\n \ + \ \"anyOf\": [\n {\n \"type\": \"string\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The ID of the tool call that is being requested.\",\n \"title\"\ + : \"Tool Call Id\"\n },\n \"name\": {\n \"anyOf\"\ + : [\n {\n \"type\": \"string\"\n },\n\ + \ {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"The\ + \ name of the tool that is being requested.\",\n \"title\": \"\ + Name\"\n },\n \"arguments\": {\n \"anyOf\": [\n \ + \ {\n \"additionalProperties\": true,\n \ + \ \"type\": \"object\"\n },\n {\n \ + \ \"type\": \"null\"\n }\n ],\n \"default\"\ + : null,\n \"description\": \"The arguments to the tool.\",\n \ + \ \"title\": \"Arguments\"\n }\n },\n \"title\"\ + : \"ToolRequestContentEntityOptional\",\n \"type\": \"object\"\n \ + \ },\n \"ToolResponseContentEntityOptional\": {\n \"properties\"\ + : {\n \"type\": {\n \"anyOf\": [\n {\n \ + \ \"const\": \"tool_response\",\n \"type\": \"string\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The type of the message, in this case `tool_response`.\",\n \ + \ \"title\": \"Type\"\n },\n \"author\": {\n \"\ + anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ + \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The style of the message. This is used by the client to determine how\ + \ to display the message.\"\n },\n \"tool_call_id\": {\n \ + \ \"anyOf\": [\n {\n \"type\": \"string\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The ID of the tool call that is being responded to.\",\n \"\ + title\": \"Tool Call Id\"\n },\n \"name\": {\n \"\ + anyOf\": [\n {\n \"type\": \"string\"\n \ + \ },\n {\n \"type\": \"null\"\n }\n\ + \ ],\n \"default\": null,\n \"description\":\ + \ \"The name of the tool that is being responded to.\",\n \"title\"\ + : \"Name\"\n },\n \"content\": {\n \"anyOf\": [\n\ + \ {},\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The result of the tool.\",\n \"title\": \"Content\"\n \ + \ }\n },\n \"title\": \"ToolResponseContentEntityOptional\"\ + ,\n \"type\": \"object\"\n }\n },\n \"description\": \"Filter\ + \ model for TaskMessage - all fields optional for flexible filtering.\\\ + n\\nThe `exclude` field determines whether this filter is inclusionary or\ + \ exclusionary.\\nWhen multiple filters are provided:\\n- Inclusionary filters\ + \ (exclude=False) are OR'd together\\n- Exclusionary filters (exclude=True)\ + \ are OR'd together and negated with $nor\\n- The two groups are AND'd:\ + \ (include1 OR include2) AND NOT (exclude1 OR exclude2)\",\n \"properties\"\ + : {\n \"content\": {\n \"anyOf\": [\n {\n \"$ref\"\ + : \"#/$defs/ToolRequestContentEntityOptional\"\n },\n {\n\ + \ \"$ref\": \"#/$defs/DataContentEntityOptional\"\n },\n\ + \ {\n \"$ref\": \"#/$defs/TextContentEntityOptional\"\n\ + \ },\n {\n \"$ref\": \"#/$defs/ToolResponseContentEntityOptional\"\ + \n },\n {\n \"$ref\": \"#/$defs/ReasoningContentEntityOptional\"\ + \n },\n {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"Filter by message\ + \ content\",\n \"title\": \"Content\"\n },\n \"streaming_status\"\ + : {\n \"anyOf\": [\n {\n \"enum\": [\n \"\ + IN_PROGRESS\",\n \"DONE\"\n ],\n \"type\":\ + \ \"string\"\n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\": \"Filter\ + \ by streaming status\",\n \"title\": \"Streaming Status\"\n },\n\ + \ \"exclude\": {\n \"default\": false,\n \"description\": \"\ + If true, this filter excludes matching messages\",\n \"title\": \"\ + Exclude\",\n \"type\": \"boolean\"\n }\n },\n \"title\": \"TaskMessageEntityFilter\"\ + ,\n \"type\": \"object\"\n}\n\nEach filter can include:\n- `content`: Filter\ + \ by message content (type, author, data fields)\n- `streaming_status`:\ + \ Filter by status (\"IN_PROGRESS\" or \"DONE\")\n- `exclude`: If true,\ + \ excludes matching messages (default: false)\n\nMultiple filters are combined:\ + \ inclusionary filters (exclude=false) are OR'd together,\nexclusionary\ + \ filters (exclude=true) are OR'd and negated, then both groups are AND'd.\n" + examples: + single_filter: + summary: Filter by content type + value: '{"content": {"type": "text"}}' + multiple_types: + summary: Filter multiple content types (OR) + value: '[{"content": {"type": "text"}}, {"content": {"type": "data"}}]' + with_exclusion: + summary: Include data messages, exclude specific data types + value: '[{"content": {"type": "data"}}, {"content": {"data": {"type": + "error_report"}}, "exclude": true}]' + nested_data: + summary: Filter by nested data field + value: '{"content": {"data": {"type": "report_status_update"}}}' + - name: task_id + in: query + required: true + schema: + type: string + description: The task ID + title: Task Id + description: The task ID + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TaskMessage' + title: Response List Messages Messages Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /messages/{message_id}: + put: + tags: + - Messages + summary: Update Message + operationId: update_message_messages__message_id__put + parameters: + - name: message_id + in: path + required: true + schema: + type: string + title: Message Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateTaskMessageRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/TaskMessage' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Messages + summary: Get Message + operationId: get_message_messages__message_id__get + parameters: + - name: message_id + in: path + required: true + schema: + type: string + title: Message Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/TaskMessage' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /messages/paginated: + get: + tags: + - Messages + summary: List Messages Paginated + description: "List messages for a task with cursor-based pagination.\n\nThis\ + \ endpoint is designed for infinite scroll UIs where new messages may arrive\n\ + while paginating through older ones.\n\nArgs:\n task_id: The task ID to\ + \ filter messages by\n limit: Maximum number of messages to return (default:\ + \ 50)\n cursor: Opaque cursor string for pagination. Pass the `next_cursor`\ + \ from\n a previous response to get the next page.\n direction:\ + \ Pagination direction - \"older\" to get older messages (default),\n \ + \ \"newer\" to get newer messages.\n\nReturns:\n PaginatedMessagesResponse\ + \ with:\n - data: List of messages (newest first when direction=\"older\"\ + )\n - next_cursor: Cursor for fetching the next page (null if no more pages)\n\ + \ - has_more: Whether there are more messages to fetch\n\nExample:\n \ + \ First request: GET /messages/paginated?task_id=xxx&limit=50\n Next page:\ + \ GET /messages/paginated?task_id=xxx&limit=50&cursor=" + operationId: list_messages_paginated_messages_paginated_get + parameters: + - name: limit + in: query + required: false + schema: + type: integer + default: 50 + title: Limit + - name: cursor + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Cursor + - name: direction + in: query + required: false + schema: + enum: + - older + - newer + type: string + default: older + title: Direction + - name: filters + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: "JSON-encoded array of TaskMessageEntityFilter objects.\n\n\ + Schema: {\n \"$defs\": {\n \"DataContentEntityOptional\": {\n \ + \ \"properties\": {\n \"type\": {\n \"anyOf\": [\n \ + \ {\n \"const\": \"data\",\n \"type\"\ + : \"string\"\n },\n {\n \"type\": \"\ + null\"\n }\n ],\n \"default\": null,\n \ + \ \"description\": \"The type of the message, in this case `data`.\"\ + ,\n \"title\": \"Type\"\n },\n \"author\": {\n\ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The role of the messages author, in this case `system`,\ + \ `user`, `assistant`, or `tool`.\"\n },\n \"style\": {\n\ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The style of the message. This is used by the client\ + \ to determine how to display the message.\"\n },\n \"data\"\ + : {\n \"anyOf\": [\n {\n \"additionalProperties\"\ + : true,\n \"type\": \"object\"\n },\n \ + \ {\n \"type\": \"null\"\n }\n ],\n\ + \ \"default\": null,\n \"description\": \"The contents\ + \ of the data message.\",\n \"title\": \"Data\"\n }\n\ + \ },\n \"title\": \"DataContentEntityOptional\",\n \"type\"\ + : \"object\"\n },\n \"FileAttachmentEntity\": {\n \"description\"\ + : \"Represents a file attachment in messages.\",\n \"properties\"\ + : {\n \"file_id\": {\n \"description\": \"The unique ID\ + \ of the attached file\",\n \"title\": \"File Id\",\n \ + \ \"type\": \"string\"\n },\n \"name\": {\n \"\ + description\": \"The name of the file\",\n \"title\": \"Name\"\ + ,\n \"type\": \"string\"\n },\n \"size\": {\n \ + \ \"description\": \"The size of the file in bytes\",\n \ + \ \"title\": \"Size\",\n \"type\": \"integer\"\n },\n\ + \ \"type\": {\n \"description\": \"The MIME type or content\ + \ type of the file\",\n \"title\": \"Type\",\n \"type\"\ + : \"string\"\n }\n },\n \"required\": [\n \"file_id\"\ + ,\n \"name\",\n \"size\",\n \"type\"\n ],\n\ + \ \"title\": \"FileAttachmentEntity\",\n \"type\": \"object\"\ + \n },\n \"MessageAuthor\": {\n \"enum\": [\n \"user\"\ + ,\n \"agent\"\n ],\n \"title\": \"MessageAuthor\",\n\ + \ \"type\": \"string\"\n },\n \"MessageStyle\": {\n \"\ + enum\": [\n \"static\",\n \"active\"\n ],\n \"\ + title\": \"MessageStyle\",\n \"type\": \"string\"\n },\n \"\ + ReasoningContentEntityOptional\": {\n \"properties\": {\n \ + \ \"type\": {\n \"anyOf\": [\n {\n \"\ + const\": \"reasoning\",\n \"type\": \"string\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The type of the message, in this case `reasoning`.\",\n \"\ + title\": \"Type\"\n },\n \"author\": {\n \"anyOf\"\ + : [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\n\ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The role of the messages author, in this case `system`,\ + \ `user`, `assistant`, or `tool`.\"\n },\n \"style\": {\n\ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The style of the message. This is used by the client\ + \ to determine how to display the message.\"\n },\n \"summary\"\ + : {\n \"anyOf\": [\n {\n \"items\": {\n\ + \ \"type\": \"string\"\n },\n \ + \ \"type\": \"array\"\n },\n {\n \"\ + type\": \"null\"\n }\n ],\n \"default\":\ + \ null,\n \"description\": \"A list of short reasoning summaries\"\ + ,\n \"title\": \"Summary\"\n },\n \"content\":\ + \ {\n \"anyOf\": [\n {\n \"items\": {\n\ + \ \"type\": \"string\"\n },\n \ + \ \"type\": \"array\"\n },\n {\n \"\ + type\": \"null\"\n }\n ],\n \"default\":\ + \ null,\n \"description\": \"The reasoning content or chain-of-thought\ + \ text\",\n \"title\": \"Content\"\n }\n },\n \ + \ \"title\": \"ReasoningContentEntityOptional\",\n \"type\": \"\ + object\"\n },\n \"TextContentEntityOptional\": {\n \"properties\"\ + : {\n \"type\": {\n \"anyOf\": [\n {\n \ + \ \"const\": \"text\",\n \"type\": \"string\"\n\ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The type of the message, in this case `text`.\",\n \ + \ \"title\": \"Type\"\n },\n \"author\": {\n \ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The role of the messages author, in this case `system`,\ + \ `user`, `assistant`, or `tool`.\"\n },\n \"style\": {\n\ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The style of the message. This is used by the client\ + \ to determine how to display the message.\"\n },\n \"format\"\ + : {\n \"anyOf\": [\n {\n \"$ref\": \"\ + #/$defs/TextFormat\"\n },\n {\n \"\ + type\": \"null\"\n }\n ],\n \"default\":\ + \ null,\n \"description\": \"The format of the message. This\ + \ is used by the client to determine how to display the message.\"\n \ + \ },\n \"content\": {\n \"anyOf\": [\n \ + \ {\n \"type\": \"string\"\n },\n \ + \ {\n \"type\": \"null\"\n }\n ],\n\ + \ \"default\": null,\n \"description\": \"The contents\ + \ of the text message.\",\n \"title\": \"Content\"\n },\n\ + \ \"attachments\": {\n \"anyOf\": [\n {\n \ + \ \"items\": {\n \"$ref\": \"#/$defs/FileAttachmentEntity\"\ + \n },\n \"type\": \"array\"\n },\n\ + \ {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"\ + Optional list of file attachments with structured metadata.\",\n \ + \ \"title\": \"Attachments\"\n }\n },\n \"title\"\ + : \"TextContentEntityOptional\",\n \"type\": \"object\"\n },\n\ + \ \"TextFormat\": {\n \"enum\": [\n \"markdown\",\n \ + \ \"plain\",\n \"code\"\n ],\n \"title\": \"TextFormat\"\ + ,\n \"type\": \"string\"\n },\n \"ToolRequestContentEntityOptional\"\ + : {\n \"properties\": {\n \"type\": {\n \"anyOf\"\ + : [\n {\n \"const\": \"tool_request\",\n \ + \ \"type\": \"string\"\n },\n {\n \ + \ \"type\": \"null\"\n }\n ],\n \"\ + default\": null,\n \"description\": \"The type of the message,\ + \ in this case `tool_request`.\",\n \"title\": \"Type\"\n \ + \ },\n \"author\": {\n \"anyOf\": [\n {\n\ + \ \"$ref\": \"#/$defs/MessageAuthor\"\n },\n \ + \ {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"\ + The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\"\ + : [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\n\ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The style of the message. This is used by the client\ + \ to determine how to display the message.\"\n },\n \"tool_call_id\"\ + : {\n \"anyOf\": [\n {\n \"type\": \"\ + string\"\n },\n {\n \"type\": \"null\"\ + \n }\n ],\n \"default\": null,\n \ + \ \"description\": \"The ID of the tool call that is being requested.\"\ + ,\n \"title\": \"Tool Call Id\"\n },\n \"name\"\ + : {\n \"anyOf\": [\n {\n \"type\": \"\ + string\"\n },\n {\n \"type\": \"null\"\ + \n }\n ],\n \"default\": null,\n \ + \ \"description\": \"The name of the tool that is being requested.\"\ + ,\n \"title\": \"Name\"\n },\n \"arguments\": {\n\ + \ \"anyOf\": [\n {\n \"additionalProperties\"\ + : true,\n \"type\": \"object\"\n },\n \ + \ {\n \"type\": \"null\"\n }\n ],\n\ + \ \"default\": null,\n \"description\": \"The arguments\ + \ to the tool.\",\n \"title\": \"Arguments\"\n }\n \ + \ },\n \"title\": \"ToolRequestContentEntityOptional\",\n \ + \ \"type\": \"object\"\n },\n \"ToolResponseContentEntityOptional\"\ + : {\n \"properties\": {\n \"type\": {\n \"anyOf\"\ + : [\n {\n \"const\": \"tool_response\",\n \ + \ \"type\": \"string\"\n },\n {\n \ + \ \"type\": \"null\"\n }\n ],\n \"\ + default\": null,\n \"description\": \"The type of the message,\ + \ in this case `tool_response`.\",\n \"title\": \"Type\"\n \ + \ },\n \"author\": {\n \"anyOf\": [\n \ + \ {\n \"$ref\": \"#/$defs/MessageAuthor\"\n },\n\ + \ {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"\ + The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\"\ + : [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\n\ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The style of the message. This is used by the client\ + \ to determine how to display the message.\"\n },\n \"tool_call_id\"\ + : {\n \"anyOf\": [\n {\n \"type\": \"\ + string\"\n },\n {\n \"type\": \"null\"\ + \n }\n ],\n \"default\": null,\n \ + \ \"description\": \"The ID of the tool call that is being responded\ + \ to.\",\n \"title\": \"Tool Call Id\"\n },\n \"\ + name\": {\n \"anyOf\": [\n {\n \"type\"\ + : \"string\"\n },\n {\n \"type\": \"\ + null\"\n }\n ],\n \"default\": null,\n \ + \ \"description\": \"The name of the tool that is being responded\ + \ to.\",\n \"title\": \"Name\"\n },\n \"content\"\ + : {\n \"anyOf\": [\n {},\n {\n \ + \ \"type\": \"null\"\n }\n ],\n \"default\"\ + : null,\n \"description\": \"The result of the tool.\",\n \ + \ \"title\": \"Content\"\n }\n },\n \"title\":\ + \ \"ToolResponseContentEntityOptional\",\n \"type\": \"object\"\n\ + \ }\n },\n \"description\": \"Filter model for TaskMessage - all\ + \ fields optional for flexible filtering.\\n\\nThe `exclude` field determines\ + \ whether this filter is inclusionary or exclusionary.\\nWhen multiple\ + \ filters are provided:\\n- Inclusionary filters (exclude=False) are OR'd\ + \ together\\n- Exclusionary filters (exclude=True) are OR'd together and\ + \ negated with $nor\\n- The two groups are AND'd: (include1 OR include2)\ + \ AND NOT (exclude1 OR exclude2)\",\n \"properties\": {\n \"content\"\ + : {\n \"anyOf\": [\n {\n \"$ref\": \"#/$defs/ToolRequestContentEntityOptional\"\ + \n },\n {\n \"$ref\": \"#/$defs/DataContentEntityOptional\"\ + \n },\n {\n \"$ref\": \"#/$defs/TextContentEntityOptional\"\ + \n },\n {\n \"$ref\": \"#/$defs/ToolResponseContentEntityOptional\"\ + \n },\n {\n \"$ref\": \"#/$defs/ReasoningContentEntityOptional\"\ + \n },\n {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"Filter by message\ + \ content\",\n \"title\": \"Content\"\n },\n \"streaming_status\"\ + : {\n \"anyOf\": [\n {\n \"enum\": [\n \ + \ \"IN_PROGRESS\",\n \"DONE\"\n ],\n \"\ + type\": \"string\"\n },\n {\n \"type\": \"null\"\ + \n }\n ],\n \"default\": null,\n \"description\"\ + : \"Filter by streaming status\",\n \"title\": \"Streaming Status\"\ + \n },\n \"exclude\": {\n \"default\": false,\n \"description\"\ + : \"If true, this filter excludes matching messages\",\n \"title\"\ + : \"Exclude\",\n \"type\": \"boolean\"\n }\n },\n \"title\"\ + : \"TaskMessageEntityFilter\",\n \"type\": \"object\"\n}\n\nEach filter\ + \ can include:\n- `content`: Filter by message content (type, author,\ + \ data fields)\n- `streaming_status`: Filter by status (\"IN_PROGRESS\"\ + \ or \"DONE\")\n- `exclude`: If true, excludes matching messages (default:\ + \ false)\n\nMultiple filters are combined: inclusionary filters (exclude=false)\ + \ are OR'd together,\nexclusionary filters (exclude=true) are OR'd and\ + \ negated, then both groups are AND'd.\n" + title: Filters + description: "JSON-encoded array of TaskMessageEntityFilter objects.\n\nSchema:\ + \ {\n \"$defs\": {\n \"DataContentEntityOptional\": {\n \"properties\"\ + : {\n \"type\": {\n \"anyOf\": [\n {\n \ + \ \"const\": \"data\",\n \"type\": \"string\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The type of the message, in this case `data`.\",\n \"title\"\ + : \"Type\"\n },\n \"author\": {\n \"anyOf\": [\n\ + \ {\n \"$ref\": \"#/$defs/MessageAuthor\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ + \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The style of the message. This is used by the client to determine how\ + \ to display the message.\"\n },\n \"data\": {\n \ + \ \"anyOf\": [\n {\n \"additionalProperties\": true,\n\ + \ \"type\": \"object\"\n },\n {\n \ + \ \"type\": \"null\"\n }\n ],\n \"\ + default\": null,\n \"description\": \"The contents of the data\ + \ message.\",\n \"title\": \"Data\"\n }\n },\n \ + \ \"title\": \"DataContentEntityOptional\",\n \"type\": \"object\"\ + \n },\n \"FileAttachmentEntity\": {\n \"description\": \"Represents\ + \ a file attachment in messages.\",\n \"properties\": {\n \"\ + file_id\": {\n \"description\": \"The unique ID of the attached\ + \ file\",\n \"title\": \"File Id\",\n \"type\": \"string\"\ + \n },\n \"name\": {\n \"description\": \"The name\ + \ of the file\",\n \"title\": \"Name\",\n \"type\": \"\ + string\"\n },\n \"size\": {\n \"description\": \"\ + The size of the file in bytes\",\n \"title\": \"Size\",\n \ + \ \"type\": \"integer\"\n },\n \"type\": {\n \ + \ \"description\": \"The MIME type or content type of the file\",\n \ + \ \"title\": \"Type\",\n \"type\": \"string\"\n }\n\ + \ },\n \"required\": [\n \"file_id\",\n \"name\"\ + ,\n \"size\",\n \"type\"\n ],\n \"title\": \"FileAttachmentEntity\"\ + ,\n \"type\": \"object\"\n },\n \"MessageAuthor\": {\n \"\ + enum\": [\n \"user\",\n \"agent\"\n ],\n \"title\"\ + : \"MessageAuthor\",\n \"type\": \"string\"\n },\n \"MessageStyle\"\ + : {\n \"enum\": [\n \"static\",\n \"active\"\n ],\n\ + \ \"title\": \"MessageStyle\",\n \"type\": \"string\"\n },\n\ + \ \"ReasoningContentEntityOptional\": {\n \"properties\": {\n \ + \ \"type\": {\n \"anyOf\": [\n {\n \ + \ \"const\": \"reasoning\",\n \"type\": \"string\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The type of the message, in this case `reasoning`.\",\n \"\ + title\": \"Type\"\n },\n \"author\": {\n \"anyOf\"\ + : [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\n\ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ + \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The style of the message. This is used by the client to determine how\ + \ to display the message.\"\n },\n \"summary\": {\n \ + \ \"anyOf\": [\n {\n \"items\": {\n \ + \ \"type\": \"string\"\n },\n \"type\":\ + \ \"array\"\n },\n {\n \"type\": \"null\"\ + \n }\n ],\n \"default\": null,\n \"\ + description\": \"A list of short reasoning summaries\",\n \"title\"\ + : \"Summary\"\n },\n \"content\": {\n \"anyOf\":\ + \ [\n {\n \"items\": {\n \"type\"\ + : \"string\"\n },\n \"type\": \"array\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The reasoning content or chain-of-thought text\",\n \"title\"\ + : \"Content\"\n }\n },\n \"title\": \"ReasoningContentEntityOptional\"\ + ,\n \"type\": \"object\"\n },\n \"TextContentEntityOptional\"\ + : {\n \"properties\": {\n \"type\": {\n \"anyOf\":\ + \ [\n {\n \"const\": \"text\",\n \"\ + type\": \"string\"\n },\n {\n \"type\"\ + : \"null\"\n }\n ],\n \"default\": null,\n\ + \ \"description\": \"The type of the message, in this case `text`.\"\ + ,\n \"title\": \"Type\"\n },\n \"author\": {\n \ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ + \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The style of the message. This is used by the client to determine how\ + \ to display the message.\"\n },\n \"format\": {\n \ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/TextFormat\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The format of the message. This is used by the client to determine how\ + \ to display the message.\"\n },\n \"content\": {\n \ + \ \"anyOf\": [\n {\n \"type\": \"string\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The contents of the text message.\",\n \"title\": \"Content\"\ + \n },\n \"attachments\": {\n \"anyOf\": [\n \ + \ {\n \"items\": {\n \"$ref\": \"#/$defs/FileAttachmentEntity\"\ + \n },\n \"type\": \"array\"\n },\n\ + \ {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"Optional\ + \ list of file attachments with structured metadata.\",\n \"title\"\ + : \"Attachments\"\n }\n },\n \"title\": \"TextContentEntityOptional\"\ + ,\n \"type\": \"object\"\n },\n \"TextFormat\": {\n \"enum\"\ + : [\n \"markdown\",\n \"plain\",\n \"code\"\n \ + \ ],\n \"title\": \"TextFormat\",\n \"type\": \"string\"\n \ + \ },\n \"ToolRequestContentEntityOptional\": {\n \"properties\"\ + : {\n \"type\": {\n \"anyOf\": [\n {\n \ + \ \"const\": \"tool_request\",\n \"type\": \"string\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The type of the message, in this case `tool_request`.\",\n \ + \ \"title\": \"Type\"\n },\n \"author\": {\n \"anyOf\"\ + : [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\n\ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ + \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The style of the message. This is used by the client to determine how\ + \ to display the message.\"\n },\n \"tool_call_id\": {\n \ + \ \"anyOf\": [\n {\n \"type\": \"string\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The ID of the tool call that is being requested.\",\n \"title\"\ + : \"Tool Call Id\"\n },\n \"name\": {\n \"anyOf\"\ + : [\n {\n \"type\": \"string\"\n },\n\ + \ {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"The\ + \ name of the tool that is being requested.\",\n \"title\": \"\ + Name\"\n },\n \"arguments\": {\n \"anyOf\": [\n \ + \ {\n \"additionalProperties\": true,\n \ + \ \"type\": \"object\"\n },\n {\n \ + \ \"type\": \"null\"\n }\n ],\n \"default\"\ + : null,\n \"description\": \"The arguments to the tool.\",\n \ + \ \"title\": \"Arguments\"\n }\n },\n \"title\"\ + : \"ToolRequestContentEntityOptional\",\n \"type\": \"object\"\n \ + \ },\n \"ToolResponseContentEntityOptional\": {\n \"properties\"\ + : {\n \"type\": {\n \"anyOf\": [\n {\n \ + \ \"const\": \"tool_response\",\n \"type\": \"string\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The type of the message, in this case `tool_response`.\",\n \ + \ \"title\": \"Type\"\n },\n \"author\": {\n \"\ + anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ + \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The style of the message. This is used by the client to determine how\ + \ to display the message.\"\n },\n \"tool_call_id\": {\n \ + \ \"anyOf\": [\n {\n \"type\": \"string\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The ID of the tool call that is being responded to.\",\n \"\ + title\": \"Tool Call Id\"\n },\n \"name\": {\n \"\ + anyOf\": [\n {\n \"type\": \"string\"\n \ + \ },\n {\n \"type\": \"null\"\n }\n\ + \ ],\n \"default\": null,\n \"description\":\ + \ \"The name of the tool that is being responded to.\",\n \"title\"\ + : \"Name\"\n },\n \"content\": {\n \"anyOf\": [\n\ + \ {},\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The result of the tool.\",\n \"title\": \"Content\"\n \ + \ }\n },\n \"title\": \"ToolResponseContentEntityOptional\"\ + ,\n \"type\": \"object\"\n }\n },\n \"description\": \"Filter\ + \ model for TaskMessage - all fields optional for flexible filtering.\\\ + n\\nThe `exclude` field determines whether this filter is inclusionary or\ + \ exclusionary.\\nWhen multiple filters are provided:\\n- Inclusionary filters\ + \ (exclude=False) are OR'd together\\n- Exclusionary filters (exclude=True)\ + \ are OR'd together and negated with $nor\\n- The two groups are AND'd:\ + \ (include1 OR include2) AND NOT (exclude1 OR exclude2)\",\n \"properties\"\ + : {\n \"content\": {\n \"anyOf\": [\n {\n \"$ref\"\ + : \"#/$defs/ToolRequestContentEntityOptional\"\n },\n {\n\ + \ \"$ref\": \"#/$defs/DataContentEntityOptional\"\n },\n\ + \ {\n \"$ref\": \"#/$defs/TextContentEntityOptional\"\n\ + \ },\n {\n \"$ref\": \"#/$defs/ToolResponseContentEntityOptional\"\ + \n },\n {\n \"$ref\": \"#/$defs/ReasoningContentEntityOptional\"\ + \n },\n {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"Filter by message\ + \ content\",\n \"title\": \"Content\"\n },\n \"streaming_status\"\ + : {\n \"anyOf\": [\n {\n \"enum\": [\n \"\ + IN_PROGRESS\",\n \"DONE\"\n ],\n \"type\":\ + \ \"string\"\n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\": \"Filter\ + \ by streaming status\",\n \"title\": \"Streaming Status\"\n },\n\ + \ \"exclude\": {\n \"default\": false,\n \"description\": \"\ + If true, this filter excludes matching messages\",\n \"title\": \"\ + Exclude\",\n \"type\": \"boolean\"\n }\n },\n \"title\": \"TaskMessageEntityFilter\"\ + ,\n \"type\": \"object\"\n}\n\nEach filter can include:\n- `content`: Filter\ + \ by message content (type, author, data fields)\n- `streaming_status`:\ + \ Filter by status (\"IN_PROGRESS\" or \"DONE\")\n- `exclude`: If true,\ + \ excludes matching messages (default: false)\n\nMultiple filters are combined:\ + \ inclusionary filters (exclude=false) are OR'd together,\nexclusionary\ + \ filters (exclude=true) are OR'd and negated, then both groups are AND'd.\n" + examples: + single_filter: + summary: Filter by content type + value: '{"content": {"type": "text"}}' + multiple_types: + summary: Filter multiple content types (OR) + value: '[{"content": {"type": "text"}}, {"content": {"type": "data"}}]' + with_exclusion: + summary: Include data messages, exclude specific data types + value: '[{"content": {"type": "data"}}, {"content": {"data": {"type": + "error_report"}}, "exclude": true}]' + nested_data: + summary: Filter by nested data field + value: '{"content": {"data": {"type": "report_status_update"}}}' + - name: task_id + in: query + required: true + schema: + type: string + description: The task ID + title: Task Id + description: The task ID + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedMessagesResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /spans: + post: + tags: + - Spans + summary: Create Span + description: Create a new span with the provided parameters + operationId: create_span_spans_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSpanRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Span' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Spans + summary: List Spans + description: List spans, optionally filtered by trace_id and/or task_id + operationId: list_spans_spans_get + parameters: + - name: trace_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Trace Id + - name: task_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Task Id + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 50 + title: Limit + - name: page_number + in: query + required: false + schema: + type: integer + minimum: 1 + default: 1 + title: Page Number + - name: order_by + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Order By + - name: order_direction + in: query + required: false + schema: + type: string + default: desc + title: Order Direction + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Span' + title: Response List Spans Spans Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /spans/{span_id}: + patch: + tags: + - Spans + summary: Partial Update Span + description: Update a span with the provided output data and mark it as complete + operationId: partial_update_span_spans__span_id__patch + parameters: + - name: span_id + in: path + required: true + schema: + type: string + title: Span Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateSpanRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Span' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Spans + summary: Get Span + description: Get a span by ID + operationId: get_span_spans__span_id__get + parameters: + - name: span_id + in: path + required: true + schema: + type: string + title: Span Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Span' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /states: + post: + tags: + - States + summary: Create Task State + operationId: create_task_state_states_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateStateRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/State' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - States + summary: List States + description: List all states, optionally filtered by query parameters. + operationId: filter_states_states_get + parameters: + - name: task_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: Task ID + title: Task Id + description: Task ID + - name: agent_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: Agent ID + title: Agent Id + description: Agent ID + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + description: Limit + default: 50 + title: Limit + description: Limit + - name: page_number + in: query + required: false + schema: + type: integer + minimum: 1 + description: Page number + default: 1 + title: Page Number + description: Page number + - name: order_by + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: Field to order by + title: Order By + description: Field to order by + - name: order_direction + in: query + required: false + schema: + type: string + description: Order direction (asc or desc) + default: desc + title: Order Direction + description: Order direction (asc or desc) + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/State' + title: Response Filter States States Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /states/{state_id}: + get: + tags: + - States + summary: Get State by State ID + description: Get a state by its unique state ID. + operationId: get_state_states__state_id__get + parameters: + - name: state_id + in: path + required: true + schema: + type: string + title: State Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/State' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + put: + tags: + - States + summary: Update Task State + operationId: update_task_state_states__state_id__put + parameters: + - name: state_id + in: path + required: true + schema: + type: string + title: State Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateStateRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/State' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - States + summary: Delete Task State + operationId: delete_task_state_states__state_id__delete + parameters: + - name: state_id + in: path + required: true + schema: + type: string + title: State Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/State' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /events/{event_id}: + get: + tags: + - Events + summary: Get Event + operationId: get_event_events__event_id__get + parameters: + - name: event_id + in: path + required: true + schema: + type: string + title: Event Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Event' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /events: + get: + tags: + - Events + summary: List Events + description: 'List events for a specific task and agent. + + + Optionally filter for events after a specific sequence ID. + + Results are ordered by sequence_id.' + operationId: list_events_events_get + parameters: + - name: last_processed_event_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: Optional event ID to get events after this ID + title: Last Processed Event Id + description: Optional event ID to get events after this ID + - name: limit + in: query + required: false + schema: + anyOf: + - type: integer + maximum: 1000 + minimum: 1 + - type: 'null' + description: Optional limit on number of results + title: Limit + description: Optional limit on number of results + - name: task_id + in: query + required: true + schema: + type: string + description: The task ID to filter events by + title: Task Id + description: The task ID to filter events by + - name: agent_id + in: query + required: true + schema: + type: string + description: The agent ID to filter events by + title: Agent Id + description: The agent ID to filter events by + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Event' + title: Response List Events Events Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tracker/{tracker_id}: + get: + tags: + - Agent Task Tracker + summary: Get Agent Task Tracker + description: Get agent task tracker by tracker ID + operationId: get_agent_task_tracker_tracker__tracker_id__get + parameters: + - name: tracker_id + in: path + required: true + schema: + type: string + title: Tracker Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AgentTaskTracker' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + put: + tags: + - Agent Task Tracker + summary: Update Agent Task Tracker + description: Update agent task tracker by tracker ID + operationId: update_agent_task_tracker_tracker__tracker_id__put + parameters: + - name: tracker_id + in: path + required: true + schema: + type: string + title: Tracker Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateAgentTaskTrackerRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AgentTaskTracker' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tracker: + get: + tags: + - Agent Task Tracker + summary: List Agent Task Trackers + description: List all agent task trackers, optionally filtered by query parameters. + operationId: filter_agent_task_tracker_tracker_get + parameters: + - name: agent_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: Agent ID + title: Agent Id + description: Agent ID + - name: task_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: Task ID + title: Task Id + description: Task ID + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + description: Limit + default: 50 + title: Limit + description: Limit + - name: page_number + in: query + required: false + schema: + type: integer + minimum: 1 + description: Page number + default: 1 + title: Page Number + description: Page number + - name: order_by + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: Field to order by + title: Order By + description: Field to order by + - name: order_direction + in: query + required: false + schema: + type: string + description: Order direction (asc or desc) + default: desc + title: Order Direction + description: Order direction (asc or desc) + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AgentTaskTracker' + title: Response Filter Agent Task Tracker Tracker Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agent_api_keys: + post: + tags: + - Agent APIKeys + summary: Create Api Key + operationId: create_api_key_agent_api_keys_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateAPIKeyRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/CreateAPIKeyResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Agent APIKeys + summary: List API keys for an agent ID + description: List API keys for an agent ID. + operationId: list_agent_api_keys_agent_api_keys_get + parameters: + - name: agent_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Agent Id + - name: agent_name + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Agent Name + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 50 + title: Limit + - name: page_number + in: query + required: false + schema: + type: integer + minimum: 1 + default: 1 + title: Page Number + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AgentAPIKey' + title: Response List Agent Api Keys Agent Api Keys Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agent_api_keys/name/{name}: + get: + tags: + - Agent APIKeys + summary: Return named API key for the agent ID + description: Return named API key for the agent ID. + operationId: get_agent_api_key_by_name_agent_api_keys_name__name__get + parameters: + - name: name + in: path + required: true + schema: + type: string + title: Name + - name: agent_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Agent Id + - name: agent_name + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Agent Name + - name: api_key_type + in: query + required: false + schema: + $ref: '#/components/schemas/AgentAPIKeyType' + default: external + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AgentAPIKey' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agent_api_keys/{id}: + get: + tags: + - Agent APIKeys + summary: Return the API key by ID + description: Return API key by ID. + operationId: get_agent_api_key_agent_api_keys__id__get + parameters: + - name: id + in: path + required: true + schema: + type: string + title: Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AgentAPIKey' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Agent APIKeys + summary: Delete API key by ID + description: Delete API key by ID. + operationId: delete_agent_api_key_agent_api_keys__id__delete + parameters: + - name: id + in: path + required: true + schema: + type: string + title: Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: string + title: Response Delete Agent Api Key Agent Api Keys Id Delete + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agent_api_keys/name/{api_key_name}: + delete: + tags: + - Agent APIKeys + summary: Delete API key by name + description: Delete API key by name. + operationId: delete_agent_api_key_by_name_agent_api_keys_name__api_key_name__delete + parameters: + - name: api_key_name + in: path + required: true + schema: + type: string + title: Api Key Name + - name: agent_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Agent Id + - name: agent_name + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Agent Name + - name: api_key_type + in: query + required: false + schema: + $ref: '#/components/schemas/AgentAPIKeyType' + default: external + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: string + title: Response Delete Agent Api Key By Name Agent Api Keys Name Api + Key Name Delete + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /deployment-history/{deployment_id}: + get: + tags: + - Deployment History + summary: Get Deployment by ID + description: Get a deployment record by its unique ID. + operationId: get_deployment_by_id_deployment_history__deployment_id__get + parameters: + - name: deployment_id + in: path + required: true + schema: + type: string + title: Deployment Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DeploymentHistory' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /deployment-history: + get: + tags: + - Deployment History + summary: List Deployments for an agent + description: List deployment history for an agent. + operationId: list_deployments_deployment_history_get + parameters: + - name: agent_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Agent Id + - name: agent_name + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Agent Name + - name: limit + in: query + required: false + schema: + type: integer + default: 50 + title: Limit + - name: page_number + in: query + required: false + schema: + type: integer + default: 1 + title: Page Number + - name: order_by + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Order By + - name: order_direction + in: query + required: false + schema: + type: string + default: desc + title: Order Direction + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DeploymentHistory' + title: Response List Deployments Deployment History Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/{agent_id}/deployments: + post: + tags: + - Deployments + summary: Create Deployment + description: Create a new deployment record in PENDING status. + operationId: create_deployment_agents__agent_id__deployments_post + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateDeploymentRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Deployment' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Deployments + summary: List Deployments + description: List deployments for an agent, newest first. + operationId: list_deployments_agents__agent_id__deployments_get + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + description: Limit + default: 50 + title: Limit + description: Limit + - name: page_number + in: query + required: false + schema: + type: integer + minimum: 1 + description: Page number + default: 1 + title: Page Number + description: Page number + - name: order_by + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: Field to order by + title: Order By + description: Field to order by + - name: order_direction + in: query + required: false + schema: + type: string + description: Order direction (asc or desc) + default: desc + title: Order Direction + description: Order direction (asc or desc) + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Deployment' + title: Response List Deployments Agents Agent Id Deployments Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/{agent_id}/deployments/{deployment_id}: + get: + tags: + - Deployments + summary: Get Deployment + description: Get a specific deployment by ID. + operationId: get_deployment_agents__agent_id__deployments__deployment_id__get + parameters: + - name: deployment_id + in: path + required: true + schema: + type: string + title: Deployment Id + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Deployment' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Deployments + summary: Delete Deployment + description: Delete a non-production deployment. + operationId: delete_deployment_agents__agent_id__deployments__deployment_id__delete + parameters: + - name: deployment_id + in: path + required: true + schema: + type: string + title: Deployment Id + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/{agent_id}/deployments/{deployment_id}/promote: + post: + tags: + - Deployments + summary: Promote Deployment + description: Promote a deployment to production with atomic cutover. + operationId: promote_deployment_agents__agent_id__deployments__deployment_id__promote_post + parameters: + - name: deployment_id + in: path + required: true + schema: + type: string + title: Deployment Id + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Deployment' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/{agent_id}/deployments/{deployment_id}/rpc: + post: + tags: + - Deployments + summary: Preview RPC + description: Send an RPC request to a specific deployment (for preview testing). + operationId: handle_deployment_rpc_agents__agent_id__deployments__deployment_id__rpc_post + parameters: + - name: deployment_id + in: path + required: true + schema: + type: string + title: Deployment Id + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AgentRPCRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AgentRPCResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/{agent_id}/schedules: + post: + tags: + - Schedules + summary: Create Schedule + description: Create a new schedule for recurring workflow execution for an agent. + operationId: create_schedule_agents__agent_id__schedules_post + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateScheduleRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduleResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Schedules + summary: List Agent Schedules + description: List all schedules for an agent. + operationId: list_schedules_agents__agent_id__schedules_get + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + - name: page_size + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Page Size + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduleListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/{agent_id}/schedules/{schedule_name}: + get: + tags: + - Schedules + summary: Get Schedule + description: Get details of a schedule by its name. + operationId: get_schedule_agents__agent_id__schedules__schedule_name__get + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + - name: schedule_name + in: path + required: true + schema: + type: string + title: Schedule Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduleResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Schedules + summary: Delete Schedule + description: Delete a schedule permanently. + operationId: delete_schedule_agents__agent_id__schedules__schedule_name__delete + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + - name: schedule_name + in: path + required: true + schema: + type: string + title: Schedule Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/{agent_id}/schedules/{schedule_name}/pause: + post: + tags: + - Schedules + summary: Pause Schedule + description: Pause a schedule to stop it from executing. + operationId: pause_schedule_agents__agent_id__schedules__schedule_name__pause_post + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + - name: schedule_name + in: path + required: true + schema: + type: string + title: Schedule Name + requestBody: + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/PauseScheduleRequest' + - type: 'null' + title: Request + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduleResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/{agent_id}/schedules/{schedule_name}/unpause: + post: + tags: + - Schedules + summary: Unpause Schedule + description: Unpause/resume a schedule to allow it to execute again. + operationId: unpause_schedule_agents__agent_id__schedules__schedule_name__unpause_post + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + - name: schedule_name + in: path + required: true + schema: + type: string + title: Schedule Name + requestBody: + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/UnpauseScheduleRequest' + - type: 'null' + title: Request + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduleResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/{agent_id}/schedules/{schedule_name}/trigger: + post: + tags: + - Schedules + summary: Trigger Schedule + description: Trigger a schedule to run immediately, regardless of its regular + schedule. + operationId: trigger_schedule_agents__agent_id__schedules__schedule_name__trigger_post + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + - name: schedule_name + in: path + required: true + schema: + type: string + title: Schedule Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduleResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /checkpoints/get-tuple: + post: + tags: + - Checkpoints + summary: Get Checkpoint Tuple + operationId: get_checkpoint_tuple_checkpoints_get_tuple_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GetCheckpointTupleRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/CheckpointTupleResponse' + - type: 'null' + title: Response Get Checkpoint Tuple Checkpoints Get Tuple Post + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /checkpoints/put: + post: + tags: + - Checkpoints + summary: Put Checkpoint + operationId: put_checkpoint_checkpoints_put_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PutCheckpointRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PutCheckpointResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /checkpoints/put-writes: + post: + tags: + - Checkpoints + summary: Put Writes + operationId: put_writes_checkpoints_put_writes_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PutWritesRequest' + required: true + responses: + '204': + description: Successful Response + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /checkpoints/list: + post: + tags: + - Checkpoints + summary: List Checkpoints + operationId: list_checkpoints_checkpoints_list_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ListCheckpointsRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + items: + $ref: '#/components/schemas/CheckpointListItem' + type: array + title: Response List Checkpoints Checkpoints List Post + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /checkpoints/delete-thread: + post: + tags: + - Checkpoints + summary: Delete Thread + operationId: delete_thread_checkpoints_delete_thread_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteThreadRequest' + required: true + responses: + '204': + description: Successful Response + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/{task_id}/export: + get: + tags: + - task-retention + summary: Export Task + description: 'Build a self-contained snapshot of a task''s content surfaces. + + + Returns the exact payload format that POST /rehydrate accepts, so + + export → clean → rehydrate is a round-trip-equivalent operation.' + operationId: export_task_tasks__task_id__export_get + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ExportTaskResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + post: + tags: + - task-retention + summary: Export Task To Url + description: 'Build the task snapshot and PUT it to a caller-supplied presigned + URL. + + + Use this when the snapshot is too large for a JSON response body (long + + conversations, deep reasoning content, many attachments). The upload URL + + must be https and resolve to a public address — see SSRF guard.' + operationId: export_task_to_url_tasks__task_id__export_post + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExportTaskToUrlRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ExportTaskToUrlResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/{task_id}/clean: + post: + tags: + - task-retention + summary: Clean Task + description: 'Delete content-bearing rows for a stale task. + + + Refuses on active tasks, in-flight workflows, or unprocessed events + + regardless of `force`. The `force=true` flag only bypasses the + + idle-threshold check.' + operationId: clean_task_tasks__task_id__clean_post + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CleanTaskRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/CleanTaskResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/{task_id}/rehydrate: + post: + tags: + - task-retention + summary: Rehydrate Task + description: 'Restore content-bearing rows from a snapshot. + + + Two modes: + + - Inline: caller provides messages and task_states in the request body. + + - URL: caller provides snapshot_url; Agentex downloads and parses it. + + + Refuses if the task isn''t currently in a cleaned state, or if any supplied + + message/state ID already exists in Mongo (catches double-rehydrate).' + operationId: rehydrate_task_tasks__task_id__rehydrate_post + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RehydrateTaskRequest' + responses: + '204': + description: Successful Response + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' +components: + schemas: + ACPType: + type: string + enum: + - sync + - async + - agentic + title: ACPType + Agent: + properties: + id: + type: string + title: Id + description: The unique identifier of the agent. + name: + type: string + title: Name + description: The unique name of the agent. + description: + type: string + title: Description + description: The description of the action. + status: + $ref: '#/components/schemas/AgentStatus' + description: The status of the action, indicating if it's building, ready, + failed, etc. + default: Unknown + acp_type: + $ref: '#/components/schemas/ACPType' + description: The type of the ACP Server (Either sync or async) + status_reason: + anyOf: + - type: string + - type: 'null' + title: Status Reason + description: The reason for the status of the action. + created_at: + type: string + format: date-time + title: Created At + description: The timestamp when the agent was created + updated_at: + type: string + format: date-time + title: Updated At + description: The timestamp when the agent was last updated + registration_metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Registration Metadata + description: The metadata for the agent's registration. + registered_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Registered At + description: The timestamp when the agent was last registered + agent_input_type: + anyOf: + - $ref: '#/components/schemas/AgentInputType' + - type: 'null' + description: The type of input the agent expects. + production_deployment_id: + anyOf: + - type: string + - type: 'null' + title: Production Deployment Id + description: ID of the current production deployment. + type: object + required: + - id + - name + - description + - acp_type + - created_at + - updated_at + title: Agent + AgentAPIKey: + properties: + id: + type: string + title: Id + description: The unique identifier of the agent API key. + agent_id: + type: string + title: Agent Id + description: The UUID of the agent + created_at: + type: string + format: date-time + title: Created At + description: When the agent API key was created + name: + anyOf: + - type: string + - type: 'null' + title: Name + description: The optional name of the agent API key. + api_key_type: + $ref: '#/components/schemas/AgentAPIKeyType' + description: The type of the agent API key (either internal or external) + type: object + required: + - id + - agent_id + - created_at + - name + - api_key_type + title: AgentAPIKey + AgentAPIKeyType: + type: string + enum: + - internal + - external + - github + - slack + title: AgentAPIKeyType + AgentInputType: + type: string + enum: + - text + - json + title: AgentInputType + AgentRPCMethod: + type: string + enum: + - event/send + - task/create + - message/send + - task/cancel + title: AgentRPCMethod + AgentRPCParams: + anyOf: + - $ref: '#/components/schemas/CreateTaskRequest' + - $ref: '#/components/schemas/CancelTaskRequest' + - $ref: '#/components/schemas/SendMessageRequest' + - $ref: '#/components/schemas/SendEventRequest' + title: AgentRPCParams + description: The parameters for the agent RPC request + AgentRPCRequest: + properties: + jsonrpc: + type: string + const: '2.0' + title: Jsonrpc + default: '2.0' + method: + $ref: '#/components/schemas/AgentRPCMethod' + params: + $ref: '#/components/schemas/AgentRPCParams' + id: + anyOf: + - type: integer + - type: string + - type: 'null' + title: Id + type: object + required: + - method + - params + title: AgentRPCRequest + AgentRPCResponse: + properties: + jsonrpc: + type: string + const: '2.0' + title: Jsonrpc + default: '2.0' + result: + $ref: '#/components/schemas/AgentRPCResult' + description: The result of the agent RPC request + error: + anyOf: + - {} + - type: 'null' + title: Error + id: + anyOf: + - type: integer + - type: string + - type: 'null' + title: Id + type: object + required: + - result + title: AgentRPCResponse + AgentRPCResult: + anyOf: + - items: + $ref: '#/components/schemas/TaskMessage' + type: array + - $ref: '#/components/schemas/TaskMessageUpdate' + - $ref: '#/components/schemas/Task' + - $ref: '#/components/schemas/Event' + - type: 'null' + title: AgentRPCResult + AgentStatus: + type: string + enum: + - Ready + - Failed + - Unknown + - Deleted + - Unhealthy + - BuildOnly + title: AgentStatus + AgentTaskTracker: + properties: + id: + type: string + title: Id + description: The UUID of the agent task tracker + agent_id: + type: string + title: Agent Id + description: The UUID of the agent + task_id: + type: string + title: Task Id + description: The UUID of the task + status: + anyOf: + - type: string + - type: 'null' + title: Status + description: Processing status + status_reason: + anyOf: + - type: string + - type: 'null' + title: Status Reason + description: Optional status reason + last_processed_event_id: + anyOf: + - type: string + - type: 'null' + title: Last Processed Event Id + description: The last processed event ID + created_at: + type: string + format: date-time + title: Created At + description: When the agent task tracker was created + updated_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Updated At + description: When the agent task tracker was last updated + type: object + required: + - id + - agent_id + - task_id + - created_at + title: AgentTaskTracker + BatchCreateTaskMessagesRequest: + properties: + task_id: + type: string + title: The unique id of the task to send the messages to + contents: + items: + $ref: '#/components/schemas/TaskMessageContent' + type: array + title: The messages to send to the task. The order of the messages will + be the order they are added to the task. + created_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Optional caller-supplied base creation timestamp for the batch + description: Optional base timestamp. Each message in the batch is stamped + with base + i milliseconds to guarantee unique, monotonic ordering. If + omitted, the server stamps datetime.now(UTC) at insert time. + type: object + required: + - task_id + - contents + title: BatchCreateTaskMessagesRequest + BatchUpdateTaskMessagesRequest: + properties: + task_id: + type: string + title: The unique id of the task to update the messages of + updates: + additionalProperties: + $ref: '#/components/schemas/TaskMessageContent' + type: object + title: The updates to apply to the messages. The key is the TaskMessage + id and the value is the TaskMessageContent to update the message with. + type: object + required: + - task_id + - updates + title: BatchUpdateTaskMessagesRequest + BlobData: + properties: + channel: + type: string + title: Channel name + version: + type: string + title: Channel version + type: + type: string + title: Serialization type tag + blob: + anyOf: + - type: string + - type: 'null' + title: Base64-encoded binary data + type: object + required: + - channel + - version + - type + title: BlobData + BlobResponse: + properties: + channel: + type: string + title: Channel + version: + type: string + title: Version + type: + type: string + title: Type + blob: + anyOf: + - type: string + - type: 'null' + title: Blob + type: object + required: + - channel + - version + - type + title: BlobResponse + CancelTaskRequest: + properties: + task_id: + anyOf: + - type: string + - type: 'null' + title: Task Id + description: The ID of the task to cancel. Either this or task_name must + be provided. + task_name: + anyOf: + - type: string + - type: 'null' + title: Task Name + description: The name of the task to cancel. Either this or task_id must + be provided. + type: object + title: CancelTaskRequest + CheckpointListItem: + properties: + thread_id: + type: string + title: Thread Id + checkpoint_ns: + type: string + title: Checkpoint Ns + checkpoint_id: + type: string + title: Checkpoint Id + parent_checkpoint_id: + anyOf: + - type: string + - type: 'null' + title: Parent Checkpoint Id + checkpoint: + additionalProperties: true + type: object + title: Checkpoint + metadata: + additionalProperties: true + type: object + title: Metadata + type: object + required: + - thread_id + - checkpoint_ns + - checkpoint_id + - checkpoint + - metadata + title: CheckpointListItem + CheckpointTupleResponse: + properties: + thread_id: + type: string + title: Thread Id + checkpoint_ns: + type: string + title: Checkpoint Ns + checkpoint_id: + type: string + title: Checkpoint Id + parent_checkpoint_id: + anyOf: + - type: string + - type: 'null' + title: Parent Checkpoint Id + checkpoint: + additionalProperties: true + type: object + title: Checkpoint + metadata: + additionalProperties: true + type: object + title: Metadata + blobs: + items: + $ref: '#/components/schemas/BlobResponse' + type: array + title: Blobs + pending_writes: + items: + $ref: '#/components/schemas/WriteResponse' + type: array + title: Pending Writes + type: object + required: + - thread_id + - checkpoint_ns + - checkpoint_id + - checkpoint + - metadata + title: CheckpointTupleResponse + CleanTaskRequest: + properties: + force: + type: boolean + title: Force + description: Skip the idle-threshold check. Active-workflow and unprocessed-events + checks still apply. Admin use only. + default: false + idle_days: + type: integer + minimum: 1.0 + title: Idle Days + description: Idle threshold in days (ignored when force=true). + default: 7 + type: object + title: CleanTaskRequest + CleanTaskResponse: + properties: + task_id: + type: string + title: Task Id + cleaned_at: + type: string + format: date-time + title: Cleaned At + messages_deleted: + type: integer + title: Messages Deleted + task_states_deleted: + type: integer + title: Task States Deleted + events_deleted: + type: integer + title: Events Deleted + type: object + required: + - task_id + - cleaned_at + - messages_deleted + - task_states_deleted + - events_deleted + title: CleanTaskResponse + CreateAPIKeyRequest: + properties: + agent_id: + anyOf: + - type: string + - type: 'null' + title: Agent Id + description: The UUID of the agent + agent_name: + anyOf: + - type: string + - type: 'null' + title: Agent Name + description: The name of the agent - if not provided, the agent_id must + be set. + name: + type: string + title: Name + description: The name of the agent's API key. + api_key_type: + $ref: '#/components/schemas/AgentAPIKeyType' + description: The type of the agent API key (external by default). + default: external + api_key: + anyOf: + - type: string + - type: 'null' + title: Api Key + description: Optionally provide the API key value - if not set, one will + be generated. + type: object + required: + - name + title: CreateAPIKeyRequest + CreateAPIKeyResponse: + properties: + id: + type: string + title: Id + description: The unique identifier of the agent API key. + agent_id: + type: string + title: Agent Id + description: The UUID of the agent + created_at: + type: string + format: date-time + title: Created At + description: When the agent API key was created + name: + anyOf: + - type: string + - type: 'null' + title: Name + description: The optional name of the agent API key. + api_key_type: + $ref: '#/components/schemas/AgentAPIKeyType' + description: The type of the created agent API key (external). + api_key: + type: string + title: Api Key + description: The value of the newly created API key. + type: object + required: + - id + - agent_id + - created_at + - name + - api_key_type + - api_key + title: CreateAPIKeyResponse + CreateDeploymentRequest: + properties: + docker_image: + type: string + title: Docker Image + description: Full Docker image URI. + registration_metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Registration Metadata + description: Git/build metadata (commit_hash, branch_name, author_name, + author_email, build_timestamp). + sgp_deploy_id: + anyOf: + - type: string + - type: 'null' + title: Sgp Deploy Id + description: SGP deployment ID. + helm_release_name: + anyOf: + - type: string + - type: 'null' + title: Helm Release Name + description: Helm release name. + type: object + required: + - docker_image + title: CreateDeploymentRequest + CreateScheduleRequest: + properties: + name: + type: string + maxLength: 64 + minLength: 1 + pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$ + title: Schedule Name + description: Human-readable name for the schedule (e.g., 'weekly-profiling'). + Will be combined with agent_id to form the full schedule_id. + workflow_name: + type: string + title: Workflow Name + description: Name of the Temporal workflow to execute (e.g., 'sae-orchestrator') + task_queue: + type: string + title: Task Queue + description: Temporal task queue where the agent's worker is listening + workflow_params: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Workflow Parameters + description: Parameters to pass to the workflow + cron_expression: + anyOf: + - type: string + - type: 'null' + title: Cron Expression + description: Cron expression for scheduling (e.g., '0 0 * * 0' for weekly + on Sunday) + interval_seconds: + anyOf: + - type: integer + minimum: 1.0 + - type: 'null' + title: Interval Seconds + description: Alternative to cron - run every N seconds + execution_timeout_seconds: + anyOf: + - type: integer + minimum: 1.0 + - type: 'null' + title: Execution Timeout + description: Maximum time in seconds for each workflow execution + start_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Start At + description: When the schedule should start being active + end_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: End At + description: When the schedule should stop being active + paused: + type: boolean + title: Paused + description: Whether to create the schedule in a paused state + default: false + type: object + required: + - name + - workflow_name + - task_queue + title: CreateScheduleRequest + description: Request model for creating a new schedule for an agent + CreateSpanRequest: + properties: + id: + anyOf: + - type: string + - type: 'null' + title: Unique Span ID + description: Unique identifier for the span. If not provided, an ID will + be generated. + trace_id: + type: string + title: The trace ID for this span + description: Unique identifier for the trace this span belongs to + task_id: + anyOf: + - type: string + - type: 'null' + title: The task ID this span is associated with + description: ID of the task this span belongs to + parent_id: + anyOf: + - type: string + - type: 'null' + title: The parent span ID if this is a child span + description: ID of the parent span if this is a child span in a trace + name: + type: string + title: The name of the span + description: Name that describes what operation this span represents + start_time: + type: string + format: date-time + title: The start time of the span + description: The time the span started + end_time: + anyOf: + - type: string + format: date-time + - type: 'null' + title: The end time of the span + description: The time the span ended + input: + anyOf: + - additionalProperties: true + type: object + - items: + additionalProperties: true + type: object + type: array + - type: 'null' + title: The input data for the span + description: Input parameters or data for the operation + output: + anyOf: + - additionalProperties: true + type: object + - items: + additionalProperties: true + type: object + type: array + - type: 'null' + title: The output data from the span + description: Output data resulting from the operation + data: + anyOf: + - additionalProperties: true + type: object + - items: + additionalProperties: true + type: object + type: array + - type: 'null' + title: Additional data associated with the span + description: Any additional metadata or context for the span + type: object + required: + - trace_id + - name + - start_time + title: CreateSpanRequest + CreateStateRequest: + properties: + task_id: + type: string + title: The unique id of the task to send the state to + agent_id: + type: string + title: The unique id of the agent to send the state to + state: + additionalProperties: true + type: object + title: The state to send to the task. + type: object + required: + - task_id + - agent_id + - state + title: CreateStateRequest + CreateTaskMessageRequest: + properties: + task_id: + type: string + title: The unique id of the task to send the message to + content: + $ref: '#/components/schemas/TaskMessageContent' + title: The message to send to the task. + streaming_status: + anyOf: + - type: string + enum: + - IN_PROGRESS + - DONE + - type: 'null' + title: The streaming status of the message + created_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Optional caller-supplied creation timestamp + description: Optional timestamp for the message. Workflow callers should + pass workflow.now() (Temporal's deterministic monotonic clock) so that + two awaited messages.create calls from the same workflow are guaranteed + to have monotonic timestamps regardless of HTTP scheduling at the server. + If omitted, the server's wall clock at insert time is used. + type: object + required: + - task_id + - content + title: CreateTaskMessageRequest + CreateTaskRequest: + properties: + name: + anyOf: + - type: string + - type: 'null' + title: Name + description: 'Optional human-readable name for the task. When set it must + be globally unique. task/create is get-or-create by name: reusing an existing + name returns the existing task (with its prior history) instead of creating + a new one, so omit name (or make it unique, e.g. by appending a UUID) + whenever each call should produce a fresh task.' + params: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Params + description: The parameters for the task. On a get-or-create by name, providing + params overwrites the existing task's params (it is not a pure read). + task_metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Task Metadata + description: Caller-provided metadata to persist on the task row. Only applied + at task creation; ignored if a task with this name already exists. Forwarded + to the agent inside the ACP payload for backward compatibility. + type: object + title: CreateTaskRequest + DataContent: + properties: + type: + type: string + const: data + title: Type + description: The type of the message, in this case `data`. + default: data + author: + $ref: '#/components/schemas/MessageAuthor' + description: The role of the messages author, in this case `system`, `user`, + `assistant`, or `tool`. + style: + $ref: '#/components/schemas/MessageStyle' + description: The style of the message. This is used by the client to determine + how to display the message. + default: static + data: + additionalProperties: true + type: object + title: Data + description: The contents of the data message. + type: object + required: + - author + - data + title: DataContent + DataContentEntity: + properties: + type: + type: string + const: data + title: Type + description: The type of the message, in this case `data`. + default: data + author: + $ref: '#/components/schemas/MessageAuthor' + description: The role of the messages author, in this case `system`, `user`, + `assistant`, or `tool`. + style: + $ref: '#/components/schemas/MessageStyle' + description: The style of the message. This is used by the client to determine + how to display the message. + default: static + data: + additionalProperties: true + type: object + title: Data + description: The contents of the data message. + type: object + required: + - author + - data + title: DataContentEntity + DataDelta: + properties: + type: + type: string + const: data + title: Type + default: data + data_delta: + anyOf: + - type: string + - type: 'null' + title: Data Delta + default: '' + type: object + title: DataDelta + description: Delta for data updates + DeleteResponse: + properties: + id: + type: string + title: Id + message: + type: string + title: Message + type: object + required: + - id + - message + title: DeleteResponse + DeleteThreadRequest: + properties: + thread_id: + type: string + title: Thread ID + type: object + required: + - thread_id + title: DeleteThreadRequest + Deployment: + properties: + id: + type: string + title: Id + description: The unique identifier of the deployment. + agent_id: + type: string + title: Agent Id + description: The agent this deployment belongs to. + docker_image: + type: string + title: Docker Image + description: Full Docker image URI. + registration_metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Registration Metadata + description: Git/build metadata from the agent pod. + status: + $ref: '#/components/schemas/DeploymentStatus' + description: Current deployment status. + acp_url: + anyOf: + - type: string + - type: 'null' + title: Acp Url + description: ACP URL set when agent registers. + is_production: + type: boolean + title: Is Production + description: Whether this is the production deployment. + sgp_deploy_id: + anyOf: + - type: string + - type: 'null' + title: Sgp Deploy Id + description: Correlates to SGP's agentex_deploys.id. + helm_release_name: + anyOf: + - type: string + - type: 'null' + title: Helm Release Name + description: Helm release name for cleanup. + created_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Created At + description: When the deployment was created. + promoted_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Promoted At + description: When promoted to production. + expires_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Expires At + description: When marked for cleanup. + type: object + required: + - id + - agent_id + - docker_image + - status + - is_production + title: Deployment + DeploymentHistory: + properties: + id: + type: string + title: Id + description: The unique identifier of the deployment record + agent_id: + type: string + title: Agent Id + description: The ID of the agent this deployment belongs to + author_name: + type: string + title: Author Name + description: Name of the commit author + author_email: + type: string + title: Author Email + description: Email of the commit author + branch_name: + type: string + title: Branch Name + description: Name of the branch + build_timestamp: + type: string + format: date-time + title: Build Timestamp + description: When the build was created + deployment_timestamp: + type: string + format: date-time + title: Deployment Timestamp + description: When this deployment was first seen in the system + commit_hash: + type: string + title: Commit Hash + description: Git commit hash for this deployment + type: object + required: + - id + - agent_id + - author_name + - author_email + - branch_name + - build_timestamp + - deployment_timestamp + - commit_hash + title: DeploymentHistory + description: API schema for deployment history. + DeploymentStatus: + type: string + enum: + - Pending + - Ready + - Failed + title: DeploymentStatus + Event: + properties: + id: + type: string + title: Id + description: The UUID of the event + sequence_id: + type: integer + title: Sequence Id + description: The sequence ID of the event + task_id: + type: string + title: Task Id + description: The UUID of the task that the event belongs to + agent_id: + type: string + title: Agent Id + description: The UUID of the agent that the event belongs to + created_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Created At + description: The timestamp of the event + content: + anyOf: + - $ref: '#/components/schemas/TaskMessageContent' + - type: 'null' + description: The content of the event + type: object + required: + - id + - sequence_id + - task_id + - agent_id + title: Event + ExportTaskResponse: + properties: + task_id: + type: string + title: Task Id + messages: + items: + $ref: '#/components/schemas/TaskMessageEntity' + type: array + title: Messages + task_states: + items: + $ref: '#/components/schemas/StateEntity' + type: array + title: Task States + type: object + required: + - task_id + title: ExportTaskResponse + description: Wire format mirrors the entity directly — schema parity is intentional. + ExportTaskToUrlRequest: + properties: + upload_url: + type: string + maxLength: 2083 + minLength: 1 + format: uri + title: Upload Url + description: Presigned PUT URL where Agentex will upload the task snapshot + as JSON. Must be https; must resolve to a public address. + type: object + required: + - upload_url + title: ExportTaskToUrlRequest + ExportTaskToUrlResponse: + properties: + task_id: + type: string + title: Task Id + upload_url: + type: string + title: Upload Url + uploaded_bytes: + type: integer + title: Uploaded Bytes + messages_count: + type: integer + title: Messages Count + task_states_count: + type: integer + title: Task States Count + type: object + required: + - task_id + - upload_url + - uploaded_bytes + - messages_count + - task_states_count + title: ExportTaskToUrlResponse + FileAttachment: + properties: + file_id: + type: string + title: File Id + description: The unique ID of the attached file + name: + type: string + title: Name + description: The name of the file + size: + type: integer + title: Size + description: The size of the file in bytes + type: + type: string + title: Type + description: The MIME type or content type of the file + type: object + required: + - file_id + - name + - size + - type + title: FileAttachment + description: Represents a file attachment in messages. + FileAttachmentEntity: + properties: + file_id: + type: string + title: File Id + description: The unique ID of the attached file + name: + type: string + title: Name + description: The name of the file + size: + type: integer + title: Size + description: The size of the file in bytes + type: + type: string + title: Type + description: The MIME type or content type of the file + type: object + required: + - file_id + - name + - size + - type + title: FileAttachmentEntity + description: Represents a file attachment in messages. + GetCheckpointTupleRequest: + properties: + thread_id: + type: string + title: Thread ID + checkpoint_ns: + type: string + title: Checkpoint namespace + default: '' + checkpoint_id: + anyOf: + - type: string + - type: 'null' + title: Checkpoint ID (None = latest) + type: object + required: + - thread_id + title: GetCheckpointTupleRequest + HTTPValidationError: + properties: + detail: + items: + $ref: '#/components/schemas/ValidationError' + type: array + title: Detail + type: object + title: HTTPValidationError + ListCheckpointsRequest: + properties: + thread_id: + type: string + title: Thread ID + checkpoint_ns: + anyOf: + - type: string + - type: 'null' + title: Checkpoint namespace + before_checkpoint_id: + anyOf: + - type: string + - type: 'null' + title: Before checkpoint ID + filter_metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Metadata filter (JSONB @>) + limit: + type: integer + maximum: 1000.0 + minimum: 1.0 + title: Max results + default: 100 + type: object + required: + - thread_id + title: ListCheckpointsRequest + MessageAuthor: + type: string + enum: + - user + - agent + title: MessageAuthor + MessageStyle: + type: string + enum: + - static + - active + title: MessageStyle + PaginatedMessagesResponse: + properties: + data: + items: + $ref: '#/components/schemas/TaskMessage' + type: array + title: Data + description: List of messages + next_cursor: + anyOf: + - type: string + - type: 'null' + title: Next Cursor + description: Cursor for fetching the next page of older messages + has_more: + type: boolean + title: Has More + description: Whether there are more messages to fetch + default: false + type: object + required: + - data + title: PaginatedMessagesResponse + description: Response with cursor pagination metadata. + PauseScheduleRequest: + properties: + note: + anyOf: + - type: string + - type: 'null' + title: Note + description: Optional note explaining why the schedule was paused + type: object + title: PauseScheduleRequest + description: Request model for pausing a schedule + PutCheckpointRequest: + properties: + thread_id: + type: string + title: Thread ID + checkpoint_ns: + type: string + title: Checkpoint namespace + default: '' + checkpoint_id: + type: string + title: Checkpoint ID + parent_checkpoint_id: + anyOf: + - type: string + - type: 'null' + title: Parent checkpoint ID + checkpoint: + additionalProperties: true + type: object + title: Checkpoint JSONB payload + metadata: + additionalProperties: true + type: object + title: Checkpoint metadata + blobs: + items: + $ref: '#/components/schemas/BlobData' + type: array + title: Channel blob data + type: object + required: + - thread_id + - checkpoint_id + - checkpoint + title: PutCheckpointRequest + PutCheckpointResponse: + properties: + thread_id: + type: string + title: Thread Id + checkpoint_ns: + type: string + title: Checkpoint Ns + checkpoint_id: + type: string + title: Checkpoint Id + type: object + required: + - thread_id + - checkpoint_ns + - checkpoint_id + title: PutCheckpointResponse + PutWritesRequest: + properties: + thread_id: + type: string + title: Thread ID + checkpoint_ns: + type: string + title: Checkpoint namespace + default: '' + checkpoint_id: + type: string + title: Checkpoint ID + writes: + items: + $ref: '#/components/schemas/WriteData' + type: array + title: Write data + upsert: + type: boolean + title: Upsert mode + default: false + type: object + required: + - thread_id + - checkpoint_id + - writes + title: PutWritesRequest + ReasoningContent: + properties: + type: + type: string + const: reasoning + title: Type + description: The type of the message, in this case `reasoning`. + default: reasoning + author: + $ref: '#/components/schemas/MessageAuthor' + description: The role of the messages author, in this case `system`, `user`, + `assistant`, or `tool`. + style: + $ref: '#/components/schemas/MessageStyle' + description: The style of the message. This is used by the client to determine + how to display the message. + default: static + summary: + items: + type: string + type: array + title: Summary + description: A list of short reasoning summaries + content: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Content + description: The reasoning content or chain-of-thought text + type: object + required: + - author + - summary + title: ReasoningContent + ReasoningContentDelta: + properties: + type: + type: string + const: reasoning_content + title: Type + default: reasoning_content + content_index: + type: integer + title: Content Index + content_delta: + anyOf: + - type: string + - type: 'null' + title: Content Delta + default: '' + type: object + required: + - content_index + title: ReasoningContentDelta + description: Delta for reasoning content updates + ReasoningContentEntity: + properties: + type: + type: string + const: reasoning + title: Type + description: The type of the message, in this case `reasoning`. + default: reasoning + author: + $ref: '#/components/schemas/MessageAuthor' + description: The role of the messages author, in this case `system`, `user`, + `assistant`, or `tool`. + style: + $ref: '#/components/schemas/MessageStyle' + description: The style of the message. This is used by the client to determine + how to display the message. + default: static + summary: + items: + type: string + type: array + title: Summary + description: A list of short reasoning summaries + content: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Content + description: The reasoning content or chain-of-thought text + type: object + required: + - author + - summary + title: ReasoningContentEntity + ReasoningSummaryDelta: + properties: + type: + type: string + const: reasoning_summary + title: Type + default: reasoning_summary + summary_index: + type: integer + title: Summary Index + summary_delta: + anyOf: + - type: string + - type: 'null' + title: Summary Delta + default: '' + type: object + required: + - summary_index + title: ReasoningSummaryDelta + description: Delta for reasoning summary updates + RegisterAgentRequest: + properties: + name: + type: string + pattern: ^[a-z0-9-]+$ + title: Name + description: The unique name of the agent. + description: + type: string + title: Description + description: The description of the agent. + acp_url: + type: string + title: Acp Url + description: The URL of the ACP server for the agent. + agent_id: + anyOf: + - type: string + - type: 'null' + title: Agent Id + description: Optional agent ID if the agent already exists and needs to + be updated. + acp_type: + $ref: '#/components/schemas/ACPType' + description: The type of ACP to use for the agent. + registration_metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Registration Metadata + description: The metadata for the agent's registration. + agent_input_type: + anyOf: + - $ref: '#/components/schemas/AgentInputType' + - type: 'null' + description: The type of input the agent expects. + type: object + required: + - name + - description + - acp_url + - acp_type + title: RegisterAgentRequest + RegisterAgentResponse: + properties: + id: + type: string + title: Id + description: The unique identifier of the agent. + name: + type: string + title: Name + description: The unique name of the agent. + description: + type: string + title: Description + description: The description of the action. + status: + $ref: '#/components/schemas/AgentStatus' + description: The status of the action, indicating if it's building, ready, + failed, etc. + default: Unknown + acp_type: + $ref: '#/components/schemas/ACPType' + description: The type of the ACP Server (Either sync or async) + status_reason: + anyOf: + - type: string + - type: 'null' + title: Status Reason + description: The reason for the status of the action. + created_at: + type: string + format: date-time + title: Created At + description: The timestamp when the agent was created + updated_at: + type: string + format: date-time + title: Updated At + description: The timestamp when the agent was last updated + registration_metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Registration Metadata + description: The metadata for the agent's registration. + registered_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Registered At + description: The timestamp when the agent was last registered + agent_input_type: + anyOf: + - $ref: '#/components/schemas/AgentInputType' + - type: 'null' + description: The type of input the agent expects. + production_deployment_id: + anyOf: + - type: string + - type: 'null' + title: Production Deployment Id + description: ID of the current production deployment. + agent_api_key: + anyOf: + - type: string + - type: 'null' + title: Agent Api Key + description: The API key for the agent, if applicable. + type: object + required: + - id + - name + - description + - acp_type + - created_at + - updated_at + title: RegisterAgentResponse + description: Response model for registering an agent. + RegisterBuildRequest: + properties: + name: + type: string + pattern: ^[a-z0-9-]+$ + title: Name + description: The unique name of the agent. + description: + type: string + title: Description + description: The description of the agent. + registration_metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Registration Metadata + description: The metadata for the agent's build registration. + agent_input_type: + anyOf: + - $ref: '#/components/schemas/AgentInputType' + - type: 'null' + description: The type of input the agent expects. + type: object + required: + - name + - description + title: RegisterBuildRequest + description: 'Request model for registering an agent at build time (pre-deploy). + + + Unlike RegisterAgentRequest, there is no acp_url (the agent is not running + + yet) and no acp_type is required. The created agent is left in BUILD_ONLY + + status so it can be permissioned/shared before it is deployed.' + RehydrateTaskRequest: + properties: + task_id: + type: string + title: Task Id + messages: + items: + $ref: '#/components/schemas/TaskMessageEntity' + type: array + title: Messages + task_states: + items: + $ref: '#/components/schemas/StateEntity' + type: array + title: Task States + snapshot_url: + anyOf: + - type: string + maxLength: 2083 + minLength: 1 + format: uri + - type: 'null' + title: Snapshot Url + description: Presigned GET URL whose body is a JSON-encoded TaskSnapshotEntity. + Must be https; must resolve to a public address. When set, messages/task_states + must be empty. + type: object + required: + - task_id + title: RehydrateTaskRequest + description: 'Either provide inline content (messages + task_states) or a snapshot_url + + pointing at a presigned JSON download. Mixing both is rejected. + + + The inline form is the canonical shape used by export''s GET response, so + + snapshot → clean → rehydrate round-trips cleanly without serialization + + changes.' + ScheduleActionInfo: + properties: + workflow_name: + type: string + title: Workflow Name + description: Name of the workflow being executed + workflow_id_prefix: + type: string + title: Workflow ID Prefix + description: Prefix for workflow execution IDs + task_queue: + type: string + title: Task Queue + description: Task queue for the workflow + workflow_params: + anyOf: + - items: {} + type: array + - type: 'null' + title: Workflow Parameters + description: Parameters passed to the workflow + type: object + required: + - workflow_name + - workflow_id_prefix + - task_queue + title: ScheduleActionInfo + description: Information about the scheduled action + ScheduleListItem: + properties: + schedule_id: + type: string + title: Schedule ID + description: Unique identifier for the schedule + name: + type: string + title: Schedule Name + description: Human-readable name for the schedule + agent_id: + type: string + title: Agent ID + description: ID of the agent this schedule belongs to + state: + $ref: '#/components/schemas/ScheduleState' + title: State + description: Current state of the schedule + workflow_name: + anyOf: + - type: string + - type: 'null' + title: Workflow Name + description: Name of the scheduled workflow + next_action_time: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Next Action Time + description: Next scheduled execution time + type: object + required: + - schedule_id + - name + - agent_id + - state + title: ScheduleListItem + description: Abbreviated schedule info for list responses + ScheduleListResponse: + properties: + schedules: + items: + $ref: '#/components/schemas/ScheduleListItem' + type: array + title: Schedules + description: List of schedules + total: + type: integer + title: Total + description: Total number of schedules + type: object + required: + - schedules + - total + title: ScheduleListResponse + description: Response model for listing schedules + ScheduleResponse: + properties: + schedule_id: + type: string + title: Schedule ID + description: Unique identifier for the schedule + name: + type: string + title: Schedule Name + description: Human-readable name for the schedule + agent_id: + type: string + title: Agent ID + description: ID of the agent this schedule belongs to + state: + $ref: '#/components/schemas/ScheduleState' + title: State + description: Current state of the schedule + action: + $ref: '#/components/schemas/ScheduleActionInfo' + title: Action + spec: + $ref: '#/components/schemas/ScheduleSpecInfo' + title: Spec + description: Schedule specification + num_actions_taken: + type: integer + title: Number of Actions Taken + description: Number of times the schedule has executed + default: 0 + num_actions_missed: + type: integer + title: Number of Actions Missed + description: Number of scheduled executions that were missed + default: 0 + next_action_times: + items: + type: string + format: date-time + type: array + title: Next Action Times + description: Upcoming scheduled execution times + last_action_time: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Last Action Time + description: When the schedule last executed + created_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Created At + description: When the schedule was created + type: object + required: + - schedule_id + - name + - agent_id + - state + - action + - spec + title: ScheduleResponse + description: Response model for schedule operations + ScheduleSpecInfo: + properties: + cron_expressions: + items: + type: string + type: array + title: Cron Expressions + description: Cron expressions for the schedule + intervals_seconds: + items: + type: integer + type: array + title: Interval Seconds + description: Interval specifications in seconds + start_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Start At + description: When the schedule starts being active + end_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: End At + description: When the schedule stops being active + type: object + title: ScheduleSpecInfo + description: Information about the schedule specification + ScheduleState: + type: string + enum: + - ACTIVE + - PAUSED + title: ScheduleState + description: Schedule state enum + SendEventRequest: + properties: + task_id: + anyOf: + - type: string + - type: 'null' + title: Task Id + description: The ID of the task that the event was sent to + task_name: + anyOf: + - type: string + - type: 'null' + title: Task Name + description: The name of the task that the event was sent to + content: + anyOf: + - $ref: '#/components/schemas/TaskMessageContent' + - type: 'null' + description: The content to send to the event + type: object + title: SendEventRequest + SendMessageRequest: + properties: + task_id: + anyOf: + - type: string + - type: 'null' + title: Task Id + description: The ID of the task that the message was sent to + task_name: + anyOf: + - type: string + - type: 'null' + title: Task Name + description: The name of the task that the message was sent to + content: + $ref: '#/components/schemas/TaskMessageContent' + description: The message that was sent to the agent + stream: + type: boolean + title: Stream + description: Whether to stream the response message back to the client + default: false + task_params: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Task Params + description: The parameters for the task (only used when creating new tasks) + type: object + required: + - content + title: SendMessageRequest + Span: + properties: + id: + type: string + title: Unique Span ID + trace_id: + type: string + title: The trace ID for this span + description: Unique identifier for the trace this span belongs to + task_id: + anyOf: + - type: string + - type: 'null' + title: The task ID this span is associated with + description: ID of the task this span belongs to + parent_id: + anyOf: + - type: string + - type: 'null' + title: The parent span ID if this is a child span + description: ID of the parent span if this is a child span in a trace + name: + type: string + title: The name of the span + description: Name that describes what operation this span represents + start_time: + type: string + format: date-time + title: The start time of the span + description: The time the span started + end_time: + anyOf: + - type: string + format: date-time + - type: 'null' + title: The end time of the span + description: The time the span ended + input: + anyOf: + - additionalProperties: true + type: object + - items: + additionalProperties: true + type: object + type: array + - type: 'null' + title: The input data for the span + description: Input parameters or data for the operation + output: + anyOf: + - additionalProperties: true + type: object + - items: + additionalProperties: true + type: object + type: array + - type: 'null' + title: The output data from the span + description: Output data resulting from the operation + data: + anyOf: + - additionalProperties: true + type: object + - items: + additionalProperties: true + type: object + type: array + - type: 'null' + title: Additional data associated with the span + description: Any additional metadata or context for the span + type: object + required: + - id + - trace_id + - name + - start_time + title: Span + State: + properties: + task_id: + type: string + title: The unique id of the task to send the state to + agent_id: + type: string + title: The unique id of the agent to send the state to + state: + additionalProperties: true + type: object + title: The state to send to the task. + id: + type: string + title: Id + description: The task state's unique id + created_at: + type: string + format: date-time + title: Created At + description: The timestamp when the state was created + updated_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Updated At + description: The timestamp when the state was last updated + type: object + required: + - task_id + - agent_id + - state + - id + - created_at + title: State + description: 'Represents a state in the agent system. A state is associated + uniquely with a task and an agent. + + + This entity is used to store states in MongoDB, with each state + + associated with a specific task and agent. The combination of task_id and + agent_id is globally unique. + + + The state is a dictionary of arbitrary data.' + StateEntity: + properties: + id: + anyOf: + - type: string + - type: 'null' + title: Id + description: The task state's unique id + task_id: + type: string + title: Task Id + description: ID of the task this state belongs to. The combination of task_id + and agent_id is globally unique. + agent_id: + type: string + title: Agent Id + description: ID of the agent this state belongs to. The combination of task_id + and agent_id is globally unique. + state: + additionalProperties: true + type: object + title: State + description: The state object that contains arbitrary data + created_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Created At + description: The timestamp when the state was created + updated_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Updated At + description: The timestamp when the state was last updated + type: object + required: + - task_id + - agent_id + - state + title: StateEntity + description: 'Represents a state in the agent system. A state is associated + uniquely with a task and an agent. + + + This entity is used to store states in MongoDB, with each state + + associated with a specific task and agent. The combination of task_id and + agent_id is globally unique. + + + The state is a dictionary of arbitrary data.' + StreamTaskMessageDelta: + properties: + type: + type: string + const: delta + title: Type + default: delta + index: + anyOf: + - type: integer + - type: 'null' + title: Index + parent_task_message: + anyOf: + - $ref: '#/components/schemas/TaskMessage' + - type: 'null' + delta: + anyOf: + - $ref: '#/components/schemas/TaskMessageDelta' + - type: 'null' + type: object + title: StreamTaskMessageDelta + description: Event for streaming chunks of content + StreamTaskMessageDone: + properties: + type: + type: string + const: done + title: Type + default: done + index: + anyOf: + - type: integer + - type: 'null' + title: Index + parent_task_message: + anyOf: + - $ref: '#/components/schemas/TaskMessage' + - type: 'null' + type: object + title: StreamTaskMessageDone + description: Event for indicating the task is done + StreamTaskMessageFull: + properties: + type: + type: string + const: full + title: Type + default: full + index: + anyOf: + - type: integer + - type: 'null' + title: Index + parent_task_message: + anyOf: + - $ref: '#/components/schemas/TaskMessage' + - type: 'null' + content: + $ref: '#/components/schemas/TaskMessageContent' + type: object + required: + - content + title: StreamTaskMessageFull + description: Event for streaming the full content + StreamTaskMessageStart: + properties: + type: + type: string + const: start + title: Type + default: start + index: + anyOf: + - type: integer + - type: 'null' + title: Index + parent_task_message: + anyOf: + - $ref: '#/components/schemas/TaskMessage' + - type: 'null' + content: + $ref: '#/components/schemas/TaskMessageContent' + type: object + required: + - content + title: StreamTaskMessageStart + description: Event for starting a streaming message + Task: + properties: + id: + type: string + title: Unique Task ID + name: + anyOf: + - type: string + - type: 'null' + title: Unique name of the task + status: + anyOf: + - $ref: '#/components/schemas/TaskStatus' + - type: 'null' + title: The current status of the task + status_reason: + anyOf: + - type: string + - type: 'null' + title: The reason for the current task status + created_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: The timestamp when the task was created + updated_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: The timestamp when the task was last updated + cleaned_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: The timestamp when the task's content was cleaned for retention compliance; + null when active + params: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Task parameters + task_metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Task metadata + type: object + required: + - id + title: Task + TaskMessage: + properties: + id: + anyOf: + - type: string + - type: 'null' + title: Id + description: The task message's unique id + task_id: + type: string + title: Task Id + description: ID of the task this message belongs to + content: + $ref: '#/components/schemas/TaskMessageContent' + description: The content of the message. This content is not OpenAI compatible. + These are messages that are meant to be displayed to the user. + streaming_status: + anyOf: + - type: string + enum: + - IN_PROGRESS + - DONE + - type: 'null' + title: In case of streaming, this indicates whether the message is still + being streamed or has been completed + created_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Created At + description: The timestamp when the message was created + updated_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Updated At + description: The timestamp when the message was last updated + type: object + required: + - task_id + - content + title: TaskMessage + description: 'Represents a message in the agent system. + + + This entity is used to store messages in MongoDB, with each message + + associated with a specific task.' + TaskMessageContent: + oneOf: + - $ref: '#/components/schemas/TextContent' + - $ref: '#/components/schemas/ReasoningContent' + - $ref: '#/components/schemas/DataContent' + - $ref: '#/components/schemas/ToolRequestContent' + - $ref: '#/components/schemas/ToolResponseContent' + title: TaskMessageContent + discriminator: + propertyName: type + mapping: + data: '#/components/schemas/DataContent' + reasoning: '#/components/schemas/ReasoningContent' + text: '#/components/schemas/TextContent' + tool_request: '#/components/schemas/ToolRequestContent' + tool_response: '#/components/schemas/ToolResponseContent' + TaskMessageDelta: + oneOf: + - $ref: '#/components/schemas/TextDelta' + - $ref: '#/components/schemas/DataDelta' + - $ref: '#/components/schemas/ToolRequestDelta' + - $ref: '#/components/schemas/ToolResponseDelta' + - $ref: '#/components/schemas/ReasoningSummaryDelta' + - $ref: '#/components/schemas/ReasoningContentDelta' + title: TaskMessageDelta + discriminator: + propertyName: type + mapping: + data: '#/components/schemas/DataDelta' + reasoning_content: '#/components/schemas/ReasoningContentDelta' + reasoning_summary: '#/components/schemas/ReasoningSummaryDelta' + text: '#/components/schemas/TextDelta' + tool_request: '#/components/schemas/ToolRequestDelta' + tool_response: '#/components/schemas/ToolResponseDelta' + TaskMessageEntity: + properties: + id: + anyOf: + - type: string + - type: 'null' + title: Id + description: The task message's unique id + task_id: + type: string + title: Task Id + description: ID of the task this message belongs to + content: + oneOf: + - $ref: '#/components/schemas/TextContentEntity' + - $ref: '#/components/schemas/DataContentEntity' + - $ref: '#/components/schemas/ToolRequestContentEntity' + - $ref: '#/components/schemas/ToolResponseContentEntity' + - $ref: '#/components/schemas/ReasoningContentEntity' + title: Content + description: The content of the message. This content is not OpenAI compatible. + These are messages that are meant to be displayed to the user. + discriminator: + propertyName: type + mapping: + data: '#/components/schemas/DataContentEntity' + reasoning: '#/components/schemas/ReasoningContentEntity' + text: '#/components/schemas/TextContentEntity' + tool_request: '#/components/schemas/ToolRequestContentEntity' + tool_response: '#/components/schemas/ToolResponseContentEntity' + streaming_status: + anyOf: + - type: string + enum: + - IN_PROGRESS + - DONE + - type: 'null' + title: In case of streaming, this indicates whether the message is still + being streamed or has been completed + created_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Created At + description: The timestamp when the message was created + updated_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Updated At + description: The timestamp when the message was last updated + type: object + required: + - task_id + - content + title: TaskMessageEntity + description: 'Represents a message in the agent system. + + + This entity is used to store messages in MongoDB, with each message + + associated with a specific task.' + TaskMessageUpdate: + oneOf: + - $ref: '#/components/schemas/StreamTaskMessageStart' + - $ref: '#/components/schemas/StreamTaskMessageDelta' + - $ref: '#/components/schemas/StreamTaskMessageFull' + - $ref: '#/components/schemas/StreamTaskMessageDone' + title: TaskMessageUpdate + discriminator: + propertyName: type + mapping: + delta: '#/components/schemas/StreamTaskMessageDelta' + done: '#/components/schemas/StreamTaskMessageDone' + full: '#/components/schemas/StreamTaskMessageFull' + start: '#/components/schemas/StreamTaskMessageStart' + TaskRelationships: + type: string + enum: + - agents + title: TaskRelationships + description: Task relationships that can be loaded + TaskResponse: + properties: + id: + type: string + title: Unique Task ID + name: + anyOf: + - type: string + - type: 'null' + title: Unique name of the task + status: + anyOf: + - $ref: '#/components/schemas/TaskStatus' + - type: 'null' + title: The current status of the task + status_reason: + anyOf: + - type: string + - type: 'null' + title: The reason for the current task status + created_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: The timestamp when the task was created + updated_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: The timestamp when the task was last updated + cleaned_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: The timestamp when the task's content was cleaned for retention compliance; + null when active + params: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Task parameters + task_metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Task metadata + agents: + anyOf: + - items: + $ref: '#/components/schemas/Agent' + type: array + - type: 'null' + title: Agents associated with this task (only populated when 'agent' view + is requested) + type: object + required: + - id + title: TaskResponse + description: Task response model with optional related data based on relationships + TaskStatus: + type: string + enum: + - CANCELED + - COMPLETED + - FAILED + - RUNNING + - TERMINATED + - TIMED_OUT + - DELETED + title: TaskStatus + TaskStatusReasonRequest: + properties: + reason: + anyOf: + - type: string + - type: 'null' + title: Optional reason for the status change + type: object + title: TaskStatusReasonRequest + TextContent: + properties: + type: + type: string + const: text + title: Type + description: The type of the message, in this case `text`. + default: text + author: + $ref: '#/components/schemas/MessageAuthor' + description: The role of the messages author, in this case `system`, `user`, + `assistant`, or `tool`. + style: + $ref: '#/components/schemas/MessageStyle' + description: The style of the message. This is used by the client to determine + how to display the message. + default: static + format: + $ref: '#/components/schemas/TextFormat' + description: The format of the message. This is used by the client to determine + how to display the message. + default: plain + content: + type: string + title: Content + description: The contents of the text message. + attachments: + anyOf: + - items: + $ref: '#/components/schemas/FileAttachment' + type: array + - type: 'null' + title: Attachments + description: Optional list of file attachments with structured metadata. + type: object + required: + - author + - content + title: TextContent + TextContentEntity: + properties: + type: + type: string + const: text + title: Type + description: The type of the message, in this case `text`. + default: text + author: + $ref: '#/components/schemas/MessageAuthor' + description: The role of the messages author, in this case `system`, `user`, + `assistant`, or `tool`. + style: + $ref: '#/components/schemas/MessageStyle' + description: The style of the message. This is used by the client to determine + how to display the message. + default: static + format: + $ref: '#/components/schemas/TextFormat' + description: The format of the message. This is used by the client to determine + how to display the message. + default: plain + content: + type: string + title: Content + description: The contents of the text message. + attachments: + anyOf: + - items: + $ref: '#/components/schemas/FileAttachmentEntity' + type: array + - type: 'null' + title: Attachments + description: Optional list of file attachments with structured metadata. + type: object + required: + - author + - content + title: TextContentEntity + TextDelta: + properties: + type: + type: string + const: text + title: Type + default: text + text_delta: + anyOf: + - type: string + - type: 'null' + title: Text Delta + default: '' + type: object + title: TextDelta + description: Delta for text updates + TextFormat: + type: string + enum: + - markdown + - plain + - code + title: TextFormat + ToolRequestContent: + properties: + type: + type: string + const: tool_request + title: Type + description: The type of the message, in this case `tool_request`. + default: tool_request + author: + $ref: '#/components/schemas/MessageAuthor' + description: The role of the messages author, in this case `system`, `user`, + `assistant`, or `tool`. + style: + $ref: '#/components/schemas/MessageStyle' + description: The style of the message. This is used by the client to determine + how to display the message. + default: static + tool_call_id: + type: string + title: Tool Call Id + description: The ID of the tool call that is being requested. + name: + type: string + title: Name + description: The name of the tool that is being requested. + arguments: + additionalProperties: true + type: object + title: Arguments + description: The arguments to the tool. + type: object + required: + - author + - tool_call_id + - name + - arguments + title: ToolRequestContent + ToolRequestContentEntity: + properties: + type: + type: string + const: tool_request + title: Type + description: The type of the message, in this case `tool_request`. + default: tool_request + author: + $ref: '#/components/schemas/MessageAuthor' + description: The role of the messages author, in this case `system`, `user`, + `assistant`, or `tool`. + style: + $ref: '#/components/schemas/MessageStyle' + description: The style of the message. This is used by the client to determine + how to display the message. + default: static + tool_call_id: + type: string + title: Tool Call Id + description: The ID of the tool call that is being requested. + name: + type: string + title: Name + description: The name of the tool that is being requested. + arguments: + additionalProperties: true + type: object + title: Arguments + description: The arguments to the tool. + type: object + required: + - author + - tool_call_id + - name + - arguments + title: ToolRequestContentEntity + ToolRequestDelta: + properties: + type: + type: string + const: tool_request + title: Type + default: tool_request + tool_call_id: + type: string + title: Tool Call Id + name: + type: string + title: Name + arguments_delta: + anyOf: + - type: string + - type: 'null' + title: Arguments Delta + default: '' + type: object + required: + - tool_call_id + - name + title: ToolRequestDelta + description: Delta for tool request updates + ToolResponseContent: + properties: + type: + type: string + const: tool_response + title: Type + description: The type of the message, in this case `tool_response`. + default: tool_response + author: + $ref: '#/components/schemas/MessageAuthor' + description: The role of the messages author, in this case `system`, `user`, + `assistant`, or `tool`. + style: + $ref: '#/components/schemas/MessageStyle' + description: The style of the message. This is used by the client to determine + how to display the message. + default: static + tool_call_id: + type: string + title: Tool Call Id + description: The ID of the tool call that is being responded to. + name: + type: string + title: Name + description: The name of the tool that is being responded to. + content: + title: Content + description: The result of the tool. + type: object + required: + - author + - tool_call_id + - name + - content + title: ToolResponseContent + ToolResponseContentEntity: + properties: + type: + type: string + const: tool_response + title: Type + description: The type of the message, in this case `tool_response`. + default: tool_response + author: + $ref: '#/components/schemas/MessageAuthor' + description: The role of the messages author, in this case `system`, `user`, + `assistant`, or `tool`. + style: + $ref: '#/components/schemas/MessageStyle' + description: The style of the message. This is used by the client to determine + how to display the message. + default: static + tool_call_id: + type: string + title: Tool Call Id + description: The ID of the tool call that is being responded to. + name: + type: string + title: Name + description: The name of the tool that is being responded to. + content: + title: Content + description: The result of the tool. + type: object + required: + - author + - tool_call_id + - name + - content + title: ToolResponseContentEntity + ToolResponseDelta: + properties: + type: + type: string + const: tool_response + title: Type + default: tool_response + tool_call_id: + type: string + title: Tool Call Id + name: + type: string + title: Name + content_delta: + anyOf: + - type: string + - type: 'null' + title: Content Delta + default: '' + type: object + required: + - tool_call_id + - name + title: ToolResponseDelta + description: Delta for tool response updates + UnpauseScheduleRequest: + properties: + note: + anyOf: + - type: string + - type: 'null' + title: Note + description: Optional note explaining why the schedule was unpaused + type: object + title: UnpauseScheduleRequest + description: Request model for unpausing a schedule + UpdateAgentTaskTrackerRequest: + properties: + last_processed_event_id: + anyOf: + - type: string + - type: 'null' + title: Last Processed Event Id + description: The most recent processed event ID (omit to leave unchanged) + status: + anyOf: + - type: string + - type: 'null' + title: Status + description: Processing status + status_reason: + anyOf: + - type: string + - type: 'null' + title: Status Reason + description: Optional status reason + type: object + title: UpdateAgentTaskTrackerRequest + description: Request model for updating an agent task tracker. + UpdateSpanRequest: + properties: + trace_id: + anyOf: + - type: string + - type: 'null' + title: The trace ID for this span + description: Unique identifier for the trace this span belongs to + task_id: + anyOf: + - type: string + - type: 'null' + title: The task ID this span is associated with + description: ID of the task this span belongs to + parent_id: + anyOf: + - type: string + - type: 'null' + title: The parent span ID if this is a child span + description: ID of the parent span if this is a child span in a trace + name: + anyOf: + - type: string + - type: 'null' + title: The name of the span + description: Name that describes what operation this span represents + start_time: + anyOf: + - type: string + format: date-time + - type: 'null' + title: The start time of the span + description: The time the span started + end_time: + anyOf: + - type: string + format: date-time + - type: 'null' + title: The end time of the span + description: The time the span ended + input: + anyOf: + - additionalProperties: true + type: object + - items: + additionalProperties: true + type: object + type: array + - type: 'null' + title: The input data for the span + description: Input parameters or data for the operation + output: + anyOf: + - additionalProperties: true + type: object + - items: + additionalProperties: true + type: object + type: array + - type: 'null' + title: The output data from the span + description: Output data resulting from the operation + data: + anyOf: + - additionalProperties: true + type: object + - items: + additionalProperties: true + type: object + type: array + - type: 'null' + title: Additional data associated with the span + description: Any additional metadata or context for the span + type: object + title: UpdateSpanRequest + UpdateStateRequest: + properties: + state: + additionalProperties: true + type: object + title: The state to update the state with. + type: object + required: + - state + title: UpdateStateRequest + UpdateTaskMessageRequest: + properties: + task_id: + type: string + title: The unique id of the task to update the message of + content: + $ref: '#/components/schemas/TaskMessageContent' + title: The message to update the message with. + streaming_status: + anyOf: + - type: string + enum: + - IN_PROGRESS + - DONE + - type: 'null' + title: The streaming status of the message + type: object + required: + - task_id + - content + title: UpdateTaskMessageRequest + UpdateTaskRequest: + properties: + task_metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: If provided, replaces task_metadata with this value + type: object + title: UpdateTaskRequest + ValidationError: + properties: + loc: + items: + anyOf: + - type: string + - type: integer + type: array + title: Location + msg: + type: string + title: Message + type: + type: string + title: Error Type + input: + title: Input + ctx: + type: object + title: Context + type: object + required: + - loc + - msg + - type + title: ValidationError + WriteData: + properties: + task_id: + type: string + title: Task ID + idx: + type: integer + title: Write index + channel: + type: string + title: Channel name + type: + anyOf: + - type: string + - type: 'null' + title: Serialization type tag + blob: + type: string + title: Base64-encoded binary data + task_path: + type: string + title: Task path + default: '' + type: object + required: + - task_id + - idx + - channel + - blob + title: WriteData + WriteResponse: + properties: + task_id: + type: string + title: Task Id + idx: + type: integer + title: Idx + channel: + type: string + title: Channel + type: + anyOf: + - type: string + - type: 'null' + title: Type + blob: + anyOf: + - type: string + - type: 'null' + title: Blob + type: object + required: + - task_id + - idx + - channel + title: WriteResponse diff --git a/tests/compat/server_specs/manifest.json b/tests/compat/server_specs/manifest.json new file mode 100644 index 000000000..986650583 --- /dev/null +++ b/tests/compat/server_specs/manifest.json @@ -0,0 +1,19 @@ +{ + "description": "Supported server OpenAPI contract window for SDK request-compatibility. Each file is agentex/openapi.yaml from scaleapi/scale-agentex at the pinned sha. Advance 'min-supported' as the oldest deployed server moves forward, then run refresh_specs.py.", + "source_repo": "scaleapi/scale-agentex", + "source_path": "agentex/openapi.yaml", + "specs": [ + { + "label": "current", + "file": "current.yaml", + "sha": "ab469df91bba043ea25356c89147d29f7df03bad", + "note": "scale-agentex main at vendoring time" + }, + { + "label": "min-supported", + "file": "min-supported.yaml", + "sha": "39e3a7118d55b7db4c01f6ee14e68a8167bb001d", + "note": "pre-#278; oldest contract still in the field. UpdateStateRequest requires task_id/agent_id in the body — the contract the 0.13.0 client broke." + } + ] +} diff --git a/tests/compat/server_specs/min-supported.yaml b/tests/compat/server_specs/min-supported.yaml new file mode 100644 index 000000000..cbcf2ca45 --- /dev/null +++ b/tests/compat/server_specs/min-supported.yaml @@ -0,0 +1,6761 @@ +openapi: 3.1.0 +info: + title: Agentex API + version: 0.1.0 +paths: + /agents/{agent_id}: + get: + tags: + - Agents + summary: Get Agent by ID + description: Get an agent by its unique ID. + operationId: get_agent_by_id_agents__agent_id__get + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Agent' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Agents + summary: Delete Agent by ID + description: Delete an agent by its unique ID. + operationId: delete_agent_by_id_agents__agent_id__delete + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/name/{agent_name}: + get: + tags: + - Agents + summary: Get Agent by Name + description: Get an agent by its unique name. + operationId: get_agent_by_name_agents_name__agent_name__get + parameters: + - name: agent_name + in: path + required: true + schema: + type: string + title: Agent Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Agent' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Agents + summary: Delete Agent by Name + description: Delete an agent by its unique name. + operationId: delete_agent_by_name_agents_name__agent_name__delete + parameters: + - name: agent_name + in: path + required: true + schema: + type: string + title: Agent Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents: + get: + tags: + - Agents + summary: List Agents + description: List all registered agents, optionally filtered by query parameters. + operationId: list_agents_agents_get + parameters: + - name: task_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: Task ID + title: Task Id + description: Task ID + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + description: Limit + default: 50 + title: Limit + description: Limit + - name: page_number + in: query + required: false + schema: + type: integer + minimum: 1 + description: Page number + default: 1 + title: Page Number + description: Page number + - name: order_by + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: Field to order by + title: Order By + description: Field to order by + - name: order_direction + in: query + required: false + schema: + type: string + description: Order direction (asc or desc) + default: desc + title: Order Direction + description: Order direction (asc or desc) + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Agent' + title: Response List Agents Agents Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/register: + post: + tags: + - Agents + summary: Register Agent + description: Register a new agent or update an existing one. + operationId: register_agent_agents_register_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterAgentRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterAgentResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/register-build: + post: + tags: + - Agents + summary: Register Build + description: Register an agent at build time, before it is deployed, so it can + be permissioned and shared prior to deploy. Idempotent by name. + operationId: register_build_agents_register_build_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterBuildRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Agent' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/forward/name/{agent_name}/{path}: + get: + tags: + - Agents + summary: Forward GET request to agent by name + description: Forward a GET request to an agent by its name. + operationId: forward_get_request_to_agent_agents_forward_name__agent_name___path__get + parameters: + - name: agent_name + in: path + required: true + schema: + type: string + title: Agent Name + - name: path + in: path + required: true + schema: + type: string + title: Path + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + post: + tags: + - Agents + summary: Forward POST request to agent by name + description: Forward a POST request to an agent by its name. + operationId: forward_post_request_to_agent_agents_forward_name__agent_name___path__post + parameters: + - name: agent_name + in: path + required: true + schema: + type: string + title: Agent Name + - name: path + in: path + required: true + schema: + type: string + title: Path + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/{agent_id}/rpc: + post: + tags: + - Agents + summary: Handle Agent RPC by ID + description: Handle JSON-RPC requests for an agent by its unique ID. + operationId: handle_agent_rpc_by_id_agents__agent_id__rpc_post + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AgentRPCRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AgentRPCResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/name/{agent_name}/rpc: + post: + tags: + - Agents + summary: Handle Agent RPC by Name + description: Handle JSON-RPC requests for an agent by its unique name. + operationId: handle_agent_rpc_by_name_agents_name__agent_name__rpc_post + parameters: + - name: agent_name + in: path + required: true + schema: + type: string + title: Agent Name + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AgentRPCRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AgentRPCResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/{task_id}: + get: + tags: + - Tasks + summary: Get Task by ID + description: Get a task by its unique ID. + operationId: get_task_tasks__task_id__get + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + - name: relationships + in: query + required: false + schema: + type: array + items: + $ref: '#/components/schemas/TaskRelationships' + title: Relationships + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/TaskResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Tasks + summary: Delete Task by ID + description: Delete a task by its unique ID. + operationId: delete_task_tasks__task_id__delete + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + put: + tags: + - Tasks + summary: Update Task by ID + description: Update mutable fields for a task by its unique ID. + operationId: update_task_tasks__task_id__put + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateTaskRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Task' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/name/{task_name}: + get: + tags: + - Tasks + summary: Get Task by Name + description: Get a task by its unique name. + operationId: get_task_by_name_tasks_name__task_name__get + parameters: + - name: task_name + in: path + required: true + schema: + type: string + title: Task Name + - name: relationships + in: query + required: false + schema: + type: array + items: + $ref: '#/components/schemas/TaskRelationships' + title: Relationships + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/TaskResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Tasks + summary: Delete Task by Name + description: Delete a task by its unique name. + operationId: delete_task_by_name_tasks_name__task_name__delete + parameters: + - name: task_name + in: path + required: true + schema: + type: string + title: Task Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + put: + tags: + - Tasks + summary: Update Task by Name + description: Update mutable fields for a task by its unique Name. + operationId: update_task_by_name_tasks_name__task_name__put + parameters: + - name: task_name + in: path + required: true + schema: + type: string + title: Task Name + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateTaskRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Task' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks: + get: + tags: + - Tasks + summary: List Tasks + description: List all tasks. + operationId: list_tasks_tasks_get + parameters: + - name: agent_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Agent Id + - name: agent_name + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Agent Name + - name: status + in: query + required: false + schema: + anyOf: + - $ref: '#/components/schemas/TaskStatus' + - type: 'null' + description: Filter tasks by status (e.g. RUNNING, COMPLETED). + title: Status + description: Filter tasks by status (e.g. RUNNING, COMPLETED). + - name: task_metadata + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: 'JSON-encoded object used to filter tasks via JSONB containment. + Example: {"created_by_user_id": "abc-123"}.' + title: Task Metadata + description: 'JSON-encoded object used to filter tasks via JSONB containment. + Example: {"created_by_user_id": "abc-123"}.' + - name: limit + in: query + required: false + schema: + type: integer + default: 50 + title: Limit + - name: page_number + in: query + required: false + schema: + type: integer + default: 1 + title: Page Number + - name: order_by + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Order By + - name: order_direction + in: query + required: false + schema: + type: string + default: desc + title: Order Direction + - name: relationships + in: query + required: false + schema: + type: array + items: + $ref: '#/components/schemas/TaskRelationships' + title: Relationships + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TaskResponse' + title: Response List Tasks Tasks Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/{task_id}/complete: + post: + tags: + - Tasks + summary: Complete Task + description: Mark a running task as completed. + operationId: complete_task_tasks__task_id__complete_post + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + requestBody: + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/TaskStatusReasonRequest' + - type: 'null' + title: Request + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Task' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/{task_id}/fail: + post: + tags: + - Tasks + summary: Fail Task + description: Mark a running task as failed. + operationId: fail_task_tasks__task_id__fail_post + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + requestBody: + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/TaskStatusReasonRequest' + - type: 'null' + title: Request + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Task' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/{task_id}/cancel: + post: + tags: + - Tasks + summary: Cancel Task + description: Mark a running task as canceled. + operationId: cancel_task_tasks__task_id__cancel_post + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + requestBody: + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/TaskStatusReasonRequest' + - type: 'null' + title: Request + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Task' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/{task_id}/terminate: + post: + tags: + - Tasks + summary: Terminate Task + description: Mark a running task as terminated. + operationId: terminate_task_tasks__task_id__terminate_post + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + requestBody: + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/TaskStatusReasonRequest' + - type: 'null' + title: Request + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Task' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/{task_id}/timeout: + post: + tags: + - Tasks + summary: Timeout Task + description: Mark a running task as timed out. + operationId: timeout_task_tasks__task_id__timeout_post + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + requestBody: + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/TaskStatusReasonRequest' + - type: 'null' + title: Request + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Task' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/{task_id}/stream: + get: + tags: + - Tasks + summary: Stream Task Events by ID + description: Stream events for a task by its unique ID. + operationId: stream_task_events_tasks__task_id__stream_get + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/name/{task_name}/stream: + get: + tags: + - Tasks + summary: Stream Task Events by Name + description: Stream events for a task by its unique name. + operationId: stream_task_events_by_name_tasks_name__task_name__stream_get + parameters: + - name: task_name + in: path + required: true + schema: + type: string + title: Task Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/{task_id}/query/{query_name}: + get: + tags: + - Tasks + summary: Query Task Workflow + description: Query a Temporal workflow associated with a task for its current + state. + operationId: query_task_workflow_tasks__task_id__query__query_name__get + parameters: + - name: query_name + in: path + required: true + schema: + type: string + title: Query Name + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Query Task Workflow Tasks Task Id Query Query Name Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /messages/batch: + put: + tags: + - Messages + summary: Batch Update Messages + operationId: batch_update_messages_messages_batch_put + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/BatchUpdateTaskMessagesRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + items: + $ref: '#/components/schemas/TaskMessage' + type: array + title: Response Batch Update Messages Messages Batch Put + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + post: + tags: + - Messages + summary: Batch Create Messages + operationId: batch_create_messages_messages_batch_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/BatchCreateTaskMessagesRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + items: + $ref: '#/components/schemas/TaskMessage' + type: array + title: Response Batch Create Messages Messages Batch Post + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /messages: + post: + tags: + - Messages + summary: Create Message + operationId: create_message_messages_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateTaskMessageRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/TaskMessage' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Messages + summary: List Messages + description: 'List messages for a task with offset-based pagination. + + + For cursor-based pagination with infinite scroll support, use /messages/paginated.' + operationId: list_messages_messages_get + parameters: + - name: limit + in: query + required: false + schema: + type: integer + default: 50 + title: Limit + - name: page_number + in: query + required: false + schema: + type: integer + default: 1 + title: Page Number + - name: order_by + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Order By + - name: order_direction + in: query + required: false + schema: + type: string + default: desc + title: Order Direction + - name: filters + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: "JSON-encoded array of TaskMessageEntityFilter objects.\n\n\ + Schema: {\n \"$defs\": {\n \"DataContentEntityOptional\": {\n \ + \ \"properties\": {\n \"type\": {\n \"anyOf\": [\n \ + \ {\n \"const\": \"data\",\n \"type\"\ + : \"string\"\n },\n {\n \"type\": \"\ + null\"\n }\n ],\n \"default\": null,\n \ + \ \"description\": \"The type of the message, in this case `data`.\"\ + ,\n \"title\": \"Type\"\n },\n \"author\": {\n\ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The role of the messages author, in this case `system`,\ + \ `user`, `assistant`, or `tool`.\"\n },\n \"style\": {\n\ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The style of the message. This is used by the client\ + \ to determine how to display the message.\"\n },\n \"data\"\ + : {\n \"anyOf\": [\n {\n \"additionalProperties\"\ + : true,\n \"type\": \"object\"\n },\n \ + \ {\n \"type\": \"null\"\n }\n ],\n\ + \ \"default\": null,\n \"description\": \"The contents\ + \ of the data message.\",\n \"title\": \"Data\"\n }\n\ + \ },\n \"title\": \"DataContentEntityOptional\",\n \"type\"\ + : \"object\"\n },\n \"FileAttachmentEntity\": {\n \"description\"\ + : \"Represents a file attachment in messages.\",\n \"properties\"\ + : {\n \"file_id\": {\n \"description\": \"The unique ID\ + \ of the attached file\",\n \"title\": \"File Id\",\n \ + \ \"type\": \"string\"\n },\n \"name\": {\n \"\ + description\": \"The name of the file\",\n \"title\": \"Name\"\ + ,\n \"type\": \"string\"\n },\n \"size\": {\n \ + \ \"description\": \"The size of the file in bytes\",\n \ + \ \"title\": \"Size\",\n \"type\": \"integer\"\n },\n\ + \ \"type\": {\n \"description\": \"The MIME type or content\ + \ type of the file\",\n \"title\": \"Type\",\n \"type\"\ + : \"string\"\n }\n },\n \"required\": [\n \"file_id\"\ + ,\n \"name\",\n \"size\",\n \"type\"\n ],\n\ + \ \"title\": \"FileAttachmentEntity\",\n \"type\": \"object\"\ + \n },\n \"MessageAuthor\": {\n \"enum\": [\n \"user\"\ + ,\n \"agent\"\n ],\n \"title\": \"MessageAuthor\",\n\ + \ \"type\": \"string\"\n },\n \"MessageStyle\": {\n \"\ + enum\": [\n \"static\",\n \"active\"\n ],\n \"\ + title\": \"MessageStyle\",\n \"type\": \"string\"\n },\n \"\ + ReasoningContentEntityOptional\": {\n \"properties\": {\n \ + \ \"type\": {\n \"anyOf\": [\n {\n \"\ + const\": \"reasoning\",\n \"type\": \"string\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The type of the message, in this case `reasoning`.\",\n \"\ + title\": \"Type\"\n },\n \"author\": {\n \"anyOf\"\ + : [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\n\ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The role of the messages author, in this case `system`,\ + \ `user`, `assistant`, or `tool`.\"\n },\n \"style\": {\n\ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The style of the message. This is used by the client\ + \ to determine how to display the message.\"\n },\n \"summary\"\ + : {\n \"anyOf\": [\n {\n \"items\": {\n\ + \ \"type\": \"string\"\n },\n \ + \ \"type\": \"array\"\n },\n {\n \"\ + type\": \"null\"\n }\n ],\n \"default\":\ + \ null,\n \"description\": \"A list of short reasoning summaries\"\ + ,\n \"title\": \"Summary\"\n },\n \"content\":\ + \ {\n \"anyOf\": [\n {\n \"items\": {\n\ + \ \"type\": \"string\"\n },\n \ + \ \"type\": \"array\"\n },\n {\n \"\ + type\": \"null\"\n }\n ],\n \"default\":\ + \ null,\n \"description\": \"The reasoning content or chain-of-thought\ + \ text\",\n \"title\": \"Content\"\n }\n },\n \ + \ \"title\": \"ReasoningContentEntityOptional\",\n \"type\": \"\ + object\"\n },\n \"TextContentEntityOptional\": {\n \"properties\"\ + : {\n \"type\": {\n \"anyOf\": [\n {\n \ + \ \"const\": \"text\",\n \"type\": \"string\"\n\ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The type of the message, in this case `text`.\",\n \ + \ \"title\": \"Type\"\n },\n \"author\": {\n \ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The role of the messages author, in this case `system`,\ + \ `user`, `assistant`, or `tool`.\"\n },\n \"style\": {\n\ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The style of the message. This is used by the client\ + \ to determine how to display the message.\"\n },\n \"format\"\ + : {\n \"anyOf\": [\n {\n \"$ref\": \"\ + #/$defs/TextFormat\"\n },\n {\n \"\ + type\": \"null\"\n }\n ],\n \"default\":\ + \ null,\n \"description\": \"The format of the message. This\ + \ is used by the client to determine how to display the message.\"\n \ + \ },\n \"content\": {\n \"anyOf\": [\n \ + \ {\n \"type\": \"string\"\n },\n \ + \ {\n \"type\": \"null\"\n }\n ],\n\ + \ \"default\": null,\n \"description\": \"The contents\ + \ of the text message.\",\n \"title\": \"Content\"\n },\n\ + \ \"attachments\": {\n \"anyOf\": [\n {\n \ + \ \"items\": {\n \"$ref\": \"#/$defs/FileAttachmentEntity\"\ + \n },\n \"type\": \"array\"\n },\n\ + \ {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"\ + Optional list of file attachments with structured metadata.\",\n \ + \ \"title\": \"Attachments\"\n }\n },\n \"title\"\ + : \"TextContentEntityOptional\",\n \"type\": \"object\"\n },\n\ + \ \"TextFormat\": {\n \"enum\": [\n \"markdown\",\n \ + \ \"plain\",\n \"code\"\n ],\n \"title\": \"TextFormat\"\ + ,\n \"type\": \"string\"\n },\n \"ToolRequestContentEntityOptional\"\ + : {\n \"properties\": {\n \"type\": {\n \"anyOf\"\ + : [\n {\n \"const\": \"tool_request\",\n \ + \ \"type\": \"string\"\n },\n {\n \ + \ \"type\": \"null\"\n }\n ],\n \"\ + default\": null,\n \"description\": \"The type of the message,\ + \ in this case `tool_request`.\",\n \"title\": \"Type\"\n \ + \ },\n \"author\": {\n \"anyOf\": [\n {\n\ + \ \"$ref\": \"#/$defs/MessageAuthor\"\n },\n \ + \ {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"\ + The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\"\ + : [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\n\ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The style of the message. This is used by the client\ + \ to determine how to display the message.\"\n },\n \"tool_call_id\"\ + : {\n \"anyOf\": [\n {\n \"type\": \"\ + string\"\n },\n {\n \"type\": \"null\"\ + \n }\n ],\n \"default\": null,\n \ + \ \"description\": \"The ID of the tool call that is being requested.\"\ + ,\n \"title\": \"Tool Call Id\"\n },\n \"name\"\ + : {\n \"anyOf\": [\n {\n \"type\": \"\ + string\"\n },\n {\n \"type\": \"null\"\ + \n }\n ],\n \"default\": null,\n \ + \ \"description\": \"The name of the tool that is being requested.\"\ + ,\n \"title\": \"Name\"\n },\n \"arguments\": {\n\ + \ \"anyOf\": [\n {\n \"additionalProperties\"\ + : true,\n \"type\": \"object\"\n },\n \ + \ {\n \"type\": \"null\"\n }\n ],\n\ + \ \"default\": null,\n \"description\": \"The arguments\ + \ to the tool.\",\n \"title\": \"Arguments\"\n }\n \ + \ },\n \"title\": \"ToolRequestContentEntityOptional\",\n \ + \ \"type\": \"object\"\n },\n \"ToolResponseContentEntityOptional\"\ + : {\n \"properties\": {\n \"type\": {\n \"anyOf\"\ + : [\n {\n \"const\": \"tool_response\",\n \ + \ \"type\": \"string\"\n },\n {\n \ + \ \"type\": \"null\"\n }\n ],\n \"\ + default\": null,\n \"description\": \"The type of the message,\ + \ in this case `tool_response`.\",\n \"title\": \"Type\"\n \ + \ },\n \"author\": {\n \"anyOf\": [\n \ + \ {\n \"$ref\": \"#/$defs/MessageAuthor\"\n },\n\ + \ {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"\ + The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\"\ + : [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\n\ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The style of the message. This is used by the client\ + \ to determine how to display the message.\"\n },\n \"tool_call_id\"\ + : {\n \"anyOf\": [\n {\n \"type\": \"\ + string\"\n },\n {\n \"type\": \"null\"\ + \n }\n ],\n \"default\": null,\n \ + \ \"description\": \"The ID of the tool call that is being responded\ + \ to.\",\n \"title\": \"Tool Call Id\"\n },\n \"\ + name\": {\n \"anyOf\": [\n {\n \"type\"\ + : \"string\"\n },\n {\n \"type\": \"\ + null\"\n }\n ],\n \"default\": null,\n \ + \ \"description\": \"The name of the tool that is being responded\ + \ to.\",\n \"title\": \"Name\"\n },\n \"content\"\ + : {\n \"anyOf\": [\n {},\n {\n \ + \ \"type\": \"null\"\n }\n ],\n \"default\"\ + : null,\n \"description\": \"The result of the tool.\",\n \ + \ \"title\": \"Content\"\n }\n },\n \"title\":\ + \ \"ToolResponseContentEntityOptional\",\n \"type\": \"object\"\n\ + \ }\n },\n \"description\": \"Filter model for TaskMessage - all\ + \ fields optional for flexible filtering.\\n\\nThe `exclude` field determines\ + \ whether this filter is inclusionary or exclusionary.\\nWhen multiple\ + \ filters are provided:\\n- Inclusionary filters (exclude=False) are OR'd\ + \ together\\n- Exclusionary filters (exclude=True) are OR'd together and\ + \ negated with $nor\\n- The two groups are AND'd: (include1 OR include2)\ + \ AND NOT (exclude1 OR exclude2)\",\n \"properties\": {\n \"content\"\ + : {\n \"anyOf\": [\n {\n \"$ref\": \"#/$defs/ToolRequestContentEntityOptional\"\ + \n },\n {\n \"$ref\": \"#/$defs/DataContentEntityOptional\"\ + \n },\n {\n \"$ref\": \"#/$defs/TextContentEntityOptional\"\ + \n },\n {\n \"$ref\": \"#/$defs/ToolResponseContentEntityOptional\"\ + \n },\n {\n \"$ref\": \"#/$defs/ReasoningContentEntityOptional\"\ + \n },\n {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"Filter by message\ + \ content\",\n \"title\": \"Content\"\n },\n \"streaming_status\"\ + : {\n \"anyOf\": [\n {\n \"enum\": [\n \ + \ \"IN_PROGRESS\",\n \"DONE\"\n ],\n \"\ + type\": \"string\"\n },\n {\n \"type\": \"null\"\ + \n }\n ],\n \"default\": null,\n \"description\"\ + : \"Filter by streaming status\",\n \"title\": \"Streaming Status\"\ + \n },\n \"exclude\": {\n \"default\": false,\n \"description\"\ + : \"If true, this filter excludes matching messages\",\n \"title\"\ + : \"Exclude\",\n \"type\": \"boolean\"\n }\n },\n \"title\"\ + : \"TaskMessageEntityFilter\",\n \"type\": \"object\"\n}\n\nEach filter\ + \ can include:\n- `content`: Filter by message content (type, author,\ + \ data fields)\n- `streaming_status`: Filter by status (\"IN_PROGRESS\"\ + \ or \"DONE\")\n- `exclude`: If true, excludes matching messages (default:\ + \ false)\n\nMultiple filters are combined: inclusionary filters (exclude=false)\ + \ are OR'd together,\nexclusionary filters (exclude=true) are OR'd and\ + \ negated, then both groups are AND'd.\n" + title: Filters + description: "JSON-encoded array of TaskMessageEntityFilter objects.\n\nSchema:\ + \ {\n \"$defs\": {\n \"DataContentEntityOptional\": {\n \"properties\"\ + : {\n \"type\": {\n \"anyOf\": [\n {\n \ + \ \"const\": \"data\",\n \"type\": \"string\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The type of the message, in this case `data`.\",\n \"title\"\ + : \"Type\"\n },\n \"author\": {\n \"anyOf\": [\n\ + \ {\n \"$ref\": \"#/$defs/MessageAuthor\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ + \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The style of the message. This is used by the client to determine how\ + \ to display the message.\"\n },\n \"data\": {\n \ + \ \"anyOf\": [\n {\n \"additionalProperties\": true,\n\ + \ \"type\": \"object\"\n },\n {\n \ + \ \"type\": \"null\"\n }\n ],\n \"\ + default\": null,\n \"description\": \"The contents of the data\ + \ message.\",\n \"title\": \"Data\"\n }\n },\n \ + \ \"title\": \"DataContentEntityOptional\",\n \"type\": \"object\"\ + \n },\n \"FileAttachmentEntity\": {\n \"description\": \"Represents\ + \ a file attachment in messages.\",\n \"properties\": {\n \"\ + file_id\": {\n \"description\": \"The unique ID of the attached\ + \ file\",\n \"title\": \"File Id\",\n \"type\": \"string\"\ + \n },\n \"name\": {\n \"description\": \"The name\ + \ of the file\",\n \"title\": \"Name\",\n \"type\": \"\ + string\"\n },\n \"size\": {\n \"description\": \"\ + The size of the file in bytes\",\n \"title\": \"Size\",\n \ + \ \"type\": \"integer\"\n },\n \"type\": {\n \ + \ \"description\": \"The MIME type or content type of the file\",\n \ + \ \"title\": \"Type\",\n \"type\": \"string\"\n }\n\ + \ },\n \"required\": [\n \"file_id\",\n \"name\"\ + ,\n \"size\",\n \"type\"\n ],\n \"title\": \"FileAttachmentEntity\"\ + ,\n \"type\": \"object\"\n },\n \"MessageAuthor\": {\n \"\ + enum\": [\n \"user\",\n \"agent\"\n ],\n \"title\"\ + : \"MessageAuthor\",\n \"type\": \"string\"\n },\n \"MessageStyle\"\ + : {\n \"enum\": [\n \"static\",\n \"active\"\n ],\n\ + \ \"title\": \"MessageStyle\",\n \"type\": \"string\"\n },\n\ + \ \"ReasoningContentEntityOptional\": {\n \"properties\": {\n \ + \ \"type\": {\n \"anyOf\": [\n {\n \ + \ \"const\": \"reasoning\",\n \"type\": \"string\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The type of the message, in this case `reasoning`.\",\n \"\ + title\": \"Type\"\n },\n \"author\": {\n \"anyOf\"\ + : [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\n\ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ + \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The style of the message. This is used by the client to determine how\ + \ to display the message.\"\n },\n \"summary\": {\n \ + \ \"anyOf\": [\n {\n \"items\": {\n \ + \ \"type\": \"string\"\n },\n \"type\":\ + \ \"array\"\n },\n {\n \"type\": \"null\"\ + \n }\n ],\n \"default\": null,\n \"\ + description\": \"A list of short reasoning summaries\",\n \"title\"\ + : \"Summary\"\n },\n \"content\": {\n \"anyOf\":\ + \ [\n {\n \"items\": {\n \"type\"\ + : \"string\"\n },\n \"type\": \"array\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The reasoning content or chain-of-thought text\",\n \"title\"\ + : \"Content\"\n }\n },\n \"title\": \"ReasoningContentEntityOptional\"\ + ,\n \"type\": \"object\"\n },\n \"TextContentEntityOptional\"\ + : {\n \"properties\": {\n \"type\": {\n \"anyOf\":\ + \ [\n {\n \"const\": \"text\",\n \"\ + type\": \"string\"\n },\n {\n \"type\"\ + : \"null\"\n }\n ],\n \"default\": null,\n\ + \ \"description\": \"The type of the message, in this case `text`.\"\ + ,\n \"title\": \"Type\"\n },\n \"author\": {\n \ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ + \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The style of the message. This is used by the client to determine how\ + \ to display the message.\"\n },\n \"format\": {\n \ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/TextFormat\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The format of the message. This is used by the client to determine how\ + \ to display the message.\"\n },\n \"content\": {\n \ + \ \"anyOf\": [\n {\n \"type\": \"string\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The contents of the text message.\",\n \"title\": \"Content\"\ + \n },\n \"attachments\": {\n \"anyOf\": [\n \ + \ {\n \"items\": {\n \"$ref\": \"#/$defs/FileAttachmentEntity\"\ + \n },\n \"type\": \"array\"\n },\n\ + \ {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"Optional\ + \ list of file attachments with structured metadata.\",\n \"title\"\ + : \"Attachments\"\n }\n },\n \"title\": \"TextContentEntityOptional\"\ + ,\n \"type\": \"object\"\n },\n \"TextFormat\": {\n \"enum\"\ + : [\n \"markdown\",\n \"plain\",\n \"code\"\n \ + \ ],\n \"title\": \"TextFormat\",\n \"type\": \"string\"\n \ + \ },\n \"ToolRequestContentEntityOptional\": {\n \"properties\"\ + : {\n \"type\": {\n \"anyOf\": [\n {\n \ + \ \"const\": \"tool_request\",\n \"type\": \"string\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The type of the message, in this case `tool_request`.\",\n \ + \ \"title\": \"Type\"\n },\n \"author\": {\n \"anyOf\"\ + : [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\n\ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ + \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The style of the message. This is used by the client to determine how\ + \ to display the message.\"\n },\n \"tool_call_id\": {\n \ + \ \"anyOf\": [\n {\n \"type\": \"string\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The ID of the tool call that is being requested.\",\n \"title\"\ + : \"Tool Call Id\"\n },\n \"name\": {\n \"anyOf\"\ + : [\n {\n \"type\": \"string\"\n },\n\ + \ {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"The\ + \ name of the tool that is being requested.\",\n \"title\": \"\ + Name\"\n },\n \"arguments\": {\n \"anyOf\": [\n \ + \ {\n \"additionalProperties\": true,\n \ + \ \"type\": \"object\"\n },\n {\n \ + \ \"type\": \"null\"\n }\n ],\n \"default\"\ + : null,\n \"description\": \"The arguments to the tool.\",\n \ + \ \"title\": \"Arguments\"\n }\n },\n \"title\"\ + : \"ToolRequestContentEntityOptional\",\n \"type\": \"object\"\n \ + \ },\n \"ToolResponseContentEntityOptional\": {\n \"properties\"\ + : {\n \"type\": {\n \"anyOf\": [\n {\n \ + \ \"const\": \"tool_response\",\n \"type\": \"string\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The type of the message, in this case `tool_response`.\",\n \ + \ \"title\": \"Type\"\n },\n \"author\": {\n \"\ + anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ + \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The style of the message. This is used by the client to determine how\ + \ to display the message.\"\n },\n \"tool_call_id\": {\n \ + \ \"anyOf\": [\n {\n \"type\": \"string\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The ID of the tool call that is being responded to.\",\n \"\ + title\": \"Tool Call Id\"\n },\n \"name\": {\n \"\ + anyOf\": [\n {\n \"type\": \"string\"\n \ + \ },\n {\n \"type\": \"null\"\n }\n\ + \ ],\n \"default\": null,\n \"description\":\ + \ \"The name of the tool that is being responded to.\",\n \"title\"\ + : \"Name\"\n },\n \"content\": {\n \"anyOf\": [\n\ + \ {},\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The result of the tool.\",\n \"title\": \"Content\"\n \ + \ }\n },\n \"title\": \"ToolResponseContentEntityOptional\"\ + ,\n \"type\": \"object\"\n }\n },\n \"description\": \"Filter\ + \ model for TaskMessage - all fields optional for flexible filtering.\\\ + n\\nThe `exclude` field determines whether this filter is inclusionary or\ + \ exclusionary.\\nWhen multiple filters are provided:\\n- Inclusionary filters\ + \ (exclude=False) are OR'd together\\n- Exclusionary filters (exclude=True)\ + \ are OR'd together and negated with $nor\\n- The two groups are AND'd:\ + \ (include1 OR include2) AND NOT (exclude1 OR exclude2)\",\n \"properties\"\ + : {\n \"content\": {\n \"anyOf\": [\n {\n \"$ref\"\ + : \"#/$defs/ToolRequestContentEntityOptional\"\n },\n {\n\ + \ \"$ref\": \"#/$defs/DataContentEntityOptional\"\n },\n\ + \ {\n \"$ref\": \"#/$defs/TextContentEntityOptional\"\n\ + \ },\n {\n \"$ref\": \"#/$defs/ToolResponseContentEntityOptional\"\ + \n },\n {\n \"$ref\": \"#/$defs/ReasoningContentEntityOptional\"\ + \n },\n {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"Filter by message\ + \ content\",\n \"title\": \"Content\"\n },\n \"streaming_status\"\ + : {\n \"anyOf\": [\n {\n \"enum\": [\n \"\ + IN_PROGRESS\",\n \"DONE\"\n ],\n \"type\":\ + \ \"string\"\n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\": \"Filter\ + \ by streaming status\",\n \"title\": \"Streaming Status\"\n },\n\ + \ \"exclude\": {\n \"default\": false,\n \"description\": \"\ + If true, this filter excludes matching messages\",\n \"title\": \"\ + Exclude\",\n \"type\": \"boolean\"\n }\n },\n \"title\": \"TaskMessageEntityFilter\"\ + ,\n \"type\": \"object\"\n}\n\nEach filter can include:\n- `content`: Filter\ + \ by message content (type, author, data fields)\n- `streaming_status`:\ + \ Filter by status (\"IN_PROGRESS\" or \"DONE\")\n- `exclude`: If true,\ + \ excludes matching messages (default: false)\n\nMultiple filters are combined:\ + \ inclusionary filters (exclude=false) are OR'd together,\nexclusionary\ + \ filters (exclude=true) are OR'd and negated, then both groups are AND'd.\n" + examples: + single_filter: + summary: Filter by content type + value: '{"content": {"type": "text"}}' + multiple_types: + summary: Filter multiple content types (OR) + value: '[{"content": {"type": "text"}}, {"content": {"type": "data"}}]' + with_exclusion: + summary: Include data messages, exclude specific data types + value: '[{"content": {"type": "data"}}, {"content": {"data": {"type": + "error_report"}}, "exclude": true}]' + nested_data: + summary: Filter by nested data field + value: '{"content": {"data": {"type": "report_status_update"}}}' + - name: task_id + in: query + required: true + schema: + type: string + description: The task ID + title: Task Id + description: The task ID + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TaskMessage' + title: Response List Messages Messages Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /messages/{message_id}: + put: + tags: + - Messages + summary: Update Message + operationId: update_message_messages__message_id__put + parameters: + - name: message_id + in: path + required: true + schema: + type: string + title: Message Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateTaskMessageRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/TaskMessage' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Messages + summary: Get Message + operationId: get_message_messages__message_id__get + parameters: + - name: message_id + in: path + required: true + schema: + type: string + title: Message Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/TaskMessage' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /messages/paginated: + get: + tags: + - Messages + summary: List Messages Paginated + description: "List messages for a task with cursor-based pagination.\n\nThis\ + \ endpoint is designed for infinite scroll UIs where new messages may arrive\n\ + while paginating through older ones.\n\nArgs:\n task_id: The task ID to\ + \ filter messages by\n limit: Maximum number of messages to return (default:\ + \ 50)\n cursor: Opaque cursor string for pagination. Pass the `next_cursor`\ + \ from\n a previous response to get the next page.\n direction:\ + \ Pagination direction - \"older\" to get older messages (default),\n \ + \ \"newer\" to get newer messages.\n\nReturns:\n PaginatedMessagesResponse\ + \ with:\n - data: List of messages (newest first when direction=\"older\"\ + )\n - next_cursor: Cursor for fetching the next page (null if no more pages)\n\ + \ - has_more: Whether there are more messages to fetch\n\nExample:\n \ + \ First request: GET /messages/paginated?task_id=xxx&limit=50\n Next page:\ + \ GET /messages/paginated?task_id=xxx&limit=50&cursor=" + operationId: list_messages_paginated_messages_paginated_get + parameters: + - name: limit + in: query + required: false + schema: + type: integer + default: 50 + title: Limit + - name: cursor + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Cursor + - name: direction + in: query + required: false + schema: + enum: + - older + - newer + type: string + default: older + title: Direction + - name: filters + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: "JSON-encoded array of TaskMessageEntityFilter objects.\n\n\ + Schema: {\n \"$defs\": {\n \"DataContentEntityOptional\": {\n \ + \ \"properties\": {\n \"type\": {\n \"anyOf\": [\n \ + \ {\n \"const\": \"data\",\n \"type\"\ + : \"string\"\n },\n {\n \"type\": \"\ + null\"\n }\n ],\n \"default\": null,\n \ + \ \"description\": \"The type of the message, in this case `data`.\"\ + ,\n \"title\": \"Type\"\n },\n \"author\": {\n\ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The role of the messages author, in this case `system`,\ + \ `user`, `assistant`, or `tool`.\"\n },\n \"style\": {\n\ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The style of the message. This is used by the client\ + \ to determine how to display the message.\"\n },\n \"data\"\ + : {\n \"anyOf\": [\n {\n \"additionalProperties\"\ + : true,\n \"type\": \"object\"\n },\n \ + \ {\n \"type\": \"null\"\n }\n ],\n\ + \ \"default\": null,\n \"description\": \"The contents\ + \ of the data message.\",\n \"title\": \"Data\"\n }\n\ + \ },\n \"title\": \"DataContentEntityOptional\",\n \"type\"\ + : \"object\"\n },\n \"FileAttachmentEntity\": {\n \"description\"\ + : \"Represents a file attachment in messages.\",\n \"properties\"\ + : {\n \"file_id\": {\n \"description\": \"The unique ID\ + \ of the attached file\",\n \"title\": \"File Id\",\n \ + \ \"type\": \"string\"\n },\n \"name\": {\n \"\ + description\": \"The name of the file\",\n \"title\": \"Name\"\ + ,\n \"type\": \"string\"\n },\n \"size\": {\n \ + \ \"description\": \"The size of the file in bytes\",\n \ + \ \"title\": \"Size\",\n \"type\": \"integer\"\n },\n\ + \ \"type\": {\n \"description\": \"The MIME type or content\ + \ type of the file\",\n \"title\": \"Type\",\n \"type\"\ + : \"string\"\n }\n },\n \"required\": [\n \"file_id\"\ + ,\n \"name\",\n \"size\",\n \"type\"\n ],\n\ + \ \"title\": \"FileAttachmentEntity\",\n \"type\": \"object\"\ + \n },\n \"MessageAuthor\": {\n \"enum\": [\n \"user\"\ + ,\n \"agent\"\n ],\n \"title\": \"MessageAuthor\",\n\ + \ \"type\": \"string\"\n },\n \"MessageStyle\": {\n \"\ + enum\": [\n \"static\",\n \"active\"\n ],\n \"\ + title\": \"MessageStyle\",\n \"type\": \"string\"\n },\n \"\ + ReasoningContentEntityOptional\": {\n \"properties\": {\n \ + \ \"type\": {\n \"anyOf\": [\n {\n \"\ + const\": \"reasoning\",\n \"type\": \"string\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The type of the message, in this case `reasoning`.\",\n \"\ + title\": \"Type\"\n },\n \"author\": {\n \"anyOf\"\ + : [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\n\ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The role of the messages author, in this case `system`,\ + \ `user`, `assistant`, or `tool`.\"\n },\n \"style\": {\n\ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The style of the message. This is used by the client\ + \ to determine how to display the message.\"\n },\n \"summary\"\ + : {\n \"anyOf\": [\n {\n \"items\": {\n\ + \ \"type\": \"string\"\n },\n \ + \ \"type\": \"array\"\n },\n {\n \"\ + type\": \"null\"\n }\n ],\n \"default\":\ + \ null,\n \"description\": \"A list of short reasoning summaries\"\ + ,\n \"title\": \"Summary\"\n },\n \"content\":\ + \ {\n \"anyOf\": [\n {\n \"items\": {\n\ + \ \"type\": \"string\"\n },\n \ + \ \"type\": \"array\"\n },\n {\n \"\ + type\": \"null\"\n }\n ],\n \"default\":\ + \ null,\n \"description\": \"The reasoning content or chain-of-thought\ + \ text\",\n \"title\": \"Content\"\n }\n },\n \ + \ \"title\": \"ReasoningContentEntityOptional\",\n \"type\": \"\ + object\"\n },\n \"TextContentEntityOptional\": {\n \"properties\"\ + : {\n \"type\": {\n \"anyOf\": [\n {\n \ + \ \"const\": \"text\",\n \"type\": \"string\"\n\ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The type of the message, in this case `text`.\",\n \ + \ \"title\": \"Type\"\n },\n \"author\": {\n \ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The role of the messages author, in this case `system`,\ + \ `user`, `assistant`, or `tool`.\"\n },\n \"style\": {\n\ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The style of the message. This is used by the client\ + \ to determine how to display the message.\"\n },\n \"format\"\ + : {\n \"anyOf\": [\n {\n \"$ref\": \"\ + #/$defs/TextFormat\"\n },\n {\n \"\ + type\": \"null\"\n }\n ],\n \"default\":\ + \ null,\n \"description\": \"The format of the message. This\ + \ is used by the client to determine how to display the message.\"\n \ + \ },\n \"content\": {\n \"anyOf\": [\n \ + \ {\n \"type\": \"string\"\n },\n \ + \ {\n \"type\": \"null\"\n }\n ],\n\ + \ \"default\": null,\n \"description\": \"The contents\ + \ of the text message.\",\n \"title\": \"Content\"\n },\n\ + \ \"attachments\": {\n \"anyOf\": [\n {\n \ + \ \"items\": {\n \"$ref\": \"#/$defs/FileAttachmentEntity\"\ + \n },\n \"type\": \"array\"\n },\n\ + \ {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"\ + Optional list of file attachments with structured metadata.\",\n \ + \ \"title\": \"Attachments\"\n }\n },\n \"title\"\ + : \"TextContentEntityOptional\",\n \"type\": \"object\"\n },\n\ + \ \"TextFormat\": {\n \"enum\": [\n \"markdown\",\n \ + \ \"plain\",\n \"code\"\n ],\n \"title\": \"TextFormat\"\ + ,\n \"type\": \"string\"\n },\n \"ToolRequestContentEntityOptional\"\ + : {\n \"properties\": {\n \"type\": {\n \"anyOf\"\ + : [\n {\n \"const\": \"tool_request\",\n \ + \ \"type\": \"string\"\n },\n {\n \ + \ \"type\": \"null\"\n }\n ],\n \"\ + default\": null,\n \"description\": \"The type of the message,\ + \ in this case `tool_request`.\",\n \"title\": \"Type\"\n \ + \ },\n \"author\": {\n \"anyOf\": [\n {\n\ + \ \"$ref\": \"#/$defs/MessageAuthor\"\n },\n \ + \ {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"\ + The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\"\ + : [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\n\ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The style of the message. This is used by the client\ + \ to determine how to display the message.\"\n },\n \"tool_call_id\"\ + : {\n \"anyOf\": [\n {\n \"type\": \"\ + string\"\n },\n {\n \"type\": \"null\"\ + \n }\n ],\n \"default\": null,\n \ + \ \"description\": \"The ID of the tool call that is being requested.\"\ + ,\n \"title\": \"Tool Call Id\"\n },\n \"name\"\ + : {\n \"anyOf\": [\n {\n \"type\": \"\ + string\"\n },\n {\n \"type\": \"null\"\ + \n }\n ],\n \"default\": null,\n \ + \ \"description\": \"The name of the tool that is being requested.\"\ + ,\n \"title\": \"Name\"\n },\n \"arguments\": {\n\ + \ \"anyOf\": [\n {\n \"additionalProperties\"\ + : true,\n \"type\": \"object\"\n },\n \ + \ {\n \"type\": \"null\"\n }\n ],\n\ + \ \"default\": null,\n \"description\": \"The arguments\ + \ to the tool.\",\n \"title\": \"Arguments\"\n }\n \ + \ },\n \"title\": \"ToolRequestContentEntityOptional\",\n \ + \ \"type\": \"object\"\n },\n \"ToolResponseContentEntityOptional\"\ + : {\n \"properties\": {\n \"type\": {\n \"anyOf\"\ + : [\n {\n \"const\": \"tool_response\",\n \ + \ \"type\": \"string\"\n },\n {\n \ + \ \"type\": \"null\"\n }\n ],\n \"\ + default\": null,\n \"description\": \"The type of the message,\ + \ in this case `tool_response`.\",\n \"title\": \"Type\"\n \ + \ },\n \"author\": {\n \"anyOf\": [\n \ + \ {\n \"$ref\": \"#/$defs/MessageAuthor\"\n },\n\ + \ {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"\ + The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\"\ + : [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\n\ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"\ + description\": \"The style of the message. This is used by the client\ + \ to determine how to display the message.\"\n },\n \"tool_call_id\"\ + : {\n \"anyOf\": [\n {\n \"type\": \"\ + string\"\n },\n {\n \"type\": \"null\"\ + \n }\n ],\n \"default\": null,\n \ + \ \"description\": \"The ID of the tool call that is being responded\ + \ to.\",\n \"title\": \"Tool Call Id\"\n },\n \"\ + name\": {\n \"anyOf\": [\n {\n \"type\"\ + : \"string\"\n },\n {\n \"type\": \"\ + null\"\n }\n ],\n \"default\": null,\n \ + \ \"description\": \"The name of the tool that is being responded\ + \ to.\",\n \"title\": \"Name\"\n },\n \"content\"\ + : {\n \"anyOf\": [\n {},\n {\n \ + \ \"type\": \"null\"\n }\n ],\n \"default\"\ + : null,\n \"description\": \"The result of the tool.\",\n \ + \ \"title\": \"Content\"\n }\n },\n \"title\":\ + \ \"ToolResponseContentEntityOptional\",\n \"type\": \"object\"\n\ + \ }\n },\n \"description\": \"Filter model for TaskMessage - all\ + \ fields optional for flexible filtering.\\n\\nThe `exclude` field determines\ + \ whether this filter is inclusionary or exclusionary.\\nWhen multiple\ + \ filters are provided:\\n- Inclusionary filters (exclude=False) are OR'd\ + \ together\\n- Exclusionary filters (exclude=True) are OR'd together and\ + \ negated with $nor\\n- The two groups are AND'd: (include1 OR include2)\ + \ AND NOT (exclude1 OR exclude2)\",\n \"properties\": {\n \"content\"\ + : {\n \"anyOf\": [\n {\n \"$ref\": \"#/$defs/ToolRequestContentEntityOptional\"\ + \n },\n {\n \"$ref\": \"#/$defs/DataContentEntityOptional\"\ + \n },\n {\n \"$ref\": \"#/$defs/TextContentEntityOptional\"\ + \n },\n {\n \"$ref\": \"#/$defs/ToolResponseContentEntityOptional\"\ + \n },\n {\n \"$ref\": \"#/$defs/ReasoningContentEntityOptional\"\ + \n },\n {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"Filter by message\ + \ content\",\n \"title\": \"Content\"\n },\n \"streaming_status\"\ + : {\n \"anyOf\": [\n {\n \"enum\": [\n \ + \ \"IN_PROGRESS\",\n \"DONE\"\n ],\n \"\ + type\": \"string\"\n },\n {\n \"type\": \"null\"\ + \n }\n ],\n \"default\": null,\n \"description\"\ + : \"Filter by streaming status\",\n \"title\": \"Streaming Status\"\ + \n },\n \"exclude\": {\n \"default\": false,\n \"description\"\ + : \"If true, this filter excludes matching messages\",\n \"title\"\ + : \"Exclude\",\n \"type\": \"boolean\"\n }\n },\n \"title\"\ + : \"TaskMessageEntityFilter\",\n \"type\": \"object\"\n}\n\nEach filter\ + \ can include:\n- `content`: Filter by message content (type, author,\ + \ data fields)\n- `streaming_status`: Filter by status (\"IN_PROGRESS\"\ + \ or \"DONE\")\n- `exclude`: If true, excludes matching messages (default:\ + \ false)\n\nMultiple filters are combined: inclusionary filters (exclude=false)\ + \ are OR'd together,\nexclusionary filters (exclude=true) are OR'd and\ + \ negated, then both groups are AND'd.\n" + title: Filters + description: "JSON-encoded array of TaskMessageEntityFilter objects.\n\nSchema:\ + \ {\n \"$defs\": {\n \"DataContentEntityOptional\": {\n \"properties\"\ + : {\n \"type\": {\n \"anyOf\": [\n {\n \ + \ \"const\": \"data\",\n \"type\": \"string\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The type of the message, in this case `data`.\",\n \"title\"\ + : \"Type\"\n },\n \"author\": {\n \"anyOf\": [\n\ + \ {\n \"$ref\": \"#/$defs/MessageAuthor\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ + \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The style of the message. This is used by the client to determine how\ + \ to display the message.\"\n },\n \"data\": {\n \ + \ \"anyOf\": [\n {\n \"additionalProperties\": true,\n\ + \ \"type\": \"object\"\n },\n {\n \ + \ \"type\": \"null\"\n }\n ],\n \"\ + default\": null,\n \"description\": \"The contents of the data\ + \ message.\",\n \"title\": \"Data\"\n }\n },\n \ + \ \"title\": \"DataContentEntityOptional\",\n \"type\": \"object\"\ + \n },\n \"FileAttachmentEntity\": {\n \"description\": \"Represents\ + \ a file attachment in messages.\",\n \"properties\": {\n \"\ + file_id\": {\n \"description\": \"The unique ID of the attached\ + \ file\",\n \"title\": \"File Id\",\n \"type\": \"string\"\ + \n },\n \"name\": {\n \"description\": \"The name\ + \ of the file\",\n \"title\": \"Name\",\n \"type\": \"\ + string\"\n },\n \"size\": {\n \"description\": \"\ + The size of the file in bytes\",\n \"title\": \"Size\",\n \ + \ \"type\": \"integer\"\n },\n \"type\": {\n \ + \ \"description\": \"The MIME type or content type of the file\",\n \ + \ \"title\": \"Type\",\n \"type\": \"string\"\n }\n\ + \ },\n \"required\": [\n \"file_id\",\n \"name\"\ + ,\n \"size\",\n \"type\"\n ],\n \"title\": \"FileAttachmentEntity\"\ + ,\n \"type\": \"object\"\n },\n \"MessageAuthor\": {\n \"\ + enum\": [\n \"user\",\n \"agent\"\n ],\n \"title\"\ + : \"MessageAuthor\",\n \"type\": \"string\"\n },\n \"MessageStyle\"\ + : {\n \"enum\": [\n \"static\",\n \"active\"\n ],\n\ + \ \"title\": \"MessageStyle\",\n \"type\": \"string\"\n },\n\ + \ \"ReasoningContentEntityOptional\": {\n \"properties\": {\n \ + \ \"type\": {\n \"anyOf\": [\n {\n \ + \ \"const\": \"reasoning\",\n \"type\": \"string\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The type of the message, in this case `reasoning`.\",\n \"\ + title\": \"Type\"\n },\n \"author\": {\n \"anyOf\"\ + : [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\n\ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ + \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The style of the message. This is used by the client to determine how\ + \ to display the message.\"\n },\n \"summary\": {\n \ + \ \"anyOf\": [\n {\n \"items\": {\n \ + \ \"type\": \"string\"\n },\n \"type\":\ + \ \"array\"\n },\n {\n \"type\": \"null\"\ + \n }\n ],\n \"default\": null,\n \"\ + description\": \"A list of short reasoning summaries\",\n \"title\"\ + : \"Summary\"\n },\n \"content\": {\n \"anyOf\":\ + \ [\n {\n \"items\": {\n \"type\"\ + : \"string\"\n },\n \"type\": \"array\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The reasoning content or chain-of-thought text\",\n \"title\"\ + : \"Content\"\n }\n },\n \"title\": \"ReasoningContentEntityOptional\"\ + ,\n \"type\": \"object\"\n },\n \"TextContentEntityOptional\"\ + : {\n \"properties\": {\n \"type\": {\n \"anyOf\":\ + \ [\n {\n \"const\": \"text\",\n \"\ + type\": \"string\"\n },\n {\n \"type\"\ + : \"null\"\n }\n ],\n \"default\": null,\n\ + \ \"description\": \"The type of the message, in this case `text`.\"\ + ,\n \"title\": \"Type\"\n },\n \"author\": {\n \ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ + \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The style of the message. This is used by the client to determine how\ + \ to display the message.\"\n },\n \"format\": {\n \ + \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/TextFormat\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The format of the message. This is used by the client to determine how\ + \ to display the message.\"\n },\n \"content\": {\n \ + \ \"anyOf\": [\n {\n \"type\": \"string\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The contents of the text message.\",\n \"title\": \"Content\"\ + \n },\n \"attachments\": {\n \"anyOf\": [\n \ + \ {\n \"items\": {\n \"$ref\": \"#/$defs/FileAttachmentEntity\"\ + \n },\n \"type\": \"array\"\n },\n\ + \ {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"Optional\ + \ list of file attachments with structured metadata.\",\n \"title\"\ + : \"Attachments\"\n }\n },\n \"title\": \"TextContentEntityOptional\"\ + ,\n \"type\": \"object\"\n },\n \"TextFormat\": {\n \"enum\"\ + : [\n \"markdown\",\n \"plain\",\n \"code\"\n \ + \ ],\n \"title\": \"TextFormat\",\n \"type\": \"string\"\n \ + \ },\n \"ToolRequestContentEntityOptional\": {\n \"properties\"\ + : {\n \"type\": {\n \"anyOf\": [\n {\n \ + \ \"const\": \"tool_request\",\n \"type\": \"string\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The type of the message, in this case `tool_request`.\",\n \ + \ \"title\": \"Type\"\n },\n \"author\": {\n \"anyOf\"\ + : [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\n\ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ + \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The style of the message. This is used by the client to determine how\ + \ to display the message.\"\n },\n \"tool_call_id\": {\n \ + \ \"anyOf\": [\n {\n \"type\": \"string\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The ID of the tool call that is being requested.\",\n \"title\"\ + : \"Tool Call Id\"\n },\n \"name\": {\n \"anyOf\"\ + : [\n {\n \"type\": \"string\"\n },\n\ + \ {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"The\ + \ name of the tool that is being requested.\",\n \"title\": \"\ + Name\"\n },\n \"arguments\": {\n \"anyOf\": [\n \ + \ {\n \"additionalProperties\": true,\n \ + \ \"type\": \"object\"\n },\n {\n \ + \ \"type\": \"null\"\n }\n ],\n \"default\"\ + : null,\n \"description\": \"The arguments to the tool.\",\n \ + \ \"title\": \"Arguments\"\n }\n },\n \"title\"\ + : \"ToolRequestContentEntityOptional\",\n \"type\": \"object\"\n \ + \ },\n \"ToolResponseContentEntityOptional\": {\n \"properties\"\ + : {\n \"type\": {\n \"anyOf\": [\n {\n \ + \ \"const\": \"tool_response\",\n \"type\": \"string\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The type of the message, in this case `tool_response`.\",\n \ + \ \"title\": \"Type\"\n },\n \"author\": {\n \"\ + anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ + \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ + \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ + \ },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The style of the message. This is used by the client to determine how\ + \ to display the message.\"\n },\n \"tool_call_id\": {\n \ + \ \"anyOf\": [\n {\n \"type\": \"string\"\ + \n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The ID of the tool call that is being responded to.\",\n \"\ + title\": \"Tool Call Id\"\n },\n \"name\": {\n \"\ + anyOf\": [\n {\n \"type\": \"string\"\n \ + \ },\n {\n \"type\": \"null\"\n }\n\ + \ ],\n \"default\": null,\n \"description\":\ + \ \"The name of the tool that is being responded to.\",\n \"title\"\ + : \"Name\"\n },\n \"content\": {\n \"anyOf\": [\n\ + \ {},\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\"\ + : \"The result of the tool.\",\n \"title\": \"Content\"\n \ + \ }\n },\n \"title\": \"ToolResponseContentEntityOptional\"\ + ,\n \"type\": \"object\"\n }\n },\n \"description\": \"Filter\ + \ model for TaskMessage - all fields optional for flexible filtering.\\\ + n\\nThe `exclude` field determines whether this filter is inclusionary or\ + \ exclusionary.\\nWhen multiple filters are provided:\\n- Inclusionary filters\ + \ (exclude=False) are OR'd together\\n- Exclusionary filters (exclude=True)\ + \ are OR'd together and negated with $nor\\n- The two groups are AND'd:\ + \ (include1 OR include2) AND NOT (exclude1 OR exclude2)\",\n \"properties\"\ + : {\n \"content\": {\n \"anyOf\": [\n {\n \"$ref\"\ + : \"#/$defs/ToolRequestContentEntityOptional\"\n },\n {\n\ + \ \"$ref\": \"#/$defs/DataContentEntityOptional\"\n },\n\ + \ {\n \"$ref\": \"#/$defs/TextContentEntityOptional\"\n\ + \ },\n {\n \"$ref\": \"#/$defs/ToolResponseContentEntityOptional\"\ + \n },\n {\n \"$ref\": \"#/$defs/ReasoningContentEntityOptional\"\ + \n },\n {\n \"type\": \"null\"\n }\n \ + \ ],\n \"default\": null,\n \"description\": \"Filter by message\ + \ content\",\n \"title\": \"Content\"\n },\n \"streaming_status\"\ + : {\n \"anyOf\": [\n {\n \"enum\": [\n \"\ + IN_PROGRESS\",\n \"DONE\"\n ],\n \"type\":\ + \ \"string\"\n },\n {\n \"type\": \"null\"\n \ + \ }\n ],\n \"default\": null,\n \"description\": \"Filter\ + \ by streaming status\",\n \"title\": \"Streaming Status\"\n },\n\ + \ \"exclude\": {\n \"default\": false,\n \"description\": \"\ + If true, this filter excludes matching messages\",\n \"title\": \"\ + Exclude\",\n \"type\": \"boolean\"\n }\n },\n \"title\": \"TaskMessageEntityFilter\"\ + ,\n \"type\": \"object\"\n}\n\nEach filter can include:\n- `content`: Filter\ + \ by message content (type, author, data fields)\n- `streaming_status`:\ + \ Filter by status (\"IN_PROGRESS\" or \"DONE\")\n- `exclude`: If true,\ + \ excludes matching messages (default: false)\n\nMultiple filters are combined:\ + \ inclusionary filters (exclude=false) are OR'd together,\nexclusionary\ + \ filters (exclude=true) are OR'd and negated, then both groups are AND'd.\n" + examples: + single_filter: + summary: Filter by content type + value: '{"content": {"type": "text"}}' + multiple_types: + summary: Filter multiple content types (OR) + value: '[{"content": {"type": "text"}}, {"content": {"type": "data"}}]' + with_exclusion: + summary: Include data messages, exclude specific data types + value: '[{"content": {"type": "data"}}, {"content": {"data": {"type": + "error_report"}}, "exclude": true}]' + nested_data: + summary: Filter by nested data field + value: '{"content": {"data": {"type": "report_status_update"}}}' + - name: task_id + in: query + required: true + schema: + type: string + description: The task ID + title: Task Id + description: The task ID + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedMessagesResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /spans: + post: + tags: + - Spans + summary: Create Span + description: Create a new span with the provided parameters + operationId: create_span_spans_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSpanRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Span' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Spans + summary: List Spans + description: List spans, optionally filtered by trace_id and/or task_id + operationId: list_spans_spans_get + parameters: + - name: trace_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Trace Id + - name: task_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Task Id + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 50 + title: Limit + - name: page_number + in: query + required: false + schema: + type: integer + minimum: 1 + default: 1 + title: Page Number + - name: order_by + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Order By + - name: order_direction + in: query + required: false + schema: + type: string + default: desc + title: Order Direction + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Span' + title: Response List Spans Spans Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /spans/{span_id}: + patch: + tags: + - Spans + summary: Partial Update Span + description: Update a span with the provided output data and mark it as complete + operationId: partial_update_span_spans__span_id__patch + parameters: + - name: span_id + in: path + required: true + schema: + type: string + title: Span Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateSpanRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Span' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Spans + summary: Get Span + description: Get a span by ID + operationId: get_span_spans__span_id__get + parameters: + - name: span_id + in: path + required: true + schema: + type: string + title: Span Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Span' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /states: + post: + tags: + - States + summary: Create Task State + operationId: create_task_state_states_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateStateRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/State' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - States + summary: List States + description: List all states, optionally filtered by query parameters. + operationId: filter_states_states_get + parameters: + - name: task_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: Task ID + title: Task Id + description: Task ID + - name: agent_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: Agent ID + title: Agent Id + description: Agent ID + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + description: Limit + default: 50 + title: Limit + description: Limit + - name: page_number + in: query + required: false + schema: + type: integer + minimum: 1 + description: Page number + default: 1 + title: Page Number + description: Page number + - name: order_by + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: Field to order by + title: Order By + description: Field to order by + - name: order_direction + in: query + required: false + schema: + type: string + description: Order direction (asc or desc) + default: desc + title: Order Direction + description: Order direction (asc or desc) + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/State' + title: Response Filter States States Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /states/{state_id}: + get: + tags: + - States + summary: Get State by State ID + description: Get a state by its unique state ID. + operationId: get_state_states__state_id__get + parameters: + - name: state_id + in: path + required: true + schema: + type: string + title: State Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/State' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + put: + tags: + - States + summary: Update Task State + operationId: update_task_state_states__state_id__put + parameters: + - name: state_id + in: path + required: true + schema: + type: string + title: State Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateStateRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/State' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - States + summary: Delete Task State + operationId: delete_task_state_states__state_id__delete + parameters: + - name: state_id + in: path + required: true + schema: + type: string + title: State Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/State' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /events/{event_id}: + get: + tags: + - Events + summary: Get Event + operationId: get_event_events__event_id__get + parameters: + - name: event_id + in: path + required: true + schema: + type: string + title: Event Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Event' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /events: + get: + tags: + - Events + summary: List Events + description: 'List events for a specific task and agent. + + + Optionally filter for events after a specific sequence ID. + + Results are ordered by sequence_id.' + operationId: list_events_events_get + parameters: + - name: last_processed_event_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: Optional event ID to get events after this ID + title: Last Processed Event Id + description: Optional event ID to get events after this ID + - name: limit + in: query + required: false + schema: + anyOf: + - type: integer + maximum: 1000 + minimum: 1 + - type: 'null' + description: Optional limit on number of results + title: Limit + description: Optional limit on number of results + - name: task_id + in: query + required: true + schema: + type: string + description: The task ID to filter events by + title: Task Id + description: The task ID to filter events by + - name: agent_id + in: query + required: true + schema: + type: string + description: The agent ID to filter events by + title: Agent Id + description: The agent ID to filter events by + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Event' + title: Response List Events Events Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tracker/{tracker_id}: + get: + tags: + - Agent Task Tracker + summary: Get Agent Task Tracker + description: Get agent task tracker by tracker ID + operationId: get_agent_task_tracker_tracker__tracker_id__get + parameters: + - name: tracker_id + in: path + required: true + schema: + type: string + title: Tracker Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AgentTaskTracker' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + put: + tags: + - Agent Task Tracker + summary: Update Agent Task Tracker + description: Update agent task tracker by tracker ID + operationId: update_agent_task_tracker_tracker__tracker_id__put + parameters: + - name: tracker_id + in: path + required: true + schema: + type: string + title: Tracker Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateAgentTaskTrackerRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AgentTaskTracker' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tracker: + get: + tags: + - Agent Task Tracker + summary: List Agent Task Trackers + description: List all agent task trackers, optionally filtered by query parameters. + operationId: filter_agent_task_tracker_tracker_get + parameters: + - name: agent_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: Agent ID + title: Agent Id + description: Agent ID + - name: task_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: Task ID + title: Task Id + description: Task ID + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + description: Limit + default: 50 + title: Limit + description: Limit + - name: page_number + in: query + required: false + schema: + type: integer + minimum: 1 + description: Page number + default: 1 + title: Page Number + description: Page number + - name: order_by + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: Field to order by + title: Order By + description: Field to order by + - name: order_direction + in: query + required: false + schema: + type: string + description: Order direction (asc or desc) + default: desc + title: Order Direction + description: Order direction (asc or desc) + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AgentTaskTracker' + title: Response Filter Agent Task Tracker Tracker Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agent_api_keys: + post: + tags: + - Agent APIKeys + summary: Create Api Key + operationId: create_api_key_agent_api_keys_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateAPIKeyRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/CreateAPIKeyResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Agent APIKeys + summary: List API keys for an agent ID + description: List API keys for an agent ID. + operationId: list_agent_api_keys_agent_api_keys_get + parameters: + - name: agent_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Agent Id + - name: agent_name + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Agent Name + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 50 + title: Limit + - name: page_number + in: query + required: false + schema: + type: integer + minimum: 1 + default: 1 + title: Page Number + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AgentAPIKey' + title: Response List Agent Api Keys Agent Api Keys Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agent_api_keys/name/{name}: + get: + tags: + - Agent APIKeys + summary: Return named API key for the agent ID + description: Return named API key for the agent ID. + operationId: get_agent_api_key_by_name_agent_api_keys_name__name__get + parameters: + - name: name + in: path + required: true + schema: + type: string + title: Name + - name: agent_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Agent Id + - name: agent_name + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Agent Name + - name: api_key_type + in: query + required: false + schema: + $ref: '#/components/schemas/AgentAPIKeyType' + default: external + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AgentAPIKey' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agent_api_keys/{id}: + get: + tags: + - Agent APIKeys + summary: Return the API key by ID + description: Return API key by ID. + operationId: get_agent_api_key_agent_api_keys__id__get + parameters: + - name: id + in: path + required: true + schema: + type: string + title: Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AgentAPIKey' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Agent APIKeys + summary: Delete API key by ID + description: Delete API key by ID. + operationId: delete_agent_api_key_agent_api_keys__id__delete + parameters: + - name: id + in: path + required: true + schema: + type: string + title: Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: string + title: Response Delete Agent Api Key Agent Api Keys Id Delete + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agent_api_keys/name/{api_key_name}: + delete: + tags: + - Agent APIKeys + summary: Delete API key by name + description: Delete API key by name. + operationId: delete_agent_api_key_by_name_agent_api_keys_name__api_key_name__delete + parameters: + - name: api_key_name + in: path + required: true + schema: + type: string + title: Api Key Name + - name: agent_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Agent Id + - name: agent_name + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Agent Name + - name: api_key_type + in: query + required: false + schema: + $ref: '#/components/schemas/AgentAPIKeyType' + default: external + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: string + title: Response Delete Agent Api Key By Name Agent Api Keys Name Api + Key Name Delete + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /deployment-history/{deployment_id}: + get: + tags: + - Deployment History + summary: Get Deployment by ID + description: Get a deployment record by its unique ID. + operationId: get_deployment_by_id_deployment_history__deployment_id__get + parameters: + - name: deployment_id + in: path + required: true + schema: + type: string + title: Deployment Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DeploymentHistory' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /deployment-history: + get: + tags: + - Deployment History + summary: List Deployments for an agent + description: List deployment history for an agent. + operationId: list_deployments_deployment_history_get + parameters: + - name: agent_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Agent Id + - name: agent_name + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Agent Name + - name: limit + in: query + required: false + schema: + type: integer + default: 50 + title: Limit + - name: page_number + in: query + required: false + schema: + type: integer + default: 1 + title: Page Number + - name: order_by + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Order By + - name: order_direction + in: query + required: false + schema: + type: string + default: desc + title: Order Direction + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DeploymentHistory' + title: Response List Deployments Deployment History Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/{agent_id}/deployments: + post: + tags: + - Deployments + summary: Create Deployment + description: Create a new deployment record in PENDING status. + operationId: create_deployment_agents__agent_id__deployments_post + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateDeploymentRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Deployment' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Deployments + summary: List Deployments + description: List deployments for an agent, newest first. + operationId: list_deployments_agents__agent_id__deployments_get + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + description: Limit + default: 50 + title: Limit + description: Limit + - name: page_number + in: query + required: false + schema: + type: integer + minimum: 1 + description: Page number + default: 1 + title: Page Number + description: Page number + - name: order_by + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: Field to order by + title: Order By + description: Field to order by + - name: order_direction + in: query + required: false + schema: + type: string + description: Order direction (asc or desc) + default: desc + title: Order Direction + description: Order direction (asc or desc) + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Deployment' + title: Response List Deployments Agents Agent Id Deployments Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/{agent_id}/deployments/{deployment_id}: + get: + tags: + - Deployments + summary: Get Deployment + description: Get a specific deployment by ID. + operationId: get_deployment_agents__agent_id__deployments__deployment_id__get + parameters: + - name: deployment_id + in: path + required: true + schema: + type: string + title: Deployment Id + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Deployment' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Deployments + summary: Delete Deployment + description: Delete a non-production deployment. + operationId: delete_deployment_agents__agent_id__deployments__deployment_id__delete + parameters: + - name: deployment_id + in: path + required: true + schema: + type: string + title: Deployment Id + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/{agent_id}/deployments/{deployment_id}/promote: + post: + tags: + - Deployments + summary: Promote Deployment + description: Promote a deployment to production with atomic cutover. + operationId: promote_deployment_agents__agent_id__deployments__deployment_id__promote_post + parameters: + - name: deployment_id + in: path + required: true + schema: + type: string + title: Deployment Id + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Deployment' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/{agent_id}/deployments/{deployment_id}/rpc: + post: + tags: + - Deployments + summary: Preview RPC + description: Send an RPC request to a specific deployment (for preview testing). + operationId: handle_deployment_rpc_agents__agent_id__deployments__deployment_id__rpc_post + parameters: + - name: deployment_id + in: path + required: true + schema: + type: string + title: Deployment Id + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AgentRPCRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AgentRPCResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/{agent_id}/schedules: + post: + tags: + - Schedules + summary: Create Schedule + description: Create a new schedule for recurring workflow execution for an agent. + operationId: create_schedule_agents__agent_id__schedules_post + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateScheduleRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduleResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Schedules + summary: List Agent Schedules + description: List all schedules for an agent. + operationId: list_schedules_agents__agent_id__schedules_get + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + - name: page_size + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Page Size + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduleListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/{agent_id}/schedules/{schedule_name}: + get: + tags: + - Schedules + summary: Get Schedule + description: Get details of a schedule by its name. + operationId: get_schedule_agents__agent_id__schedules__schedule_name__get + parameters: + - name: schedule_name + in: path + required: true + schema: + type: string + title: Schedule Name + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduleResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Schedules + summary: Delete Schedule + description: Delete a schedule permanently. + operationId: delete_schedule_agents__agent_id__schedules__schedule_name__delete + parameters: + - name: schedule_name + in: path + required: true + schema: + type: string + title: Schedule Name + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/{agent_id}/schedules/{schedule_name}/pause: + post: + tags: + - Schedules + summary: Pause Schedule + description: Pause a schedule to stop it from executing. + operationId: pause_schedule_agents__agent_id__schedules__schedule_name__pause_post + parameters: + - name: schedule_name + in: path + required: true + schema: + type: string + title: Schedule Name + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + requestBody: + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/PauseScheduleRequest' + - type: 'null' + title: Request + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduleResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/{agent_id}/schedules/{schedule_name}/unpause: + post: + tags: + - Schedules + summary: Unpause Schedule + description: Unpause/resume a schedule to allow it to execute again. + operationId: unpause_schedule_agents__agent_id__schedules__schedule_name__unpause_post + parameters: + - name: schedule_name + in: path + required: true + schema: + type: string + title: Schedule Name + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + requestBody: + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/UnpauseScheduleRequest' + - type: 'null' + title: Request + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduleResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agents/{agent_id}/schedules/{schedule_name}/trigger: + post: + tags: + - Schedules + summary: Trigger Schedule + description: Trigger a schedule to run immediately, regardless of its regular + schedule. + operationId: trigger_schedule_agents__agent_id__schedules__schedule_name__trigger_post + parameters: + - name: schedule_name + in: path + required: true + schema: + type: string + title: Schedule Name + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduleResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /checkpoints/get-tuple: + post: + tags: + - Checkpoints + summary: Get Checkpoint Tuple + operationId: get_checkpoint_tuple_checkpoints_get_tuple_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GetCheckpointTupleRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/CheckpointTupleResponse' + - type: 'null' + title: Response Get Checkpoint Tuple Checkpoints Get Tuple Post + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /checkpoints/put: + post: + tags: + - Checkpoints + summary: Put Checkpoint + operationId: put_checkpoint_checkpoints_put_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PutCheckpointRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PutCheckpointResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /checkpoints/put-writes: + post: + tags: + - Checkpoints + summary: Put Writes + operationId: put_writes_checkpoints_put_writes_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PutWritesRequest' + required: true + responses: + '204': + description: Successful Response + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /checkpoints/list: + post: + tags: + - Checkpoints + summary: List Checkpoints + operationId: list_checkpoints_checkpoints_list_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ListCheckpointsRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + items: + $ref: '#/components/schemas/CheckpointListItem' + type: array + title: Response List Checkpoints Checkpoints List Post + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /checkpoints/delete-thread: + post: + tags: + - Checkpoints + summary: Delete Thread + operationId: delete_thread_checkpoints_delete_thread_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteThreadRequest' + required: true + responses: + '204': + description: Successful Response + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/{task_id}/export: + get: + tags: + - task-retention + summary: Export Task + description: 'Build a self-contained snapshot of a task''s content surfaces. + + + Returns the exact payload format that POST /rehydrate accepts, so + + export → clean → rehydrate is a round-trip-equivalent operation.' + operationId: export_task_tasks__task_id__export_get + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ExportTaskResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + post: + tags: + - task-retention + summary: Export Task To Url + description: 'Build the task snapshot and PUT it to a caller-supplied presigned + URL. + + + Use this when the snapshot is too large for a JSON response body (long + + conversations, deep reasoning content, many attachments). The upload URL + + must be https and resolve to a public address — see SSRF guard.' + operationId: export_task_to_url_tasks__task_id__export_post + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExportTaskToUrlRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ExportTaskToUrlResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/{task_id}/clean: + post: + tags: + - task-retention + summary: Clean Task + description: 'Delete content-bearing rows for a stale task. + + + Refuses on active tasks, in-flight workflows, or unprocessed events + + regardless of `force`. The `force=true` flag only bypasses the + + idle-threshold check.' + operationId: clean_task_tasks__task_id__clean_post + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CleanTaskRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/CleanTaskResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /tasks/{task_id}/rehydrate: + post: + tags: + - task-retention + summary: Rehydrate Task + description: 'Restore content-bearing rows from a snapshot. + + + Two modes: + + - Inline: caller provides messages and task_states in the request body. + + - URL: caller provides snapshot_url; Agentex downloads and parses it. + + + Refuses if the task isn''t currently in a cleaned state, or if any supplied + + message/state ID already exists in Mongo (catches double-rehydrate).' + operationId: rehydrate_task_tasks__task_id__rehydrate_post + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RehydrateTaskRequest' + responses: + '204': + description: Successful Response + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' +components: + schemas: + ACPType: + type: string + enum: + - sync + - async + - agentic + title: ACPType + Agent: + properties: + id: + type: string + title: Id + description: The unique identifier of the agent. + name: + type: string + title: Name + description: The unique name of the agent. + description: + type: string + title: Description + description: The description of the action. + status: + $ref: '#/components/schemas/AgentStatus' + description: The status of the action, indicating if it's building, ready, + failed, etc. + default: Unknown + acp_type: + $ref: '#/components/schemas/ACPType' + description: The type of the ACP Server (Either sync or async) + status_reason: + anyOf: + - type: string + - type: 'null' + title: Status Reason + description: The reason for the status of the action. + created_at: + type: string + format: date-time + title: Created At + description: The timestamp when the agent was created + updated_at: + type: string + format: date-time + title: Updated At + description: The timestamp when the agent was last updated + registration_metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Registration Metadata + description: The metadata for the agent's registration. + registered_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Registered At + description: The timestamp when the agent was last registered + agent_input_type: + anyOf: + - $ref: '#/components/schemas/AgentInputType' + - type: 'null' + description: The type of input the agent expects. + production_deployment_id: + anyOf: + - type: string + - type: 'null' + title: Production Deployment Id + description: ID of the current production deployment. + type: object + required: + - id + - name + - description + - acp_type + - created_at + - updated_at + title: Agent + AgentAPIKey: + properties: + id: + type: string + title: Id + description: The unique identifier of the agent API key. + agent_id: + type: string + title: Agent Id + description: The UUID of the agent + created_at: + type: string + format: date-time + title: Created At + description: When the agent API key was created + name: + anyOf: + - type: string + - type: 'null' + title: Name + description: The optional name of the agent API key. + api_key_type: + $ref: '#/components/schemas/AgentAPIKeyType' + description: The type of the agent API key (either internal or external) + type: object + required: + - id + - agent_id + - created_at + - name + - api_key_type + title: AgentAPIKey + AgentAPIKeyType: + type: string + enum: + - internal + - external + - github + - slack + title: AgentAPIKeyType + AgentInputType: + type: string + enum: + - text + - json + title: AgentInputType + AgentRPCMethod: + type: string + enum: + - event/send + - task/create + - message/send + - task/cancel + title: AgentRPCMethod + AgentRPCParams: + anyOf: + - $ref: '#/components/schemas/CreateTaskRequest' + - $ref: '#/components/schemas/CancelTaskRequest' + - $ref: '#/components/schemas/SendMessageRequest' + - $ref: '#/components/schemas/SendEventRequest' + title: AgentRPCParams + description: The parameters for the agent RPC request + AgentRPCRequest: + properties: + jsonrpc: + type: string + const: '2.0' + title: Jsonrpc + default: '2.0' + method: + $ref: '#/components/schemas/AgentRPCMethod' + params: + $ref: '#/components/schemas/AgentRPCParams' + id: + anyOf: + - type: integer + - type: string + - type: 'null' + title: Id + type: object + required: + - method + - params + title: AgentRPCRequest + AgentRPCResponse: + properties: + jsonrpc: + type: string + const: '2.0' + title: Jsonrpc + default: '2.0' + result: + $ref: '#/components/schemas/AgentRPCResult' + description: The result of the agent RPC request + error: + anyOf: + - {} + - type: 'null' + title: Error + id: + anyOf: + - type: integer + - type: string + - type: 'null' + title: Id + type: object + required: + - result + title: AgentRPCResponse + AgentRPCResult: + anyOf: + - items: + $ref: '#/components/schemas/TaskMessage' + type: array + - $ref: '#/components/schemas/TaskMessageUpdate' + - $ref: '#/components/schemas/Task' + - $ref: '#/components/schemas/Event' + - type: 'null' + title: AgentRPCResult + AgentStatus: + type: string + enum: + - Ready + - Failed + - Unknown + - Deleted + - Unhealthy + - BuildOnly + title: AgentStatus + AgentTaskTracker: + properties: + id: + type: string + title: Id + description: The UUID of the agent task tracker + agent_id: + type: string + title: Agent Id + description: The UUID of the agent + task_id: + type: string + title: Task Id + description: The UUID of the task + status: + anyOf: + - type: string + - type: 'null' + title: Status + description: Processing status + status_reason: + anyOf: + - type: string + - type: 'null' + title: Status Reason + description: Optional status reason + last_processed_event_id: + anyOf: + - type: string + - type: 'null' + title: Last Processed Event Id + description: The last processed event ID + created_at: + type: string + format: date-time + title: Created At + description: When the agent task tracker was created + updated_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Updated At + description: When the agent task tracker was last updated + type: object + required: + - id + - agent_id + - task_id + - created_at + title: AgentTaskTracker + BatchCreateTaskMessagesRequest: + properties: + task_id: + type: string + title: The unique id of the task to send the messages to + contents: + items: + $ref: '#/components/schemas/TaskMessageContent' + type: array + title: The messages to send to the task. The order of the messages will + be the order they are added to the task. + created_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Optional caller-supplied base creation timestamp for the batch + description: Optional base timestamp. Each message in the batch is stamped + with base + i milliseconds to guarantee unique, monotonic ordering. If + omitted, the server stamps datetime.now(UTC) at insert time. + type: object + required: + - task_id + - contents + title: BatchCreateTaskMessagesRequest + BatchUpdateTaskMessagesRequest: + properties: + task_id: + type: string + title: The unique id of the task to update the messages of + updates: + additionalProperties: + $ref: '#/components/schemas/TaskMessageContent' + type: object + title: The updates to apply to the messages. The key is the TaskMessage + id and the value is the TaskMessageContent to update the message with. + type: object + required: + - task_id + - updates + title: BatchUpdateTaskMessagesRequest + BlobData: + properties: + channel: + type: string + title: Channel name + version: + type: string + title: Channel version + type: + type: string + title: Serialization type tag + blob: + anyOf: + - type: string + - type: 'null' + title: Base64-encoded binary data + type: object + required: + - channel + - version + - type + title: BlobData + BlobResponse: + properties: + channel: + type: string + title: Channel + version: + type: string + title: Version + type: + type: string + title: Type + blob: + anyOf: + - type: string + - type: 'null' + title: Blob + type: object + required: + - channel + - version + - type + title: BlobResponse + CancelTaskRequest: + properties: + task_id: + anyOf: + - type: string + - type: 'null' + title: Task Id + description: The ID of the task to cancel. Either this or task_name must + be provided. + task_name: + anyOf: + - type: string + - type: 'null' + title: Task Name + description: The name of the task to cancel. Either this or task_id must + be provided. + type: object + title: CancelTaskRequest + CheckpointListItem: + properties: + thread_id: + type: string + title: Thread Id + checkpoint_ns: + type: string + title: Checkpoint Ns + checkpoint_id: + type: string + title: Checkpoint Id + parent_checkpoint_id: + anyOf: + - type: string + - type: 'null' + title: Parent Checkpoint Id + checkpoint: + additionalProperties: true + type: object + title: Checkpoint + metadata: + additionalProperties: true + type: object + title: Metadata + type: object + required: + - thread_id + - checkpoint_ns + - checkpoint_id + - checkpoint + - metadata + title: CheckpointListItem + CheckpointTupleResponse: + properties: + thread_id: + type: string + title: Thread Id + checkpoint_ns: + type: string + title: Checkpoint Ns + checkpoint_id: + type: string + title: Checkpoint Id + parent_checkpoint_id: + anyOf: + - type: string + - type: 'null' + title: Parent Checkpoint Id + checkpoint: + additionalProperties: true + type: object + title: Checkpoint + metadata: + additionalProperties: true + type: object + title: Metadata + blobs: + items: + $ref: '#/components/schemas/BlobResponse' + type: array + title: Blobs + pending_writes: + items: + $ref: '#/components/schemas/WriteResponse' + type: array + title: Pending Writes + type: object + required: + - thread_id + - checkpoint_ns + - checkpoint_id + - checkpoint + - metadata + title: CheckpointTupleResponse + CleanTaskRequest: + properties: + force: + type: boolean + title: Force + description: Skip the idle-threshold check. Active-workflow and unprocessed-events + checks still apply. Admin use only. + default: false + idle_days: + type: integer + minimum: 1.0 + title: Idle Days + description: Idle threshold in days (ignored when force=true). + default: 7 + type: object + title: CleanTaskRequest + CleanTaskResponse: + properties: + task_id: + type: string + title: Task Id + cleaned_at: + type: string + format: date-time + title: Cleaned At + messages_deleted: + type: integer + title: Messages Deleted + task_states_deleted: + type: integer + title: Task States Deleted + events_deleted: + type: integer + title: Events Deleted + type: object + required: + - task_id + - cleaned_at + - messages_deleted + - task_states_deleted + - events_deleted + title: CleanTaskResponse + CreateAPIKeyRequest: + properties: + agent_id: + anyOf: + - type: string + - type: 'null' + title: Agent Id + description: The UUID of the agent + agent_name: + anyOf: + - type: string + - type: 'null' + title: Agent Name + description: The name of the agent - if not provided, the agent_id must + be set. + name: + type: string + title: Name + description: The name of the agent's API key. + api_key_type: + $ref: '#/components/schemas/AgentAPIKeyType' + description: The type of the agent API key (external by default). + default: external + api_key: + anyOf: + - type: string + - type: 'null' + title: Api Key + description: Optionally provide the API key value - if not set, one will + be generated. + type: object + required: + - name + title: CreateAPIKeyRequest + CreateAPIKeyResponse: + properties: + id: + type: string + title: Id + description: The unique identifier of the agent API key. + agent_id: + type: string + title: Agent Id + description: The UUID of the agent + created_at: + type: string + format: date-time + title: Created At + description: When the agent API key was created + name: + anyOf: + - type: string + - type: 'null' + title: Name + description: The optional name of the agent API key. + api_key_type: + $ref: '#/components/schemas/AgentAPIKeyType' + description: The type of the created agent API key (external). + api_key: + type: string + title: Api Key + description: The value of the newly created API key. + type: object + required: + - id + - agent_id + - created_at + - name + - api_key_type + - api_key + title: CreateAPIKeyResponse + CreateDeploymentRequest: + properties: + docker_image: + type: string + title: Docker Image + description: Full Docker image URI. + registration_metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Registration Metadata + description: Git/build metadata (commit_hash, branch_name, author_name, + author_email, build_timestamp). + sgp_deploy_id: + anyOf: + - type: string + - type: 'null' + title: Sgp Deploy Id + description: SGP deployment ID. + helm_release_name: + anyOf: + - type: string + - type: 'null' + title: Helm Release Name + description: Helm release name. + type: object + required: + - docker_image + title: CreateDeploymentRequest + CreateScheduleRequest: + properties: + name: + type: string + maxLength: 64 + minLength: 1 + pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$ + title: Schedule Name + description: Human-readable name for the schedule (e.g., 'weekly-profiling'). + Will be combined with agent_id to form the full schedule_id. + workflow_name: + type: string + title: Workflow Name + description: Name of the Temporal workflow to execute (e.g., 'sae-orchestrator') + task_queue: + type: string + title: Task Queue + description: Temporal task queue where the agent's worker is listening + workflow_params: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Workflow Parameters + description: Parameters to pass to the workflow + cron_expression: + anyOf: + - type: string + - type: 'null' + title: Cron Expression + description: Cron expression for scheduling (e.g., '0 0 * * 0' for weekly + on Sunday) + interval_seconds: + anyOf: + - type: integer + minimum: 1.0 + - type: 'null' + title: Interval Seconds + description: Alternative to cron - run every N seconds + execution_timeout_seconds: + anyOf: + - type: integer + minimum: 1.0 + - type: 'null' + title: Execution Timeout + description: Maximum time in seconds for each workflow execution + start_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Start At + description: When the schedule should start being active + end_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: End At + description: When the schedule should stop being active + paused: + type: boolean + title: Paused + description: Whether to create the schedule in a paused state + default: false + type: object + required: + - name + - workflow_name + - task_queue + title: CreateScheduleRequest + description: Request model for creating a new schedule for an agent + CreateSpanRequest: + properties: + id: + anyOf: + - type: string + - type: 'null' + title: Unique Span ID + description: Unique identifier for the span. If not provided, an ID will + be generated. + trace_id: + type: string + title: The trace ID for this span + description: Unique identifier for the trace this span belongs to + task_id: + anyOf: + - type: string + - type: 'null' + title: The task ID this span is associated with + description: ID of the task this span belongs to + parent_id: + anyOf: + - type: string + - type: 'null' + title: The parent span ID if this is a child span + description: ID of the parent span if this is a child span in a trace + name: + type: string + title: The name of the span + description: Name that describes what operation this span represents + start_time: + type: string + format: date-time + title: The start time of the span + description: The time the span started + end_time: + anyOf: + - type: string + format: date-time + - type: 'null' + title: The end time of the span + description: The time the span ended + input: + anyOf: + - additionalProperties: true + type: object + - items: + additionalProperties: true + type: object + type: array + - type: 'null' + title: The input data for the span + description: Input parameters or data for the operation + output: + anyOf: + - additionalProperties: true + type: object + - items: + additionalProperties: true + type: object + type: array + - type: 'null' + title: The output data from the span + description: Output data resulting from the operation + data: + anyOf: + - additionalProperties: true + type: object + - items: + additionalProperties: true + type: object + type: array + - type: 'null' + title: Additional data associated with the span + description: Any additional metadata or context for the span + type: object + required: + - trace_id + - name + - start_time + title: CreateSpanRequest + CreateStateRequest: + properties: + task_id: + type: string + title: The unique id of the task to send the state to + agent_id: + type: string + title: The unique id of the agent to send the state to + state: + additionalProperties: true + type: object + title: The state to send to the task. + type: object + required: + - task_id + - agent_id + - state + title: CreateStateRequest + CreateTaskMessageRequest: + properties: + task_id: + type: string + title: The unique id of the task to send the message to + content: + $ref: '#/components/schemas/TaskMessageContent' + title: The message to send to the task. + streaming_status: + anyOf: + - type: string + enum: + - IN_PROGRESS + - DONE + - type: 'null' + title: The streaming status of the message + created_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Optional caller-supplied creation timestamp + description: Optional timestamp for the message. Workflow callers should + pass workflow.now() (Temporal's deterministic monotonic clock) so that + two awaited messages.create calls from the same workflow are guaranteed + to have monotonic timestamps regardless of HTTP scheduling at the server. + If omitted, the server's wall clock at insert time is used. + type: object + required: + - task_id + - content + title: CreateTaskMessageRequest + CreateTaskRequest: + properties: + name: + anyOf: + - type: string + - type: 'null' + title: Name + description: 'Optional human-readable name for the task. When set it must + be globally unique. task/create is get-or-create by name: reusing an existing + name returns the existing task (with its prior history) instead of creating + a new one, so omit name (or make it unique, e.g. by appending a UUID) + whenever each call should produce a fresh task.' + params: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Params + description: The parameters for the task. On a get-or-create by name, providing + params overwrites the existing task's params (it is not a pure read). + task_metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Task Metadata + description: Caller-provided metadata to persist on the task row. Only applied + at task creation; ignored if a task with this name already exists. Forwarded + to the agent inside the ACP payload for backward compatibility. + type: object + title: CreateTaskRequest + DataContent: + properties: + type: + type: string + const: data + title: Type + description: The type of the message, in this case `data`. + default: data + author: + $ref: '#/components/schemas/MessageAuthor' + description: The role of the messages author, in this case `system`, `user`, + `assistant`, or `tool`. + style: + $ref: '#/components/schemas/MessageStyle' + description: The style of the message. This is used by the client to determine + how to display the message. + default: static + data: + additionalProperties: true + type: object + title: Data + description: The contents of the data message. + type: object + required: + - author + - data + title: DataContent + DataContentEntity: + properties: + type: + type: string + const: data + title: Type + description: The type of the message, in this case `data`. + default: data + author: + $ref: '#/components/schemas/MessageAuthor' + description: The role of the messages author, in this case `system`, `user`, + `assistant`, or `tool`. + style: + $ref: '#/components/schemas/MessageStyle' + description: The style of the message. This is used by the client to determine + how to display the message. + default: static + data: + additionalProperties: true + type: object + title: Data + description: The contents of the data message. + type: object + required: + - author + - data + title: DataContentEntity + DataDelta: + properties: + type: + type: string + const: data + title: Type + default: data + data_delta: + anyOf: + - type: string + - type: 'null' + title: Data Delta + default: '' + type: object + title: DataDelta + description: Delta for data updates + DeleteResponse: + properties: + id: + type: string + title: Id + message: + type: string + title: Message + type: object + required: + - id + - message + title: DeleteResponse + DeleteThreadRequest: + properties: + thread_id: + type: string + title: Thread ID + type: object + required: + - thread_id + title: DeleteThreadRequest + Deployment: + properties: + id: + type: string + title: Id + description: The unique identifier of the deployment. + agent_id: + type: string + title: Agent Id + description: The agent this deployment belongs to. + docker_image: + type: string + title: Docker Image + description: Full Docker image URI. + registration_metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Registration Metadata + description: Git/build metadata from the agent pod. + status: + $ref: '#/components/schemas/DeploymentStatus' + description: Current deployment status. + acp_url: + anyOf: + - type: string + - type: 'null' + title: Acp Url + description: ACP URL set when agent registers. + is_production: + type: boolean + title: Is Production + description: Whether this is the production deployment. + sgp_deploy_id: + anyOf: + - type: string + - type: 'null' + title: Sgp Deploy Id + description: Correlates to SGP's agentex_deploys.id. + helm_release_name: + anyOf: + - type: string + - type: 'null' + title: Helm Release Name + description: Helm release name for cleanup. + created_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Created At + description: When the deployment was created. + promoted_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Promoted At + description: When promoted to production. + expires_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Expires At + description: When marked for cleanup. + type: object + required: + - id + - agent_id + - docker_image + - status + - is_production + title: Deployment + DeploymentHistory: + properties: + id: + type: string + title: Id + description: The unique identifier of the deployment record + agent_id: + type: string + title: Agent Id + description: The ID of the agent this deployment belongs to + author_name: + type: string + title: Author Name + description: Name of the commit author + author_email: + type: string + title: Author Email + description: Email of the commit author + branch_name: + type: string + title: Branch Name + description: Name of the branch + build_timestamp: + type: string + format: date-time + title: Build Timestamp + description: When the build was created + deployment_timestamp: + type: string + format: date-time + title: Deployment Timestamp + description: When this deployment was first seen in the system + commit_hash: + type: string + title: Commit Hash + description: Git commit hash for this deployment + type: object + required: + - id + - agent_id + - author_name + - author_email + - branch_name + - build_timestamp + - deployment_timestamp + - commit_hash + title: DeploymentHistory + description: API schema for deployment history. + DeploymentStatus: + type: string + enum: + - Pending + - Ready + - Failed + title: DeploymentStatus + Event: + properties: + id: + type: string + title: Id + description: The UUID of the event + sequence_id: + type: integer + title: Sequence Id + description: The sequence ID of the event + task_id: + type: string + title: Task Id + description: The UUID of the task that the event belongs to + agent_id: + type: string + title: Agent Id + description: The UUID of the agent that the event belongs to + created_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Created At + description: The timestamp of the event + content: + anyOf: + - $ref: '#/components/schemas/TaskMessageContent' + - type: 'null' + description: The content of the event + type: object + required: + - id + - sequence_id + - task_id + - agent_id + title: Event + ExportTaskResponse: + properties: + task_id: + type: string + title: Task Id + messages: + items: + $ref: '#/components/schemas/TaskMessageEntity' + type: array + title: Messages + task_states: + items: + $ref: '#/components/schemas/StateEntity' + type: array + title: Task States + type: object + required: + - task_id + title: ExportTaskResponse + description: Wire format mirrors the entity directly — schema parity is intentional. + ExportTaskToUrlRequest: + properties: + upload_url: + type: string + maxLength: 2083 + minLength: 1 + format: uri + title: Upload Url + description: Presigned PUT URL where Agentex will upload the task snapshot + as JSON. Must be https; must resolve to a public address. + type: object + required: + - upload_url + title: ExportTaskToUrlRequest + ExportTaskToUrlResponse: + properties: + task_id: + type: string + title: Task Id + upload_url: + type: string + title: Upload Url + uploaded_bytes: + type: integer + title: Uploaded Bytes + messages_count: + type: integer + title: Messages Count + task_states_count: + type: integer + title: Task States Count + type: object + required: + - task_id + - upload_url + - uploaded_bytes + - messages_count + - task_states_count + title: ExportTaskToUrlResponse + FileAttachment: + properties: + file_id: + type: string + title: File Id + description: The unique ID of the attached file + name: + type: string + title: Name + description: The name of the file + size: + type: integer + title: Size + description: The size of the file in bytes + type: + type: string + title: Type + description: The MIME type or content type of the file + type: object + required: + - file_id + - name + - size + - type + title: FileAttachment + description: Represents a file attachment in messages. + FileAttachmentEntity: + properties: + file_id: + type: string + title: File Id + description: The unique ID of the attached file + name: + type: string + title: Name + description: The name of the file + size: + type: integer + title: Size + description: The size of the file in bytes + type: + type: string + title: Type + description: The MIME type or content type of the file + type: object + required: + - file_id + - name + - size + - type + title: FileAttachmentEntity + description: Represents a file attachment in messages. + GetCheckpointTupleRequest: + properties: + thread_id: + type: string + title: Thread ID + checkpoint_ns: + type: string + title: Checkpoint namespace + default: '' + checkpoint_id: + anyOf: + - type: string + - type: 'null' + title: Checkpoint ID (None = latest) + type: object + required: + - thread_id + title: GetCheckpointTupleRequest + HTTPValidationError: + properties: + detail: + items: + $ref: '#/components/schemas/ValidationError' + type: array + title: Detail + type: object + title: HTTPValidationError + ListCheckpointsRequest: + properties: + thread_id: + type: string + title: Thread ID + checkpoint_ns: + anyOf: + - type: string + - type: 'null' + title: Checkpoint namespace + before_checkpoint_id: + anyOf: + - type: string + - type: 'null' + title: Before checkpoint ID + filter_metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Metadata filter (JSONB @>) + limit: + type: integer + maximum: 1000.0 + minimum: 1.0 + title: Max results + default: 100 + type: object + required: + - thread_id + title: ListCheckpointsRequest + MessageAuthor: + type: string + enum: + - user + - agent + title: MessageAuthor + MessageStyle: + type: string + enum: + - static + - active + title: MessageStyle + PaginatedMessagesResponse: + properties: + data: + items: + $ref: '#/components/schemas/TaskMessage' + type: array + title: Data + description: List of messages + next_cursor: + anyOf: + - type: string + - type: 'null' + title: Next Cursor + description: Cursor for fetching the next page of older messages + has_more: + type: boolean + title: Has More + description: Whether there are more messages to fetch + default: false + type: object + required: + - data + title: PaginatedMessagesResponse + description: Response with cursor pagination metadata. + PauseScheduleRequest: + properties: + note: + anyOf: + - type: string + - type: 'null' + title: Note + description: Optional note explaining why the schedule was paused + type: object + title: PauseScheduleRequest + description: Request model for pausing a schedule + PutCheckpointRequest: + properties: + thread_id: + type: string + title: Thread ID + checkpoint_ns: + type: string + title: Checkpoint namespace + default: '' + checkpoint_id: + type: string + title: Checkpoint ID + parent_checkpoint_id: + anyOf: + - type: string + - type: 'null' + title: Parent checkpoint ID + checkpoint: + additionalProperties: true + type: object + title: Checkpoint JSONB payload + metadata: + additionalProperties: true + type: object + title: Checkpoint metadata + blobs: + items: + $ref: '#/components/schemas/BlobData' + type: array + title: Channel blob data + type: object + required: + - thread_id + - checkpoint_id + - checkpoint + title: PutCheckpointRequest + PutCheckpointResponse: + properties: + thread_id: + type: string + title: Thread Id + checkpoint_ns: + type: string + title: Checkpoint Ns + checkpoint_id: + type: string + title: Checkpoint Id + type: object + required: + - thread_id + - checkpoint_ns + - checkpoint_id + title: PutCheckpointResponse + PutWritesRequest: + properties: + thread_id: + type: string + title: Thread ID + checkpoint_ns: + type: string + title: Checkpoint namespace + default: '' + checkpoint_id: + type: string + title: Checkpoint ID + writes: + items: + $ref: '#/components/schemas/WriteData' + type: array + title: Write data + upsert: + type: boolean + title: Upsert mode + default: false + type: object + required: + - thread_id + - checkpoint_id + - writes + title: PutWritesRequest + ReasoningContent: + properties: + type: + type: string + const: reasoning + title: Type + description: The type of the message, in this case `reasoning`. + default: reasoning + author: + $ref: '#/components/schemas/MessageAuthor' + description: The role of the messages author, in this case `system`, `user`, + `assistant`, or `tool`. + style: + $ref: '#/components/schemas/MessageStyle' + description: The style of the message. This is used by the client to determine + how to display the message. + default: static + summary: + items: + type: string + type: array + title: Summary + description: A list of short reasoning summaries + content: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Content + description: The reasoning content or chain-of-thought text + type: object + required: + - author + - summary + title: ReasoningContent + ReasoningContentDelta: + properties: + type: + type: string + const: reasoning_content + title: Type + default: reasoning_content + content_index: + type: integer + title: Content Index + content_delta: + anyOf: + - type: string + - type: 'null' + title: Content Delta + default: '' + type: object + required: + - content_index + title: ReasoningContentDelta + description: Delta for reasoning content updates + ReasoningContentEntity: + properties: + type: + type: string + const: reasoning + title: Type + description: The type of the message, in this case `reasoning`. + default: reasoning + author: + $ref: '#/components/schemas/MessageAuthor' + description: The role of the messages author, in this case `system`, `user`, + `assistant`, or `tool`. + style: + $ref: '#/components/schemas/MessageStyle' + description: The style of the message. This is used by the client to determine + how to display the message. + default: static + summary: + items: + type: string + type: array + title: Summary + description: A list of short reasoning summaries + content: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Content + description: The reasoning content or chain-of-thought text + type: object + required: + - author + - summary + title: ReasoningContentEntity + ReasoningSummaryDelta: + properties: + type: + type: string + const: reasoning_summary + title: Type + default: reasoning_summary + summary_index: + type: integer + title: Summary Index + summary_delta: + anyOf: + - type: string + - type: 'null' + title: Summary Delta + default: '' + type: object + required: + - summary_index + title: ReasoningSummaryDelta + description: Delta for reasoning summary updates + RegisterAgentRequest: + properties: + name: + type: string + pattern: ^[a-z0-9-]+$ + title: Name + description: The unique name of the agent. + description: + type: string + title: Description + description: The description of the agent. + acp_url: + type: string + title: Acp Url + description: The URL of the ACP server for the agent. + agent_id: + anyOf: + - type: string + - type: 'null' + title: Agent Id + description: Optional agent ID if the agent already exists and needs to + be updated. + acp_type: + $ref: '#/components/schemas/ACPType' + description: The type of ACP to use for the agent. + principal_context: + anyOf: + - {} + - type: 'null' + title: Principal Context + description: Principal used for authorization + registration_metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Registration Metadata + description: The metadata for the agent's registration. + agent_input_type: + anyOf: + - $ref: '#/components/schemas/AgentInputType' + - type: 'null' + description: The type of input the agent expects. + type: object + required: + - name + - description + - acp_url + - acp_type + title: RegisterAgentRequest + RegisterAgentResponse: + properties: + id: + type: string + title: Id + description: The unique identifier of the agent. + name: + type: string + title: Name + description: The unique name of the agent. + description: + type: string + title: Description + description: The description of the action. + status: + $ref: '#/components/schemas/AgentStatus' + description: The status of the action, indicating if it's building, ready, + failed, etc. + default: Unknown + acp_type: + $ref: '#/components/schemas/ACPType' + description: The type of the ACP Server (Either sync or async) + status_reason: + anyOf: + - type: string + - type: 'null' + title: Status Reason + description: The reason for the status of the action. + created_at: + type: string + format: date-time + title: Created At + description: The timestamp when the agent was created + updated_at: + type: string + format: date-time + title: Updated At + description: The timestamp when the agent was last updated + registration_metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Registration Metadata + description: The metadata for the agent's registration. + registered_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Registered At + description: The timestamp when the agent was last registered + agent_input_type: + anyOf: + - $ref: '#/components/schemas/AgentInputType' + - type: 'null' + description: The type of input the agent expects. + production_deployment_id: + anyOf: + - type: string + - type: 'null' + title: Production Deployment Id + description: ID of the current production deployment. + agent_api_key: + anyOf: + - type: string + - type: 'null' + title: Agent Api Key + description: The API key for the agent, if applicable. + type: object + required: + - id + - name + - description + - acp_type + - created_at + - updated_at + title: RegisterAgentResponse + description: Response model for registering an agent. + RegisterBuildRequest: + properties: + name: + type: string + pattern: ^[a-z0-9-]+$ + title: Name + description: The unique name of the agent. + description: + type: string + title: Description + description: The description of the agent. + principal_context: + anyOf: + - {} + - type: 'null' + title: Principal Context + description: Principal used for authorization + registration_metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Registration Metadata + description: The metadata for the agent's build registration. + agent_input_type: + anyOf: + - $ref: '#/components/schemas/AgentInputType' + - type: 'null' + description: The type of input the agent expects. + type: object + required: + - name + - description + title: RegisterBuildRequest + description: 'Request model for registering an agent at build time (pre-deploy). + + + Unlike RegisterAgentRequest, there is no acp_url (the agent is not running + + yet) and no acp_type is required. The created agent is left in BUILD_ONLY + + status so it can be permissioned/shared before it is deployed.' + RehydrateTaskRequest: + properties: + task_id: + type: string + title: Task Id + messages: + items: + $ref: '#/components/schemas/TaskMessageEntity' + type: array + title: Messages + task_states: + items: + $ref: '#/components/schemas/StateEntity' + type: array + title: Task States + snapshot_url: + anyOf: + - type: string + maxLength: 2083 + minLength: 1 + format: uri + - type: 'null' + title: Snapshot Url + description: Presigned GET URL whose body is a JSON-encoded TaskSnapshotEntity. + Must be https; must resolve to a public address. When set, messages/task_states + must be empty. + type: object + required: + - task_id + title: RehydrateTaskRequest + description: 'Either provide inline content (messages + task_states) or a snapshot_url + + pointing at a presigned JSON download. Mixing both is rejected. + + + The inline form is the canonical shape used by export''s GET response, so + + snapshot → clean → rehydrate round-trips cleanly without serialization + + changes.' + ScheduleActionInfo: + properties: + workflow_name: + type: string + title: Workflow Name + description: Name of the workflow being executed + workflow_id_prefix: + type: string + title: Workflow ID Prefix + description: Prefix for workflow execution IDs + task_queue: + type: string + title: Task Queue + description: Task queue for the workflow + workflow_params: + anyOf: + - items: {} + type: array + - type: 'null' + title: Workflow Parameters + description: Parameters passed to the workflow + type: object + required: + - workflow_name + - workflow_id_prefix + - task_queue + title: ScheduleActionInfo + description: Information about the scheduled action + ScheduleListItem: + properties: + schedule_id: + type: string + title: Schedule ID + description: Unique identifier for the schedule + name: + type: string + title: Schedule Name + description: Human-readable name for the schedule + agent_id: + type: string + title: Agent ID + description: ID of the agent this schedule belongs to + state: + $ref: '#/components/schemas/ScheduleState' + title: State + description: Current state of the schedule + workflow_name: + anyOf: + - type: string + - type: 'null' + title: Workflow Name + description: Name of the scheduled workflow + next_action_time: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Next Action Time + description: Next scheduled execution time + type: object + required: + - schedule_id + - name + - agent_id + - state + title: ScheduleListItem + description: Abbreviated schedule info for list responses + ScheduleListResponse: + properties: + schedules: + items: + $ref: '#/components/schemas/ScheduleListItem' + type: array + title: Schedules + description: List of schedules + total: + type: integer + title: Total + description: Total number of schedules + type: object + required: + - schedules + - total + title: ScheduleListResponse + description: Response model for listing schedules + ScheduleResponse: + properties: + schedule_id: + type: string + title: Schedule ID + description: Unique identifier for the schedule + name: + type: string + title: Schedule Name + description: Human-readable name for the schedule + agent_id: + type: string + title: Agent ID + description: ID of the agent this schedule belongs to + state: + $ref: '#/components/schemas/ScheduleState' + title: State + description: Current state of the schedule + action: + $ref: '#/components/schemas/ScheduleActionInfo' + title: Action + spec: + $ref: '#/components/schemas/ScheduleSpecInfo' + title: Spec + description: Schedule specification + num_actions_taken: + type: integer + title: Number of Actions Taken + description: Number of times the schedule has executed + default: 0 + num_actions_missed: + type: integer + title: Number of Actions Missed + description: Number of scheduled executions that were missed + default: 0 + next_action_times: + items: + type: string + format: date-time + type: array + title: Next Action Times + description: Upcoming scheduled execution times + last_action_time: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Last Action Time + description: When the schedule last executed + created_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Created At + description: When the schedule was created + type: object + required: + - schedule_id + - name + - agent_id + - state + - action + - spec + title: ScheduleResponse + description: Response model for schedule operations + ScheduleSpecInfo: + properties: + cron_expressions: + items: + type: string + type: array + title: Cron Expressions + description: Cron expressions for the schedule + intervals_seconds: + items: + type: integer + type: array + title: Interval Seconds + description: Interval specifications in seconds + start_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Start At + description: When the schedule starts being active + end_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: End At + description: When the schedule stops being active + type: object + title: ScheduleSpecInfo + description: Information about the schedule specification + ScheduleState: + type: string + enum: + - ACTIVE + - PAUSED + title: ScheduleState + description: Schedule state enum + SendEventRequest: + properties: + task_id: + anyOf: + - type: string + - type: 'null' + title: Task Id + description: The ID of the task that the event was sent to + task_name: + anyOf: + - type: string + - type: 'null' + title: Task Name + description: The name of the task that the event was sent to + content: + anyOf: + - $ref: '#/components/schemas/TaskMessageContent' + - type: 'null' + description: The content to send to the event + type: object + title: SendEventRequest + SendMessageRequest: + properties: + task_id: + anyOf: + - type: string + - type: 'null' + title: Task Id + description: The ID of the task that the message was sent to + task_name: + anyOf: + - type: string + - type: 'null' + title: Task Name + description: The name of the task that the message was sent to + content: + $ref: '#/components/schemas/TaskMessageContent' + description: The message that was sent to the agent + stream: + type: boolean + title: Stream + description: Whether to stream the response message back to the client + default: false + task_params: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Task Params + description: The parameters for the task (only used when creating new tasks) + type: object + required: + - content + title: SendMessageRequest + Span: + properties: + id: + type: string + title: Unique Span ID + trace_id: + type: string + title: The trace ID for this span + description: Unique identifier for the trace this span belongs to + task_id: + anyOf: + - type: string + - type: 'null' + title: The task ID this span is associated with + description: ID of the task this span belongs to + parent_id: + anyOf: + - type: string + - type: 'null' + title: The parent span ID if this is a child span + description: ID of the parent span if this is a child span in a trace + name: + type: string + title: The name of the span + description: Name that describes what operation this span represents + start_time: + type: string + format: date-time + title: The start time of the span + description: The time the span started + end_time: + anyOf: + - type: string + format: date-time + - type: 'null' + title: The end time of the span + description: The time the span ended + input: + anyOf: + - additionalProperties: true + type: object + - items: + additionalProperties: true + type: object + type: array + - type: 'null' + title: The input data for the span + description: Input parameters or data for the operation + output: + anyOf: + - additionalProperties: true + type: object + - items: + additionalProperties: true + type: object + type: array + - type: 'null' + title: The output data from the span + description: Output data resulting from the operation + data: + anyOf: + - additionalProperties: true + type: object + - items: + additionalProperties: true + type: object + type: array + - type: 'null' + title: Additional data associated with the span + description: Any additional metadata or context for the span + type: object + required: + - id + - trace_id + - name + - start_time + title: Span + State: + properties: + task_id: + type: string + title: The unique id of the task to send the state to + agent_id: + type: string + title: The unique id of the agent to send the state to + state: + additionalProperties: true + type: object + title: The state to send to the task. + id: + type: string + title: Id + description: The task state's unique id + created_at: + type: string + format: date-time + title: Created At + description: The timestamp when the state was created + updated_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Updated At + description: The timestamp when the state was last updated + type: object + required: + - task_id + - agent_id + - state + - id + - created_at + title: State + description: 'Represents a state in the agent system. A state is associated + uniquely with a task and an agent. + + + This entity is used to store states in MongoDB, with each state + + associated with a specific task and agent. The combination of task_id and + agent_id is globally unique. + + + The state is a dictionary of arbitrary data.' + StateEntity: + properties: + id: + anyOf: + - type: string + - type: 'null' + title: Id + description: The task state's unique id + task_id: + type: string + title: Task Id + description: ID of the task this state belongs to. The combination of task_id + and agent_id is globally unique. + agent_id: + type: string + title: Agent Id + description: ID of the agent this state belongs to. The combination of task_id + and agent_id is globally unique. + state: + additionalProperties: true + type: object + title: State + description: The state object that contains arbitrary data + created_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Created At + description: The timestamp when the state was created + updated_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Updated At + description: The timestamp when the state was last updated + type: object + required: + - task_id + - agent_id + - state + title: StateEntity + description: 'Represents a state in the agent system. A state is associated + uniquely with a task and an agent. + + + This entity is used to store states in MongoDB, with each state + + associated with a specific task and agent. The combination of task_id and + agent_id is globally unique. + + + The state is a dictionary of arbitrary data.' + StreamTaskMessageDelta: + properties: + type: + type: string + const: delta + title: Type + default: delta + index: + anyOf: + - type: integer + - type: 'null' + title: Index + parent_task_message: + anyOf: + - $ref: '#/components/schemas/TaskMessage' + - type: 'null' + delta: + anyOf: + - $ref: '#/components/schemas/TaskMessageDelta' + - type: 'null' + type: object + title: StreamTaskMessageDelta + description: Event for streaming chunks of content + StreamTaskMessageDone: + properties: + type: + type: string + const: done + title: Type + default: done + index: + anyOf: + - type: integer + - type: 'null' + title: Index + parent_task_message: + anyOf: + - $ref: '#/components/schemas/TaskMessage' + - type: 'null' + type: object + title: StreamTaskMessageDone + description: Event for indicating the task is done + StreamTaskMessageFull: + properties: + type: + type: string + const: full + title: Type + default: full + index: + anyOf: + - type: integer + - type: 'null' + title: Index + parent_task_message: + anyOf: + - $ref: '#/components/schemas/TaskMessage' + - type: 'null' + content: + $ref: '#/components/schemas/TaskMessageContent' + type: object + required: + - content + title: StreamTaskMessageFull + description: Event for streaming the full content + StreamTaskMessageStart: + properties: + type: + type: string + const: start + title: Type + default: start + index: + anyOf: + - type: integer + - type: 'null' + title: Index + parent_task_message: + anyOf: + - $ref: '#/components/schemas/TaskMessage' + - type: 'null' + content: + $ref: '#/components/schemas/TaskMessageContent' + type: object + required: + - content + title: StreamTaskMessageStart + description: Event for starting a streaming message + Task: + properties: + id: + type: string + title: Unique Task ID + name: + anyOf: + - type: string + - type: 'null' + title: Unique name of the task + status: + anyOf: + - $ref: '#/components/schemas/TaskStatus' + - type: 'null' + title: The current status of the task + status_reason: + anyOf: + - type: string + - type: 'null' + title: The reason for the current task status + created_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: The timestamp when the task was created + updated_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: The timestamp when the task was last updated + cleaned_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: The timestamp when the task's content was cleaned for retention compliance; + null when active + params: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Task parameters + task_metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Task metadata + type: object + required: + - id + title: Task + TaskMessage: + properties: + id: + anyOf: + - type: string + - type: 'null' + title: Id + description: The task message's unique id + task_id: + type: string + title: Task Id + description: ID of the task this message belongs to + content: + $ref: '#/components/schemas/TaskMessageContent' + description: The content of the message. This content is not OpenAI compatible. + These are messages that are meant to be displayed to the user. + streaming_status: + anyOf: + - type: string + enum: + - IN_PROGRESS + - DONE + - type: 'null' + title: In case of streaming, this indicates whether the message is still + being streamed or has been completed + created_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Created At + description: The timestamp when the message was created + updated_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Updated At + description: The timestamp when the message was last updated + type: object + required: + - task_id + - content + title: TaskMessage + description: 'Represents a message in the agent system. + + + This entity is used to store messages in MongoDB, with each message + + associated with a specific task.' + TaskMessageContent: + oneOf: + - $ref: '#/components/schemas/TextContent' + - $ref: '#/components/schemas/ReasoningContent' + - $ref: '#/components/schemas/DataContent' + - $ref: '#/components/schemas/ToolRequestContent' + - $ref: '#/components/schemas/ToolResponseContent' + title: TaskMessageContent + discriminator: + propertyName: type + mapping: + data: '#/components/schemas/DataContent' + reasoning: '#/components/schemas/ReasoningContent' + text: '#/components/schemas/TextContent' + tool_request: '#/components/schemas/ToolRequestContent' + tool_response: '#/components/schemas/ToolResponseContent' + TaskMessageDelta: + oneOf: + - $ref: '#/components/schemas/TextDelta' + - $ref: '#/components/schemas/DataDelta' + - $ref: '#/components/schemas/ToolRequestDelta' + - $ref: '#/components/schemas/ToolResponseDelta' + - $ref: '#/components/schemas/ReasoningSummaryDelta' + - $ref: '#/components/schemas/ReasoningContentDelta' + title: TaskMessageDelta + discriminator: + propertyName: type + mapping: + data: '#/components/schemas/DataDelta' + reasoning_content: '#/components/schemas/ReasoningContentDelta' + reasoning_summary: '#/components/schemas/ReasoningSummaryDelta' + text: '#/components/schemas/TextDelta' + tool_request: '#/components/schemas/ToolRequestDelta' + tool_response: '#/components/schemas/ToolResponseDelta' + TaskMessageEntity: + properties: + id: + anyOf: + - type: string + - type: 'null' + title: Id + description: The task message's unique id + task_id: + type: string + title: Task Id + description: ID of the task this message belongs to + content: + oneOf: + - $ref: '#/components/schemas/TextContentEntity' + - $ref: '#/components/schemas/DataContentEntity' + - $ref: '#/components/schemas/ToolRequestContentEntity' + - $ref: '#/components/schemas/ToolResponseContentEntity' + - $ref: '#/components/schemas/ReasoningContentEntity' + title: Content + description: The content of the message. This content is not OpenAI compatible. + These are messages that are meant to be displayed to the user. + discriminator: + propertyName: type + mapping: + data: '#/components/schemas/DataContentEntity' + reasoning: '#/components/schemas/ReasoningContentEntity' + text: '#/components/schemas/TextContentEntity' + tool_request: '#/components/schemas/ToolRequestContentEntity' + tool_response: '#/components/schemas/ToolResponseContentEntity' + streaming_status: + anyOf: + - type: string + enum: + - IN_PROGRESS + - DONE + - type: 'null' + title: In case of streaming, this indicates whether the message is still + being streamed or has been completed + created_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Created At + description: The timestamp when the message was created + updated_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Updated At + description: The timestamp when the message was last updated + type: object + required: + - task_id + - content + title: TaskMessageEntity + description: 'Represents a message in the agent system. + + + This entity is used to store messages in MongoDB, with each message + + associated with a specific task.' + TaskMessageUpdate: + oneOf: + - $ref: '#/components/schemas/StreamTaskMessageStart' + - $ref: '#/components/schemas/StreamTaskMessageDelta' + - $ref: '#/components/schemas/StreamTaskMessageFull' + - $ref: '#/components/schemas/StreamTaskMessageDone' + title: TaskMessageUpdate + discriminator: + propertyName: type + mapping: + delta: '#/components/schemas/StreamTaskMessageDelta' + done: '#/components/schemas/StreamTaskMessageDone' + full: '#/components/schemas/StreamTaskMessageFull' + start: '#/components/schemas/StreamTaskMessageStart' + TaskRelationships: + type: string + enum: + - agents + title: TaskRelationships + description: Task relationships that can be loaded + TaskResponse: + properties: + id: + type: string + title: Unique Task ID + name: + anyOf: + - type: string + - type: 'null' + title: Unique name of the task + status: + anyOf: + - $ref: '#/components/schemas/TaskStatus' + - type: 'null' + title: The current status of the task + status_reason: + anyOf: + - type: string + - type: 'null' + title: The reason for the current task status + created_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: The timestamp when the task was created + updated_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: The timestamp when the task was last updated + cleaned_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: The timestamp when the task's content was cleaned for retention compliance; + null when active + params: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Task parameters + task_metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Task metadata + agents: + anyOf: + - items: + $ref: '#/components/schemas/Agent' + type: array + - type: 'null' + title: Agents associated with this task (only populated when 'agent' view + is requested) + type: object + required: + - id + title: TaskResponse + description: Task response model with optional related data based on relationships + TaskStatus: + type: string + enum: + - CANCELED + - COMPLETED + - FAILED + - RUNNING + - TERMINATED + - TIMED_OUT + - DELETED + title: TaskStatus + TaskStatusReasonRequest: + properties: + reason: + anyOf: + - type: string + - type: 'null' + title: Optional reason for the status change + type: object + title: TaskStatusReasonRequest + TextContent: + properties: + type: + type: string + const: text + title: Type + description: The type of the message, in this case `text`. + default: text + author: + $ref: '#/components/schemas/MessageAuthor' + description: The role of the messages author, in this case `system`, `user`, + `assistant`, or `tool`. + style: + $ref: '#/components/schemas/MessageStyle' + description: The style of the message. This is used by the client to determine + how to display the message. + default: static + format: + $ref: '#/components/schemas/TextFormat' + description: The format of the message. This is used by the client to determine + how to display the message. + default: plain + content: + type: string + title: Content + description: The contents of the text message. + attachments: + anyOf: + - items: + $ref: '#/components/schemas/FileAttachment' + type: array + - type: 'null' + title: Attachments + description: Optional list of file attachments with structured metadata. + type: object + required: + - author + - content + title: TextContent + TextContentEntity: + properties: + type: + type: string + const: text + title: Type + description: The type of the message, in this case `text`. + default: text + author: + $ref: '#/components/schemas/MessageAuthor' + description: The role of the messages author, in this case `system`, `user`, + `assistant`, or `tool`. + style: + $ref: '#/components/schemas/MessageStyle' + description: The style of the message. This is used by the client to determine + how to display the message. + default: static + format: + $ref: '#/components/schemas/TextFormat' + description: The format of the message. This is used by the client to determine + how to display the message. + default: plain + content: + type: string + title: Content + description: The contents of the text message. + attachments: + anyOf: + - items: + $ref: '#/components/schemas/FileAttachmentEntity' + type: array + - type: 'null' + title: Attachments + description: Optional list of file attachments with structured metadata. + type: object + required: + - author + - content + title: TextContentEntity + TextDelta: + properties: + type: + type: string + const: text + title: Type + default: text + text_delta: + anyOf: + - type: string + - type: 'null' + title: Text Delta + default: '' + type: object + title: TextDelta + description: Delta for text updates + TextFormat: + type: string + enum: + - markdown + - plain + - code + title: TextFormat + ToolRequestContent: + properties: + type: + type: string + const: tool_request + title: Type + description: The type of the message, in this case `tool_request`. + default: tool_request + author: + $ref: '#/components/schemas/MessageAuthor' + description: The role of the messages author, in this case `system`, `user`, + `assistant`, or `tool`. + style: + $ref: '#/components/schemas/MessageStyle' + description: The style of the message. This is used by the client to determine + how to display the message. + default: static + tool_call_id: + type: string + title: Tool Call Id + description: The ID of the tool call that is being requested. + name: + type: string + title: Name + description: The name of the tool that is being requested. + arguments: + additionalProperties: true + type: object + title: Arguments + description: The arguments to the tool. + type: object + required: + - author + - tool_call_id + - name + - arguments + title: ToolRequestContent + ToolRequestContentEntity: + properties: + type: + type: string + const: tool_request + title: Type + description: The type of the message, in this case `tool_request`. + default: tool_request + author: + $ref: '#/components/schemas/MessageAuthor' + description: The role of the messages author, in this case `system`, `user`, + `assistant`, or `tool`. + style: + $ref: '#/components/schemas/MessageStyle' + description: The style of the message. This is used by the client to determine + how to display the message. + default: static + tool_call_id: + type: string + title: Tool Call Id + description: The ID of the tool call that is being requested. + name: + type: string + title: Name + description: The name of the tool that is being requested. + arguments: + additionalProperties: true + type: object + title: Arguments + description: The arguments to the tool. + type: object + required: + - author + - tool_call_id + - name + - arguments + title: ToolRequestContentEntity + ToolRequestDelta: + properties: + type: + type: string + const: tool_request + title: Type + default: tool_request + tool_call_id: + type: string + title: Tool Call Id + name: + type: string + title: Name + arguments_delta: + anyOf: + - type: string + - type: 'null' + title: Arguments Delta + default: '' + type: object + required: + - tool_call_id + - name + title: ToolRequestDelta + description: Delta for tool request updates + ToolResponseContent: + properties: + type: + type: string + const: tool_response + title: Type + description: The type of the message, in this case `tool_response`. + default: tool_response + author: + $ref: '#/components/schemas/MessageAuthor' + description: The role of the messages author, in this case `system`, `user`, + `assistant`, or `tool`. + style: + $ref: '#/components/schemas/MessageStyle' + description: The style of the message. This is used by the client to determine + how to display the message. + default: static + tool_call_id: + type: string + title: Tool Call Id + description: The ID of the tool call that is being responded to. + name: + type: string + title: Name + description: The name of the tool that is being responded to. + content: + title: Content + description: The result of the tool. + type: object + required: + - author + - tool_call_id + - name + - content + title: ToolResponseContent + ToolResponseContentEntity: + properties: + type: + type: string + const: tool_response + title: Type + description: The type of the message, in this case `tool_response`. + default: tool_response + author: + $ref: '#/components/schemas/MessageAuthor' + description: The role of the messages author, in this case `system`, `user`, + `assistant`, or `tool`. + style: + $ref: '#/components/schemas/MessageStyle' + description: The style of the message. This is used by the client to determine + how to display the message. + default: static + tool_call_id: + type: string + title: Tool Call Id + description: The ID of the tool call that is being responded to. + name: + type: string + title: Name + description: The name of the tool that is being responded to. + content: + title: Content + description: The result of the tool. + type: object + required: + - author + - tool_call_id + - name + - content + title: ToolResponseContentEntity + ToolResponseDelta: + properties: + type: + type: string + const: tool_response + title: Type + default: tool_response + tool_call_id: + type: string + title: Tool Call Id + name: + type: string + title: Name + content_delta: + anyOf: + - type: string + - type: 'null' + title: Content Delta + default: '' + type: object + required: + - tool_call_id + - name + title: ToolResponseDelta + description: Delta for tool response updates + UnpauseScheduleRequest: + properties: + note: + anyOf: + - type: string + - type: 'null' + title: Note + description: Optional note explaining why the schedule was unpaused + type: object + title: UnpauseScheduleRequest + description: Request model for unpausing a schedule + UpdateAgentTaskTrackerRequest: + properties: + last_processed_event_id: + anyOf: + - type: string + - type: 'null' + title: Last Processed Event Id + description: The most recent processed event ID (omit to leave unchanged) + status: + anyOf: + - type: string + - type: 'null' + title: Status + description: Processing status + status_reason: + anyOf: + - type: string + - type: 'null' + title: Status Reason + description: Optional status reason + type: object + title: UpdateAgentTaskTrackerRequest + description: Request model for updating an agent task tracker. + UpdateSpanRequest: + properties: + trace_id: + anyOf: + - type: string + - type: 'null' + title: The trace ID for this span + description: Unique identifier for the trace this span belongs to + task_id: + anyOf: + - type: string + - type: 'null' + title: The task ID this span is associated with + description: ID of the task this span belongs to + parent_id: + anyOf: + - type: string + - type: 'null' + title: The parent span ID if this is a child span + description: ID of the parent span if this is a child span in a trace + name: + anyOf: + - type: string + - type: 'null' + title: The name of the span + description: Name that describes what operation this span represents + start_time: + anyOf: + - type: string + format: date-time + - type: 'null' + title: The start time of the span + description: The time the span started + end_time: + anyOf: + - type: string + format: date-time + - type: 'null' + title: The end time of the span + description: The time the span ended + input: + anyOf: + - additionalProperties: true + type: object + - items: + additionalProperties: true + type: object + type: array + - type: 'null' + title: The input data for the span + description: Input parameters or data for the operation + output: + anyOf: + - additionalProperties: true + type: object + - items: + additionalProperties: true + type: object + type: array + - type: 'null' + title: The output data from the span + description: Output data resulting from the operation + data: + anyOf: + - additionalProperties: true + type: object + - items: + additionalProperties: true + type: object + type: array + - type: 'null' + title: Additional data associated with the span + description: Any additional metadata or context for the span + type: object + title: UpdateSpanRequest + UpdateStateRequest: + properties: + task_id: + type: string + title: The unique id of the task to update the state of + agent_id: + type: string + title: The unique id of the agent to update the state of + state: + additionalProperties: true + type: object + title: The state to update the state with. + type: object + required: + - task_id + - agent_id + - state + title: UpdateStateRequest + UpdateTaskMessageRequest: + properties: + task_id: + type: string + title: The unique id of the task to update the message of + content: + $ref: '#/components/schemas/TaskMessageContent' + title: The message to update the message with. + streaming_status: + anyOf: + - type: string + enum: + - IN_PROGRESS + - DONE + - type: 'null' + title: The streaming status of the message + type: object + required: + - task_id + - content + title: UpdateTaskMessageRequest + UpdateTaskRequest: + properties: + task_metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: If provided, replaces task_metadata with this value + type: object + title: UpdateTaskRequest + ValidationError: + properties: + loc: + items: + anyOf: + - type: string + - type: integer + type: array + title: Location + msg: + type: string + title: Message + type: + type: string + title: Error Type + input: + title: Input + ctx: + type: object + title: Context + type: object + required: + - loc + - msg + - type + title: ValidationError + WriteData: + properties: + task_id: + type: string + title: Task ID + idx: + type: integer + title: Write index + channel: + type: string + title: Channel name + type: + anyOf: + - type: string + - type: 'null' + title: Serialization type tag + blob: + type: string + title: Base64-encoded binary data + task_path: + type: string + title: Task path + default: '' + type: object + required: + - task_id + - idx + - channel + - blob + title: WriteData + WriteResponse: + properties: + task_id: + type: string + title: Task Id + idx: + type: integer + title: Idx + channel: + type: string + title: Channel + type: + anyOf: + - type: string + - type: 'null' + title: Type + blob: + anyOf: + - type: string + - type: 'null' + title: Blob + type: object + required: + - task_id + - idx + - channel + title: WriteResponse diff --git a/tests/compat/test_request_compat.py b/tests/compat/test_request_compat.py new file mode 100644 index 000000000..9a6a62deb --- /dev/null +++ b/tests/compat/test_request_compat.py @@ -0,0 +1,129 @@ +"""Validate that ADK requests stay valid against a window of supported server +OpenAPI contracts (server_specs/); see server_specs/manifest.json for the window.""" + +from __future__ import annotations + +import json +from typing import Any +from pathlib import Path +from unittest.mock import Mock + +import yaml +import httpx +import pytest +from jsonschema import Draft202012Validator +from referencing import Registry, Resource +from referencing.jsonschema import DRAFT202012 + +from agentex import AsyncAgentex +from agentex.lib.core.services.adk.state import StateService + +_SPEC_DIR = Path(__file__).parent / "server_specs" +_BASE_URL = "http://127.0.0.1:4010" +_STATE_RESPONSE = { + "id": "s1", + "agent_id": "a1", + "task_id": "t1", + "state": {"k": "v"}, + "created_at": "2026-05-13T18:30:00Z", +} + + +def _window() -> list[dict[str, Any]]: + manifest = json.loads((_SPEC_DIR / "manifest.json").read_text()) + return [ + {"label": e["label"], "spec": yaml.safe_load((_SPEC_DIR / e["file"]).read_text())} for e in manifest["specs"] + ] + + +def _mock_span(): + span = Mock() + span.output = None + + async def __aenter__(_self): + return span + + async def __aexit__(_self, *args): + return None + + span.__aenter__ = __aenter__ + span.__aexit__ = __aexit__ + return span + + +def _state_service(client: AsyncAgentex) -> StateService: + tracer = Mock() + trace = Mock() + trace.span.return_value = _mock_span() + tracer.trace.return_value = trace + return StateService(agentex_client=client, tracer=tracer) + + +async def _drive_update(svc: StateService) -> None: + await svc.update_state(state_id="s1", task_id="t1", agent_id="a1", state={"k": "v"}) + + +async def _drive_create(svc: StateService) -> None: + await svc.create_state(task_id="t1", agent_id="a1", state={"k": "v"}) + + +# Each ADK operation: how to drive it, the request it emits, and the server-spec +# operation (path template + method) whose requestBody its body must satisfy. +_OPERATIONS = [ + { + "label": "states.update", + "http_method": "PUT", + "url": f"{_BASE_URL}/states/s1", + "spec_path": "/states/{state_id}", + "spec_method": "put", + "drive": _drive_update, + }, + { + "label": "states.create", + "http_method": "POST", + "url": f"{_BASE_URL}/states", + "spec_path": "/states", + "spec_method": "post", + "drive": _drive_create, + }, +] + +_WINDOW = _window() + + +def _deref(schema: dict[str, Any], spec: dict[str, Any]) -> dict[str, Any]: + ref = schema.get("$ref") + if not ref or not ref.startswith("#/"): + return schema + node: Any = spec + for part in ref[2:].split("/"): + node = node[part] + return node + + +def _request_body_schema(spec: dict[str, Any], path: str, method: str) -> dict[str, Any]: + schema = spec["paths"][path][method]["requestBody"]["content"]["application/json"]["schema"] + return _deref(schema, spec) + + +@pytest.mark.parametrize("entry", _WINDOW, ids=lambda e: e["label"]) +@pytest.mark.parametrize("op", _OPERATIONS, ids=lambda o: o["label"]) +async def test_adk_request_validates_against_server_spec( + op: dict[str, Any], entry: dict[str, Any], respx_mock: Any +) -> None: + route = respx_mock.route(method=op["http_method"], url=op["url"]).mock( + return_value=httpx.Response(200, json=_STATE_RESPONSE) + ) + async with AsyncAgentex(base_url=_BASE_URL, api_key="test") as client: + await op["drive"](_state_service(client)) + + assert route.called, f"{op['label']} did not hit {op['url']}" + body = json.loads(route.calls.last.request.content) + + spec = entry["spec"] + registry = Registry().with_resource(uri="", resource=Resource(contents=spec, specification=DRAFT202012)) + schema = _request_body_schema(spec, op["spec_path"], op["spec_method"]) + errors = sorted(Draft202012Validator(schema, registry=registry).iter_errors(body), key=str) + assert not errors, f"{op['label']} request {body} violates server contract '{entry['label']}': " + "; ".join( + e.message for e in errors + ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..d08e65cf6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,84 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +import logging +from typing import TYPE_CHECKING, Iterator, AsyncIterator + +import httpx +import pytest +from pytest_asyncio import is_async_test + +from agentex import Agentex, AsyncAgentex, DefaultAioHttpClient +from agentex._utils import is_dict + +if TYPE_CHECKING: + from _pytest.fixtures import FixtureRequest # pyright: ignore[reportPrivateImportUsage] + +pytest.register_assert_rewrite("tests.utils") + +logging.getLogger("agentex").setLevel(logging.DEBUG) + + +# automatically add `pytest.mark.asyncio()` to all of our async tests +# so we don't have to add that boilerplate everywhere +def pytest_collection_modifyitems(items: list[pytest.Function]) -> None: + pytest_asyncio_tests = (item for item in items if is_async_test(item)) + session_scope_marker = pytest.mark.asyncio(loop_scope="session") + for async_test in pytest_asyncio_tests: + async_test.add_marker(session_scope_marker, append=False) + + # We skip tests that use both the aiohttp client and respx_mock as respx_mock + # doesn't support custom transports. + for item in items: + if "async_client" not in item.fixturenames or "respx_mock" not in item.fixturenames: + continue + + if not hasattr(item, "callspec"): + continue + + async_client_param = item.callspec.params.get("async_client") + if is_dict(async_client_param) and async_client_param.get("http_client") == "aiohttp": + item.add_marker(pytest.mark.skip(reason="aiohttp client is not compatible with respx_mock")) + + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + +api_key = "My API Key" + + +@pytest.fixture(scope="session") +def client(request: FixtureRequest) -> Iterator[Agentex]: + strict = getattr(request, "param", True) + if not isinstance(strict, bool): + raise TypeError(f"Unexpected fixture parameter type {type(strict)}, expected {bool}") + + with Agentex(base_url=base_url, api_key=api_key, _strict_response_validation=strict) as client: + yield client + + +@pytest.fixture(scope="session") +async def async_client(request: FixtureRequest) -> AsyncIterator[AsyncAgentex]: + param = getattr(request, "param", True) + + # defaults + strict = True + http_client: None | httpx.AsyncClient = None + + if isinstance(param, bool): + strict = param + elif is_dict(param): + strict = param.get("strict", True) + assert isinstance(strict, bool) + + http_client_type = param.get("http_client", "httpx") + if http_client_type == "aiohttp": + http_client = DefaultAioHttpClient() + else: + raise TypeError(f"Unexpected fixture parameter type {type(param)}, expected bool or dict") + + async with AsyncAgentex( + base_url=base_url, api_key=api_key, _strict_response_validation=strict, http_client=http_client + ) as client: + yield client diff --git a/tests/lib/__init__.py b/tests/lib/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/lib/adk/__init__.py b/tests/lib/adk/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/lib/adk/conftest.py b/tests/lib/adk/conftest.py new file mode 100644 index 000000000..6d17956a8 --- /dev/null +++ b/tests/lib/adk/conftest.py @@ -0,0 +1,33 @@ +"""Conftest for ADK tests. + +Mocks optional dependencies that are imported as side effects of the ADK +package init but are not needed for unit tests. +""" + +import sys +from unittest.mock import MagicMock + +# Mock all langchain_core and langgraph submodules used by the ADK package. +# These are imported as side effects of agentex.lib.adk.__init__ but are not +# needed for task-related unit tests. + +_langchain_core_modules = [ + "langchain_core", + "langchain_core.runnables", + "langchain_core.runnables.config", + "langchain_core.outputs", + "langchain_core.messages", + "langchain_core.callbacks", +] + +_langgraph_modules = [ + "langgraph", + "langgraph.checkpoint", + "langgraph.checkpoint.base", + "langgraph.checkpoint.serde", + "langgraph.checkpoint.serde.types", +] + +for mod_name in _langchain_core_modules + _langgraph_modules: + if mod_name not in sys.modules: + sys.modules[mod_name] = MagicMock() diff --git a/tests/lib/adk/providers/__init__.py b/tests/lib/adk/providers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/lib/adk/providers/test_litellm_usage.py b/tests/lib/adk/providers/test_litellm_usage.py new file mode 100644 index 000000000..5f5d480d9 --- /dev/null +++ b/tests/lib/adk/providers/test_litellm_usage.py @@ -0,0 +1,219 @@ +"""Tests that LiteLLMService puts LLM token usage on spans for billing. + +Covers the paths that previously dropped usage: both auto_send variants (span +output was only the TaskMessage dump) and streaming (litellm omits usage unless +``stream_options.include_usage`` is set). +""" + +from __future__ import annotations + +from typing import Any +from datetime import UTC, datetime +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock + +from agentex.types.span import Span +from agentex.types.task_message import TaskMessage +from agentex.lib.types.llm_messages import ( + Delta, + Usage, + Choice, + LLMConfig, + Completion, + AssistantMessage, +) +from agentex.types.task_message_content import TextContent +from agentex.lib.core.services.adk.providers.litellm import ( + LiteLLMService, + _stream_kwargs_with_usage, +) + + +class FakeTrace: + def __init__(self) -> None: + self.spans: list[Span] = [] + + @asynccontextmanager + async def span(self, name, parent_id=None, input=None, data=None, task_id=None): + span = Span( + id=f"span-{len(self.spans)}", + name=name, + start_time=datetime.now(UTC), + trace_id="trace-1", + parent_id=parent_id, + input=input, + ) + self.spans.append(span) + yield span + + +class FakeTracer: + def __init__(self) -> None: + self.trace_obj = FakeTrace() + + def trace(self, trace_id): + return self.trace_obj + + +def _output_dict(span: Span) -> dict[str, Any]: + assert isinstance(span.output, dict) + return span.output + + +def _make_streaming_service(): + streaming_context = MagicMock() + streaming_context.task_message = TaskMessage( + id="msg-1", + task_id="task-1", + content=TextContent(author="agent", content="", format="markdown"), + ) + streaming_context.stream_update = AsyncMock() + + @asynccontextmanager + async def fake_context(**kwargs): + yield streaming_context + + streaming_service = MagicMock() + streaming_service.streaming_task_message_context = fake_context + return streaming_service, streaming_context + + +def _make_service(llm_gateway) -> tuple[LiteLLMService, FakeTracer]: + streaming_service, _ = _make_streaming_service() + tracer = FakeTracer() + service = LiteLLMService( + agentex_client=MagicMock(), + streaming_service=streaming_service, + tracer=tracer, + llm_gateway=llm_gateway, + ) + return service, tracer + + +def _stream_gateway(chunks, captured_kwargs): + gateway = MagicMock() + + def acompletion_stream(**kwargs): + captured_kwargs.update(kwargs) + + async def stream(): + for chunk in chunks: + yield chunk + + return stream() + + gateway.acompletion_stream = acompletion_stream + return gateway + + +def _delta_chunk(content: str, role: str | None = None) -> Completion: + return Completion(choices=[Choice(index=0, delta=Delta(content=content, role=role))]) + + +def _usage_only_chunk() -> Completion: + return Completion( + choices=[], + usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15), + ) + + +class TestStreamKwargsWithUsage: + def test_defaults_include_usage_on(self): + config = LLMConfig(model="gpt-4o", messages=[], stream=True) + assert _stream_kwargs_with_usage(config)["stream_options"] == {"include_usage": True} + + def test_caller_opt_out_preserved(self): + config = LLMConfig(model="gpt-4o", messages=[], stream=True, stream_options={"include_usage": False}) + assert _stream_kwargs_with_usage(config)["stream_options"] == {"include_usage": False} + + def test_merges_with_other_stream_options(self): + config = LLMConfig(model="gpt-4o", messages=[], stream=True, stream_options={"other": 1}) + assert _stream_kwargs_with_usage(config)["stream_options"] == {"include_usage": True, "other": 1} + + +class TestChatCompletionAutoSend: + async def test_span_output_carries_usage(self): + completion = Completion( + choices=[Choice(index=0, message=AssistantMessage(content="Hello!"), finish_reason="stop")], + usage=Usage(prompt_tokens=7, completion_tokens=3, total_tokens=10), + ) + gateway = MagicMock() + gateway.acompletion = AsyncMock(return_value=completion) + service, tracer = _make_service(gateway) + + await service.chat_completion_auto_send( + task_id="task-1", + llm_config=LLMConfig(model="gpt-4o", messages=[], stream=False), + trace_id="trace-1", + ) + + span = tracer.trace_obj.spans[0] + assert _output_dict(span)["usage"] == {"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10} + + async def test_span_output_omits_usage_when_absent(self): + completion = Completion( + choices=[Choice(index=0, message=AssistantMessage(content="Hello!"), finish_reason="stop")], + ) + gateway = MagicMock() + gateway.acompletion = AsyncMock(return_value=completion) + service, tracer = _make_service(gateway) + + await service.chat_completion_auto_send( + task_id="task-1", + llm_config=LLMConfig(model="gpt-4o", messages=[], stream=False), + trace_id="trace-1", + ) + + assert "usage" not in _output_dict(tracer.trace_obj.spans[0]) + + +class TestChatCompletionStream: + async def test_stream_requests_usage_and_span_output_carries_it(self): + captured_kwargs: dict = {} + chunks = [_delta_chunk("Hel", role="assistant"), _delta_chunk("lo!"), _usage_only_chunk()] + service, tracer = _make_service(_stream_gateway(chunks, captured_kwargs)) + + results = [] + async for chunk in service.chat_completion_stream( + llm_config=LLMConfig(model="gpt-4o", messages=[], stream=True), + trace_id="trace-1", + ): + results.append(chunk) + + assert len(results) == 3 + assert captured_kwargs["stream_options"] == {"include_usage": True} + span = tracer.trace_obj.spans[0] + assert _output_dict(span)["usage"] == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} + assert _output_dict(span)["choices"][0]["message"]["content"] == "Hello!" + + +class TestChatCompletionStreamAutoSend: + async def test_usage_only_final_chunk_reaches_span_output(self): + captured_kwargs: dict = {} + chunks = [_delta_chunk("Hel", role="assistant"), _delta_chunk("lo!"), _usage_only_chunk()] + service, tracer = _make_service(_stream_gateway(chunks, captured_kwargs)) + + await service.chat_completion_stream_auto_send( + task_id="task-1", + llm_config=LLMConfig(model="gpt-4o", messages=[], stream=True), + trace_id="trace-1", + ) + + assert captured_kwargs["stream_options"] == {"include_usage": True} + span = tracer.trace_obj.spans[0] + assert _output_dict(span)["usage"] == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} + # TaskMessage dump is still the base of the span output + assert _output_dict(span)["id"] == "msg-1" + + async def test_stream_without_usage_chunk_omits_usage(self): + captured_kwargs: dict = {} + chunks = [_delta_chunk("Hi", role="assistant")] + service, tracer = _make_service(_stream_gateway(chunks, captured_kwargs)) + + await service.chat_completion_stream_auto_send( + task_id="task-1", + llm_config=LLMConfig(model="gpt-4o", messages=[], stream=True), + trace_id="trace-1", + ) + + assert "usage" not in _output_dict(tracer.trace_obj.spans[0]) diff --git a/tests/lib/adk/providers/test_openai_activities.py b/tests/lib/adk/providers/test_openai_activities.py new file mode 100644 index 000000000..964b24545 --- /dev/null +++ b/tests/lib/adk/providers/test_openai_activities.py @@ -0,0 +1,843 @@ +from unittest.mock import Mock, patch + +import pytest +from agents import RunResult, RunResultStreaming +from temporalio.testing import ActivityEnvironment +from openai.types.responses import ResponseCodeInterpreterToolCall + + +class TestOpenAIActivities: + @pytest.fixture + def sample_run_result(self): + """Create a sample RunResult for mocking.""" + mock_result = Mock(spec=RunResult) + mock_result.final_output = "Hello! How can I help you today?" + mock_result.to_input_list.return_value = [ + {"role": "user", "content": "Hello, world!"}, + {"role": "assistant", "content": "Hello! How can I help you today?"}, + ] + # Add new_items attribute that the OpenAIService expects + mock_result.new_items = [] + return mock_result + + @pytest.mark.parametrize( + "max_turns,should_be_passed", + [ + (None, False), + (7, True), # Test with non-default value (default is 10) + ], + ) + @patch("agents.Runner.run") + async def test_run_agent(self, mock_runner_run, max_turns, should_be_passed, sample_run_result): + """Comprehensive test for run_agent covering all major scenarios.""" + from agentex.lib.core.temporal.activities.adk.providers.openai_activities import RunAgentParams + + # Arrange + mock_runner_run.return_value = sample_run_result + mock_tracer = self._create_mock_tracer() + _, openai_activities, env = self._create_test_setup(mock_tracer) + + # Create params with or without max_turns + params = RunAgentParams( + input_list=[{"role": "user", "content": "Hello, world!"}], + mcp_server_params=[], + agent_name="test_agent", + agent_instructions="You are a helpful assistant", + max_turns=max_turns, + trace_id="test-trace-id", + parent_span_id="test-span-id", + ) + + # Act + result = await env.run(openai_activities.run_agent, params) + + # Assert - Result structure + self._assert_result_structure(result) + + # Assert - Runner call + mock_runner_run.assert_called_once() + call_args = mock_runner_run.call_args + + # Assert - Runner signature validation + self._assert_runner_call_signature(call_args) + + # Assert - Input parameter matches + assert call_args.kwargs["input"] == params.input_list + + # Assert - Starting agent parameters + starting_agent = call_args.kwargs["starting_agent"] + self._assert_starting_agent_params(starting_agent, params) + + # Assert - Max turns parameter handling + if should_be_passed: + assert "max_turns" in call_args.kwargs, f"max_turns should be passed when set to {max_turns}" + assert call_args.kwargs["max_turns"] == max_turns, f"max_turns value should be {max_turns}" + else: + assert "max_turns" not in call_args.kwargs, "max_turns should not be passed when None" + + @pytest.mark.parametrize( + "previous_response_id,should_be_passed", + [ + (None, False), + ("response_123", True), + ], + ) + @patch("agents.Runner.run") + async def test_run_agent_previous_response_id( + self, mock_runner_run, previous_response_id, should_be_passed, sample_run_result + ): + """Test run_agent with previous_response_id parameter.""" + from agentex.lib.core.temporal.activities.adk.providers.openai_activities import RunAgentParams + + # Arrange + mock_runner_run.return_value = sample_run_result + mock_tracer = self._create_mock_tracer() + _, openai_activities, env = self._create_test_setup(mock_tracer) + + # Create params with or without previous_response_id + params = RunAgentParams( + input_list=[{"role": "user", "content": "Hello, world!"}], + mcp_server_params=[], + agent_name="test_agent", + agent_instructions="You are a helpful assistant", + previous_response_id=previous_response_id, + trace_id="test-trace-id", + parent_span_id="test-span-id", + ) + + # Act + result = await env.run(openai_activities.run_agent, params) + + # Assert - Result structure + self._assert_result_structure(result) + + # Assert - Runner call + mock_runner_run.assert_called_once() + call_args = mock_runner_run.call_args + + # Assert - Runner signature validation + self._assert_runner_call_signature(call_args) + + # Assert - Previous response ID parameter handling + if should_be_passed: + assert "previous_response_id" in call_args.kwargs, ( + f"previous_response_id should be passed when set to {previous_response_id}" + ) + assert call_args.kwargs["previous_response_id"] == previous_response_id, ( + f"previous_response_id value should be {previous_response_id}" + ) + else: + assert "previous_response_id" not in call_args.kwargs, "previous_response_id should not be passed when None" + + @pytest.mark.parametrize( + "tools_case", + [ + "no_tools", + "function_tool", + "web_search_tool", + "file_search_tool", + "computer_tool", + "code_interpreter_tool", + "image_generation_tool", + "local_shell_tool", + "mixed_tools", + ], + ) + @patch("agents.Runner.run") + async def test_run_agent_tools_conversion(self, mock_runner_run, tools_case, sample_run_result): + """Test that tools are properly converted from Temporal to OpenAI agents format.""" + from agentex.lib.core.temporal.activities.adk.providers.openai_activities import ( + RunAgentParams, + ) + + # Arrange + mock_runner_run.return_value = sample_run_result + mock_tracer = self._create_mock_tracer() + _, openai_activities, env = self._create_test_setup(mock_tracer) + + # Create different tool configurations based on test case + tools = self._create_tools_for_case(tools_case) + + params = RunAgentParams( + input_list=[{"role": "user", "content": "Hello, world!"}], + mcp_server_params=[], + agent_name="test_agent", + agent_instructions="You are a helpful assistant", + tools=tools, + trace_id="test-trace-id", + parent_span_id="test-span-id", + ) + + # Act + result = await env.run(openai_activities.run_agent, params) + + # Assert - Result structure + self._assert_result_structure(result) + + # Assert - Runner call + mock_runner_run.assert_called_once() + call_args = mock_runner_run.call_args + + # Assert - Runner signature validation + self._assert_runner_call_signature(call_args) + + # Assert - Agent was created and tools were converted properly + starting_agent = call_args.kwargs["starting_agent"] + self._assert_tools_conversion(starting_agent, tools_case, tools) + + @patch("agents.Runner.run") + async def test_run_agent_auto_send_with_tool_responses(self, mock_runner_run): + """Test run_agent_auto_send with code interpreter tool responses.""" + from agentex.lib.core.temporal.activities.adk.providers.openai_activities import ( + CodeInterpreterTool, + RunAgentAutoSendParams, + ) + + # Arrange - Setup test environment + mock_tracer = self._create_mock_tracer() + openai_service, openai_activities, env = self._create_test_setup(mock_tracer) + mock_streaming_context = self._setup_streaming_service_mocks(openai_service) + + # Create tool call and response mocks using helpers + code_interpreter_call = self._create_code_interpreter_tool_call_mock() + mock_tool_call_item = self._create_tool_call_item_mock(code_interpreter_call) + mock_tool_output_item = self._create_tool_output_item_mock() + + # Create a mock result with tool calls that will be processed + mock_result_with_tools = Mock(spec=RunResult) + mock_result_with_tools.final_output = "Code executed successfully" + mock_result_with_tools.to_input_list.return_value = [ + {"role": "user", "content": "Run some Python code"}, + {"role": "assistant", "content": "Code executed successfully"}, + ] + mock_result_with_tools.new_items = [mock_tool_call_item, mock_tool_output_item] + mock_runner_run.return_value = mock_result_with_tools + + # Create test parameters + params = RunAgentAutoSendParams( + input_list=[{"role": "user", "content": "Run some Python code"}], + mcp_server_params=[], + agent_name="test_agent", + agent_instructions=("You are a helpful assistant with code interpreter"), + tools=[CodeInterpreterTool(tool_config={"type": "code_interpreter"})], + trace_id="test-trace-id", + parent_span_id="test-span-id", + task_id="test-task-id", + ) + + result = await env.run(openai_activities.run_agent_auto_send, params) + + assert result.final_output == "Code executed successfully" + + # Verify runner.run was called with expected signature + mock_runner_run.assert_called_once() + call_args = mock_runner_run.call_args + self._assert_runner_call_signature(call_args) + + # Verify starting agent parameters + starting_agent = call_args.kwargs["starting_agent"] + # Create a mock object with the expected attributes + expected_params = Mock() + expected_params.agent_name = "test_agent" + expected_params.agent_instructions = "You are a helpful assistant with code interpreter" + expected_params.tools = [CodeInterpreterTool(tool_config={"type": "code_interpreter"})] + self._assert_starting_agent_params(starting_agent, expected_params) + + # Verify streaming context received tool request and response updates + # Should have been called twice - once for tool request, once for response + assert mock_streaming_context.stream_update.call_count == 2 + + # First call should be tool request + first_call = mock_streaming_context.stream_update.call_args_list[0] + first_update = first_call[1]["update"] # keyword argument + assert hasattr(first_update, "content") + assert first_update.content.name == "code_interpreter" + assert first_update.content.tool_call_id == "code_interpreter_call_123" + + # Second call should be tool response + second_call = mock_streaming_context.stream_update.call_args_list[1] + second_update = second_call[1]["update"] # keyword argument + assert hasattr(second_update, "content") + assert second_update.content.name == "code_interpreter_call" + assert second_update.content.tool_call_id == "code_interpreter_call_123" + + @patch("agents.Runner.run_streamed") + async def test_run_agent_streamed_auto_send(self, mock_runner_run_streamed): + """Test run_agent_streamed_auto_send with streaming and tool responses.""" + from agentex.lib.core.temporal.activities.adk.providers.openai_activities import ( + CodeInterpreterTool, + RunAgentStreamedAutoSendParams, + ) + + # Create streaming result mock using helper + mock_streaming_result = self._create_streaming_result_mock() + + # Create mock streaming events + async def mock_stream_events(): + # Tool call event + tool_call_event = Mock() + tool_call_event.type = "run_item_stream_event" + tool_call_item = Mock() + tool_call_item.type = "tool_call_item" + tool_call_item.raw_item = self._create_code_interpreter_tool_call_mock() + tool_call_event.item = tool_call_item + yield tool_call_event + + # Tool response event + tool_response_event = Mock() + tool_response_event.type = "run_item_stream_event" + tool_response_item = Mock() + tool_response_item.type = "tool_call_output_item" + tool_response_item.raw_item = {"call_id": "code_interpreter_call_123", "output": "Hello from streaming"} + tool_response_event.item = tool_response_item + yield tool_response_event + + mock_streaming_result.stream_events = mock_stream_events + mock_runner_run_streamed.return_value = mock_streaming_result + + # Setup test environment + mock_tracer = self._create_mock_tracer() + openai_service, openai_activities, env = self._create_test_setup(mock_tracer) + mock_streaming_context = self._setup_streaming_service_mocks(openai_service) + + # Create test parameters + params = RunAgentStreamedAutoSendParams( + input_list=[{"role": "user", "content": "Run some Python code"}], + mcp_server_params=[], + agent_name="test_agent", + agent_instructions=("You are a helpful assistant with code interpreter"), + tools=[CodeInterpreterTool(tool_config={"type": "code_interpreter"})], + trace_id="test-trace-id", + parent_span_id="test-span-id", + task_id="test-task-id", + ) + + # Act + result = await env.run(openai_activities.run_agent_streamed_auto_send, params) + + # Assert - Result structure (expecting SerializableRunResultStreaming from activity) + from agentex.lib.types.agent_results import SerializableRunResultStreaming + + assert isinstance(result, SerializableRunResultStreaming) + assert result.final_output == "Code executed successfully" + + # Verify runner.run_streamed was called with expected signature + mock_runner_run_streamed.assert_called_once() + call_args = mock_runner_run_streamed.call_args + self._assert_runner_call_signature_streamed(call_args) + + # Verify starting agent parameters + starting_agent = call_args.kwargs["starting_agent"] + # Create a mock object with the expected attributes + expected_params = Mock() + expected_params.agent_name = "test_agent" + expected_params.agent_instructions = "You are a helpful assistant with code interpreter" + expected_params.tools = [CodeInterpreterTool(tool_config={"type": "code_interpreter"})] + self._assert_starting_agent_params(starting_agent, expected_params) + + # Under the unified harness, the OpenAI events are converted to canonical + # StreamTaskMessageFull events and auto_send posts each full tool message + # by opening a streaming context with the content as initial_content and + # closing it (no stream_update). So assert on the opened contents. + opened = mock_streaming_context.opened_contents + tool_contents = [c for c in opened if getattr(c, "type", None) in ("tool_request", "tool_response")] + assert len(tool_contents) == 2 + + # First opened context is the tool request. + first = tool_contents[0] + assert first.type == "tool_request" + assert first.name == "code_interpreter" + assert first.tool_call_id == "code_interpreter_call_123" + + # Second opened context is the tool response. + second = tool_contents[1] + assert second.type == "tool_response" + assert second.tool_call_id == "code_interpreter_call_123" + + @patch("agents.Runner.run_streamed") + async def test_run_agent_streamed_auto_send_forwards_previous_response_id(self, mock_runner_run_streamed): + """previous_response_id must reach Runner.run_streamed so a Responses-API + conversation continues instead of silently starting fresh.""" + from agentex.lib.core.temporal.activities.adk.providers.openai_activities import ( + RunAgentStreamedAutoSendParams, + ) + + mock_streaming_result = self._create_streaming_result_mock() + + async def _no_events(): + return + yield + + mock_streaming_result.stream_events = _no_events + mock_runner_run_streamed.return_value = mock_streaming_result + + mock_tracer = self._create_mock_tracer() + openai_service, openai_activities, env = self._create_test_setup(mock_tracer) + self._setup_streaming_service_mocks(openai_service) + + params = RunAgentStreamedAutoSendParams( + input_list=[{"role": "user", "content": "continue"}], + mcp_server_params=[], + agent_name="test_agent", + agent_instructions="You are a helpful assistant", + trace_id="test-trace-id", + parent_span_id="test-span-id", + task_id="test-task-id", + previous_response_id="response_123", + ) + + await env.run(openai_activities.run_agent_streamed_auto_send, params) + + mock_runner_run_streamed.assert_called_once() + assert mock_runner_run_streamed.call_args.kwargs.get("previous_response_id") == "response_123" + + def _create_mock_tracer(self): + """Helper method to create a properly mocked tracer with async context manager support.""" + mock_tracer = Mock() + mock_trace = Mock() + mock_span = Mock() + + # Setup the span context manager + async def mock_span_aenter(_): + return mock_span + + async def mock_span_aexit(_, _exc_type, _exc_val, _exc_tb): + return None + + mock_span.__aenter__ = mock_span_aenter + mock_span.__aexit__ = mock_span_aexit + mock_trace.span.return_value = mock_span + mock_tracer.trace.return_value = mock_trace + + return mock_tracer + + def _create_test_setup(self, mock_tracer): + """Helper method to create OpenAIService and OpenAIActivities instances.""" + # Import here to avoid circular imports + from agentex.lib.core.services.adk.providers.openai import OpenAIService + from agentex.lib.core.temporal.activities.adk.providers.openai_activities import OpenAIActivities + + openai_service = OpenAIService(tracer=mock_tracer) + openai_activities = OpenAIActivities(openai_service) + env = ActivityEnvironment() + + return openai_service, openai_activities, env + + def _assert_runner_call_signature(self, call_args): + """Helper method to validate Runner.run call signature.""" + actual_kwargs = set(call_args.kwargs.keys()) + + # Check that we only pass valid Runner.run parameters + valid_params = { + "starting_agent", + "input", + "context", + "max_turns", + "hooks", + "run_config", + "previous_response_id", + "session", + } + invalid_kwargs = actual_kwargs - valid_params + assert not invalid_kwargs, f"Invalid arguments passed to Runner.run: {invalid_kwargs}" + + # Verify required arguments are present + assert "starting_agent" in call_args.kwargs, "starting_agent is required for Runner.run" + assert "input" in call_args.kwargs, "input is required for Runner.run" + + # Verify starting_agent is not None (actual agent object created) + assert call_args.kwargs["starting_agent"] is not None, "starting_agent should not be None" + + def _assert_runner_call_signature_streamed(self, call_args): + """Helper method to validate Runner.run_streamed call signature.""" + actual_kwargs = set(call_args.kwargs.keys()) + + # Check that we only pass valid Runner.run_streamed parameters + valid_params = { + "starting_agent", + "input", + "context", + "max_turns", + "hooks", + "run_config", + "previous_response_id", + "session", + } + invalid_kwargs = actual_kwargs - valid_params + assert not invalid_kwargs, f"Invalid arguments passed to Runner.run_streamed: {invalid_kwargs}" + + # Verify required arguments are present + assert "starting_agent" in call_args.kwargs, "starting_agent is required for Runner.run_streamed" + assert "input" in call_args.kwargs, "input is required for Runner.run_streamed" + + # Verify starting_agent is not None (actual agent object created) + assert call_args.kwargs["starting_agent"] is not None, "starting_agent should not be None" + + def _assert_starting_agent_params(self, starting_agent, expected_params): + """Helper method to validate starting_agent parameters match expected values.""" + # Verify agent name and instructions match + assert starting_agent.name == expected_params.agent_name, f"Agent name should be {expected_params.agent_name}" + assert starting_agent.instructions == expected_params.agent_instructions, f"Agent instructions should match" + + # Note: Other agent parameters like tools, guardrails would be tested here + # but they require more complex inspection of the agent object + + def _assert_result_structure(self, result, expected_output="Hello! How can I help you today?"): + """Helper method to validate the result structure.""" + from agentex.lib.types.agent_results import SerializableRunResult + + assert isinstance(result, SerializableRunResult) + assert result.final_output == expected_output + assert len(result.final_input_list) == 2 + assert result.final_input_list[0]["role"] == "user" + assert result.final_input_list[1]["role"] == "assistant" + + def _create_tools_for_case(self, tools_case): + """Helper method to create tools based on test case.""" + from agentex.lib.core.temporal.activities.adk.providers.openai_activities import ( + ComputerTool, + FunctionTool, + WebSearchTool, + FileSearchTool, + LocalShellTool, + CodeInterpreterTool, + ImageGenerationTool, + ) + + def sample_tool_function(_context, args): + return f"Tool called with {args}" + + def sample_computer(): + return Mock() # Mock computer object + + def sample_safety_check(_data): + return True + + def sample_executor(): + return Mock() # Mock executor + + if tools_case == "no_tools": + return None + elif tools_case == "function_tool": + return [ + FunctionTool( + name="test_function", + description="A test function tool", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=sample_tool_function, + ) + ] + elif tools_case == "web_search_tool": + return [WebSearchTool()] + elif tools_case == "file_search_tool": + return [ + FileSearchTool(vector_store_ids=["store1", "store2"], max_num_results=10, include_search_results=True) + ] + elif tools_case == "computer_tool": + return [ComputerTool(computer=sample_computer(), on_safety_check=sample_safety_check)] + elif tools_case == "code_interpreter_tool": + return [ + CodeInterpreterTool( + tool_config={"type": "code_interpreter", "container": {"type": "static", "image": "python:3.11"}} + ) + ] + elif tools_case == "image_generation_tool": + return [ + ImageGenerationTool( + tool_config={ + "type": "image_generation", + "quality": "high", + "size": "1024x1024", + "output_format": "png", + } + ) + ] + elif tools_case == "local_shell_tool": + return [LocalShellTool(executor=sample_executor())] + elif tools_case == "mixed_tools": + return [ + FunctionTool( + name="calculator", + description="A calculator tool", + params_json_schema={"type": "object", "properties": {"expression": {"type": "string"}}}, + on_invoke_tool=sample_tool_function, + ), + WebSearchTool(), + FileSearchTool(vector_store_ids=["store1"], max_num_results=5), + ] + else: + raise ValueError(f"Unknown tools_case: {tools_case}") + + def _assert_tools_conversion(self, starting_agent, tools_case, _original_tools): + """Helper method to validate that tools were properly converted.""" + from agents.tool import ( + ComputerTool as OAIComputerTool, + FunctionTool as OAIFunctionTool, + WebSearchTool as OAIWebSearchTool, + FileSearchTool as OAIFileSearchTool, + LocalShellTool as OAILocalShellTool, + CodeInterpreterTool as OAICodeInterpreterTool, + ImageGenerationTool as OAIImageGenerationTool, + ) + + if tools_case == "no_tools": + # When no tools are provided, the agent should have an empty tools list + assert starting_agent.tools == [], "Agent should have empty tools list when no tools provided" + + elif tools_case == "function_tool": + assert len(starting_agent.tools) == 1, "Agent should have 1 tool" + agent_tool = starting_agent.tools[0] + assert isinstance(agent_tool, OAIFunctionTool), "Tool should be converted to OAIFunctionTool" + assert agent_tool.name == "test_function", "Tool name should be preserved" + assert agent_tool.description == "A test function tool", "Tool description should be preserved" + # Check that the schema contains our expected fields (may have additional fields) + assert "type" in agent_tool.params_json_schema, "Tool schema should have type field" + assert agent_tool.params_json_schema["type"] == "object", "Tool schema type should be object" + assert "properties" in agent_tool.params_json_schema, "Tool schema should have properties field" + assert callable(agent_tool.on_invoke_tool), "Tool function should be callable" + + elif tools_case == "web_search_tool": + assert len(starting_agent.tools) == 1, "Agent should have 1 tool" + agent_tool = starting_agent.tools[0] + assert isinstance(agent_tool, OAIWebSearchTool), "Tool should be converted to OAIWebSearchTool" + + elif tools_case == "file_search_tool": + assert len(starting_agent.tools) == 1, "Agent should have 1 tool" + agent_tool = starting_agent.tools[0] + assert isinstance(agent_tool, OAIFileSearchTool), "Tool should be converted to OAIFileSearchTool" + assert agent_tool.vector_store_ids == ["store1", "store2"], "Vector store IDs should be preserved" + assert agent_tool.max_num_results == 10, "Max results should be preserved" + assert agent_tool.include_search_results, "Include search results flag should be preserved" + + elif tools_case == "computer_tool": + assert len(starting_agent.tools) == 1, "Agent should have 1 tool" + agent_tool = starting_agent.tools[0] + assert isinstance(agent_tool, OAIComputerTool), "Tool should be converted to OAIComputerTool" + assert agent_tool.computer is not None, "Computer object should be present" + assert agent_tool.on_safety_check is not None, "Safety check function should be present" + + elif tools_case == "code_interpreter_tool": + assert len(starting_agent.tools) == 1, "Agent should have 1 tool" + agent_tool = starting_agent.tools[0] + assert isinstance(agent_tool, OAICodeInterpreterTool), "Tool should be converted to OAICodeInterpreterTool" + + elif tools_case == "image_generation_tool": + assert len(starting_agent.tools) == 1, "Agent should have 1 tool" + agent_tool = starting_agent.tools[0] + assert isinstance(agent_tool, OAIImageGenerationTool), "Tool should be converted to OAIImageGenerationTool" + + elif tools_case == "local_shell_tool": + assert len(starting_agent.tools) == 1, "Agent should have 1 tool" + agent_tool = starting_agent.tools[0] + assert isinstance(agent_tool, OAILocalShellTool), "Tool should be converted to OAILocalShellTool" + assert agent_tool.executor is not None, "Executor should be present" + + elif tools_case == "mixed_tools": + assert len(starting_agent.tools) == 3, "Agent should have 3 tools" + + # Check first tool (FunctionTool) + function_tool = starting_agent.tools[0] + assert isinstance(function_tool, OAIFunctionTool), "First tool should be OAIFunctionTool" + assert function_tool.name == "calculator", "Function tool name should be preserved" + + # Check second tool (WebSearchTool) + web_tool = starting_agent.tools[1] + assert isinstance(web_tool, OAIWebSearchTool), "Second tool should be OAIWebSearchTool" + + # Check third tool (FileSearchTool) + file_tool = starting_agent.tools[2] + assert isinstance(file_tool, OAIFileSearchTool), "Third tool should be OAIFileSearchTool" + + else: + raise ValueError(f"Unknown tools_case: {tools_case}") + + @patch("agents.Runner.run_streamed") + async def test_run_agent_streamed_auto_send_forwards_created_at(self, mock_runner_run_streamed): + """created_at is forwarded to every streaming context opened by auto_send_turn.""" + from datetime import datetime, timezone + + from agentex.lib.core.temporal.activities.adk.providers.openai_activities import ( + RunAgentStreamedAutoSendParams, + ) + + deterministic_ts = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + + mock_streaming_result = self._create_streaming_result_mock() + + # Emit a tool call + tool response so auto_send actually opens streaming + # contexts; an empty stream opens none, making the assertion below + # vacuously true and unable to catch a created_at regression. + async def mock_stream_events(): + tool_call_event = Mock() + tool_call_event.type = "run_item_stream_event" + tool_call_event.item = self._create_tool_call_item_mock(self._create_code_interpreter_tool_call_mock()) + yield tool_call_event + + tool_response_event = Mock() + tool_response_event.type = "run_item_stream_event" + tool_response_event.item = self._create_tool_output_item_mock() + yield tool_response_event + + mock_streaming_result.stream_events = mock_stream_events + mock_runner_run_streamed.return_value = mock_streaming_result + + mock_tracer = self._create_mock_tracer() + openai_service, openai_activities, env = self._create_test_setup(mock_tracer) + mock_ctx, recorded_created_ats = self._setup_streaming_service_mocks_with_created_at(openai_service) + + params = RunAgentStreamedAutoSendParams( + input_list=[{"role": "user", "content": "hello"}], + mcp_server_params=[], + agent_name="test_agent", + agent_instructions="You are a helpful assistant", + trace_id="test-trace-id", + parent_span_id="test-span-id", + task_id="test-task-id", + created_at=deterministic_ts, + ) + + await env.run(openai_activities.run_agent_streamed_auto_send, params) + + # Guard against a vacuous pass: at least one streaming context must have + # been opened so the per-context created_at assertion is meaningful. + assert recorded_created_ats, "expected at least one streaming context to be opened" + assert all(ts == deterministic_ts for ts in recorded_created_ats), ( + f"Expected all streaming contexts to receive created_at={deterministic_ts!r}, got: {recorded_created_ats!r}" + ) + + def _setup_streaming_service_mocks(self, openai_service): + """Helper method to setup streaming service mocks for run_agent_auto_send.""" + from unittest.mock import AsyncMock + + # Mock the streaming service and agentex client + mock_streaming_service = AsyncMock() + mock_agentex_client = AsyncMock() + + # Mock streaming context manager + mock_streaming_context = AsyncMock() + + # Create a proper TaskMessage mock that passes validation + from agentex.types.task_message import TaskMessage + + mock_task_message = Mock(spec=TaskMessage) + mock_task_message.id = "test-task-message-id" + mock_task_message.task_id = "test-task-id" + mock_task_message.content = {"type": "text", "content": "test"} + + mock_streaming_context.task_message = mock_task_message + mock_streaming_context.stream_update = AsyncMock() + + # Record the initial_content passed to each opened streaming context. + # The unified harness auto_send path posts full tool messages by opening + # a context with initial_content and closing it (no stream_update), so + # assertions inspect the opened contents rather than stream_update calls. + opened_contents: list = [] + + # Create a proper async context manager mock + from contextlib import asynccontextmanager + from unittest.mock import AsyncMock + + @asynccontextmanager + async def mock_streaming_context_manager(*_args, **kwargs): + if "initial_content" in kwargs: + opened_contents.append(kwargs["initial_content"]) + yield mock_streaming_context + + mock_streaming_service.streaming_task_message_context = mock_streaming_context_manager + # Expose the recorded contents on the returned context mock for assertions. + mock_streaming_context.opened_contents = opened_contents + + openai_service.streaming_service = mock_streaming_service + openai_service.agentex_client = mock_agentex_client + + return mock_streaming_context + + def _setup_streaming_service_mocks_with_created_at(self, openai_service): + """Like _setup_streaming_service_mocks but also records every created_at kwarg.""" + from contextlib import asynccontextmanager + from unittest.mock import AsyncMock + + from agentex.types.task_message import TaskMessage + + mock_streaming_service = AsyncMock() + mock_agentex_client = AsyncMock() + + mock_streaming_context = AsyncMock() + mock_task_message = Mock(spec=TaskMessage) + mock_task_message.id = "test-task-message-id" + mock_task_message.task_id = "test-task-id" + mock_task_message.content = {"type": "text", "content": "test"} + mock_streaming_context.task_message = mock_task_message + mock_streaming_context.stream_update = AsyncMock() + + recorded_created_ats: list = [] + + @asynccontextmanager + async def mock_ctx_manager(*_args, **kwargs): + recorded_created_ats.append(kwargs.get("created_at")) + yield mock_streaming_context + + mock_streaming_service.streaming_task_message_context = mock_ctx_manager + mock_streaming_context.opened_contents = [] + + openai_service.streaming_service = mock_streaming_service + openai_service.agentex_client = mock_agentex_client + + return mock_streaming_context, recorded_created_ats + + def _create_code_interpreter_tool_call_mock(self, call_id="code_interpreter_call_123"): + """Helper to create ResponseCodeInterpreterToolCall mock objects.""" + return ResponseCodeInterpreterToolCall( + id=call_id, + type="code_interpreter_call", + status="completed", + code="print('Hello from code interpreter')", + container_id="container_123", + outputs=[], + ) + + def _create_tool_call_item_mock(self, tool_call): + """Helper to create tool call item mock.""" + mock_tool_call_item = Mock() + mock_tool_call_item.type = "tool_call_item" + mock_tool_call_item.raw_item = tool_call + return mock_tool_call_item + + def _create_tool_output_item_mock(self, call_id="code_interpreter_call_123", output="Hello from code interpreter"): + """Helper to create tool output item mock.""" + mock_tool_output_item = Mock() + mock_tool_output_item.type = "tool_call_output_item" + mock_tool_output_item.raw_item = {"call_id": call_id, "output": output} + return mock_tool_output_item + + def _create_streaming_result_mock(self, final_output="Code executed successfully"): + """Helper to create streaming result mock with common setup.""" + mock_streaming_result = Mock(spec=RunResultStreaming) + mock_streaming_result.final_output = final_output + mock_streaming_result.new_items = [] + # OpenAITurn reads raw_responses after stream exhaustion to aggregate + # usage; provide an empty list so usage normalizes to model-only. + mock_streaming_result.raw_responses = [] + mock_streaming_result.final_input_list = [ + {"role": "user", "content": "Run some Python code"}, + {"role": "assistant", "content": final_output}, + ] + mock_streaming_result.to_input_list.return_value = [ + {"role": "user", "content": "Run some Python code"}, + {"role": "assistant", "content": final_output}, + ] + return mock_streaming_result + + def _create_common_agent_params(self, **overrides): + """Helper to create common agent parameters with defaults.""" + defaults = { + "input_list": [{"role": "user", "content": "Run some Python code"}], + "mcp_server_params": [], + "agent_name": "test_agent", + "agent_instructions": "You are a helpful assistant with code interpreter", + "trace_id": "test-trace-id", + "parent_span_id": "test-span-id", + "task_id": "test-task-id", + } + defaults.update(overrides) + return defaults diff --git a/tests/lib/adk/providers/test_openai_turn.py b/tests/lib/adk/providers/test_openai_turn.py new file mode 100644 index 000000000..d5ad2b5c8 --- /dev/null +++ b/tests/lib/adk/providers/test_openai_turn.py @@ -0,0 +1,248 @@ +"""Tests for OpenAITurn and its usage mapping. + +OpenAITurn adapts an OpenAI Agents SDK streamed run onto the harness +``HarnessTurn`` protocol. These tests cover: +- ``openai_usage_to_turn_usage`` (full usage, None, real zeros) +- ``_aggregate_usage`` (empty, single, multiple ModelResponses) +- ``OpenAITurn.events`` driven by an injected canonical stream (bypassing the + OpenAI->canonical converter), plus ``usage()`` before/after exhaustion +- the ``ValueError`` guard when neither ``result`` nor ``stream`` is supplied +""" + +import types as _types + +import pytest +from agents.usage import Usage +from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails + +from agentex.types.text_content import TextContent +from agentex.types.task_message_delta import TextDelta +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) + + +def _import_target(): + from agentex.lib.adk._modules._openai_turn import ( + OpenAITurn, + _aggregate_usage, + openai_usage_to_turn_usage, + ) + + return OpenAITurn, _aggregate_usage, openai_usage_to_turn_usage + + +# --------------------------------------------------------------------------- +# openai_usage_to_turn_usage +# --------------------------------------------------------------------------- + + +def test_usage_mapping_full(): + _, _, openai_usage_to_turn_usage = _import_target() + usage = Usage( + requests=3, + input_tokens=100, + input_tokens_details=InputTokensDetails(cached_tokens=20), + output_tokens=50, + output_tokens_details=OutputTokensDetails(reasoning_tokens=10), + total_tokens=150, + ) + turn_usage = openai_usage_to_turn_usage(usage, model="gpt-4o") + + assert turn_usage.model == "gpt-4o" + assert turn_usage.num_llm_calls == 3 + assert turn_usage.input_tokens == 100 + assert turn_usage.cached_input_tokens == 20 + assert turn_usage.output_tokens == 50 + assert turn_usage.reasoning_tokens == 10 + assert turn_usage.total_tokens == 150 + + +def test_usage_mapping_none_usage(): + _, _, openai_usage_to_turn_usage = _import_target() + turn_usage = openai_usage_to_turn_usage(None, model="gpt-4o") + + assert turn_usage.model == "gpt-4o" + # num_llm_calls is None ("not reported") when no usage is present, matching + # the token fields below; a real 0 is only reported when the provider says so. + assert turn_usage.num_llm_calls is None + assert turn_usage.input_tokens is None + assert turn_usage.output_tokens is None + assert turn_usage.total_tokens is None + + +def test_usage_mapping_real_zeros_are_preserved(): + # A cache hit can legitimately produce 0 output tokens; a present-but-zero + # value must survive as 0, not be coerced to None. + _, _, openai_usage_to_turn_usage = _import_target() + usage = Usage( + requests=1, + input_tokens=0, + input_tokens_details=InputTokensDetails(cached_tokens=0), + output_tokens=0, + output_tokens_details=OutputTokensDetails(reasoning_tokens=0), + total_tokens=0, + ) + turn_usage = openai_usage_to_turn_usage(usage, model="m") + + assert turn_usage.input_tokens == 0 + assert turn_usage.cached_input_tokens == 0 + assert turn_usage.output_tokens == 0 + assert turn_usage.reasoning_tokens == 0 + assert turn_usage.total_tokens == 0 + assert turn_usage.num_llm_calls == 1 + + +# --------------------------------------------------------------------------- +# _aggregate_usage +# --------------------------------------------------------------------------- + + +def _resp(usage): + return _types.SimpleNamespace(usage=usage) + + +def test_aggregate_usage_empty(): + _, _aggregate_usage, _ = _import_target() + assert _aggregate_usage([]) is None + + +def test_aggregate_usage_single(): + _, _aggregate_usage, _ = _import_target() + usage = Usage(requests=1, input_tokens=10, output_tokens=5, total_tokens=15) + total = _aggregate_usage([_resp(usage)]) + + assert total is not None + assert total.requests == 1 + assert total.input_tokens == 10 + assert total.output_tokens == 5 + assert total.total_tokens == 15 + + +def test_aggregate_usage_multiple(): + _, _aggregate_usage, _ = _import_target() + u1 = Usage( + requests=1, + input_tokens=10, + input_tokens_details=InputTokensDetails(cached_tokens=2), + output_tokens=5, + output_tokens_details=OutputTokensDetails(reasoning_tokens=1), + total_tokens=15, + ) + u2 = Usage( + requests=2, + input_tokens=20, + input_tokens_details=InputTokensDetails(cached_tokens=3), + output_tokens=7, + output_tokens_details=OutputTokensDetails(reasoning_tokens=4), + total_tokens=27, + ) + # A response without usage must be skipped, not crash the aggregation. + total = _aggregate_usage([_resp(u1), _resp(None), _resp(u2)]) + + assert total is not None + assert total.requests == 3 + assert total.input_tokens == 30 + assert total.output_tokens == 12 + assert total.total_tokens == 42 + assert total.input_tokens_details.cached_tokens == 5 + assert total.output_tokens_details.reasoning_tokens == 5 + + +# --------------------------------------------------------------------------- +# OpenAITurn.events / usage / construction +# --------------------------------------------------------------------------- + + +async def _canonical_stream(events): + for e in events: + yield e + + +@pytest.mark.asyncio +async def test_turn_events_forwards_injected_stream(): + OpenAITurn, _, _ = _import_target() + events = [ + StreamTaskMessageStart(type="start", index=0, content=TextContent(type="text", author="agent", content="")), + StreamTaskMessageDelta(type="delta", index=0, delta=TextDelta(type="text", text_delta="Hi")), + StreamTaskMessageDone(type="done", index=0), + ] + turn = OpenAITurn(stream=_canonical_stream(events), model="gpt-4o") + + out = [e async for e in turn.events] + assert out == events + + +@pytest.mark.asyncio +async def test_turn_usage_before_and_after_exhaustion_with_injected_stream(): + OpenAITurn, _, _ = _import_target() + events = [ + StreamTaskMessageStart(type="start", index=0, content=TextContent(type="text", author="agent", content="")), + StreamTaskMessageDone(type="done", index=0), + ] + turn = OpenAITurn(stream=_canonical_stream(events), model="gpt-4o") + + # Before exhaustion: usage carries only the model name. + before = turn.usage() + assert before.model == "gpt-4o" + assert before.input_tokens is None + + async for _ in turn.events: + pass + + # With an injected stream there is no run to read usage from, so usage + # stays model-only after exhaustion. + after = turn.usage() + assert after.model == "gpt-4o" + assert after.input_tokens is None + + +@pytest.mark.asyncio +async def test_turn_usage_populated_from_result_after_exhaustion(): + OpenAITurn, _, _ = _import_target() + + canonical = [ + StreamTaskMessageStart(type="start", index=0, content=TextContent(type="text", author="agent", content="")), + StreamTaskMessageDone(type="done", index=0), + ] + + class _FakeResult: + def __init__(self): + self.raw_responses = [ + _resp(Usage(requests=1, input_tokens=8, output_tokens=4, total_tokens=12)), + ] + + def stream_events(self): + # OpenAITurn passes this to convert_openai_to_agentex_events; we + # monkeypatch that converter below so this can yield canonical events. + return _canonical_stream(canonical) + + import agentex.lib.adk._modules._openai_turn as mod + + async def _passthrough(stream): + async for e in stream: + yield e + + original = mod.convert_openai_to_agentex_events + mod.convert_openai_to_agentex_events = _passthrough + try: + turn = OpenAITurn(result=_FakeResult(), model="gpt-4o") + out = [e async for e in turn.events] + finally: + mod.convert_openai_to_agentex_events = original + + assert out == canonical + usage = turn.usage() + assert usage.model == "gpt-4o" + assert usage.num_llm_calls == 1 + assert usage.input_tokens == 8 + assert usage.output_tokens == 4 + assert usage.total_tokens == 12 + + +def test_turn_requires_result_or_stream(): + OpenAITurn, _, _ = _import_target() + with pytest.raises(ValueError, match="either"): + OpenAITurn() diff --git a/tests/lib/adk/test_claude_code_sync.py b/tests/lib/adk/test_claude_code_sync.py new file mode 100644 index 000000000..5a78acaf7 --- /dev/null +++ b/tests/lib/adk/test_claude_code_sync.py @@ -0,0 +1,715 @@ +"""Tests for the claude-code stream-json -> Agentex StreamTaskMessage* converter.""" + +from __future__ import annotations + +from typing import Any, AsyncIterator + +import pytest + +from agentex.types.text_content import TextContent +from agentex.types.reasoning_content import ReasoningContent +from agentex.types.task_message_delta import TextDelta +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.types.reasoning_content_delta import ReasoningContentDelta +from agentex.lib.adk._modules._claude_code_sync import convert_claude_code_to_agentex_events + + +async def _aiter(events: list[Any]) -> AsyncIterator[Any]: + for e in events: + yield e + + +async def _collect(stream: AsyncIterator[Any]) -> list[Any]: + return [e async for e in stream] + + +# --------------------------------------------------------------------------- +# Text content +# --------------------------------------------------------------------------- + + +class TestTextContent: + async def test_text_block_in_assistant_message_emits_start_delta_done(self): + envelopes = [ + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "Hello world"}]}, + } + ] + out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) + + assert len(out) == 3 + assert isinstance(out[0], StreamTaskMessageStart) + assert isinstance(out[0].content, TextContent) + assert out[0].content.content == "" + assert isinstance(out[1], StreamTaskMessageDelta) + assert isinstance(out[1].delta, TextDelta) + assert out[1].delta.text_delta == "Hello world" + assert isinstance(out[2], StreamTaskMessageDone) + assert out[0].index == out[1].index == out[2].index + + async def test_empty_text_block_is_skipped(self): + envelopes = [ + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": ""}]}, + } + ] + out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) + assert out == [] + + async def test_streamed_text_via_stream_event_emits_start_deltas_done(self): + envelopes = [ + { + "type": "stream_event", + "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "text"}}, + }, + { + "type": "stream_event", + "event": { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "Hello"}, + }, + }, + { + "type": "stream_event", + "event": { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": " world"}, + }, + }, + { + "type": "stream_event", + "event": {"type": "content_block_stop", "index": 0}, + }, + ] + out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) + + starts = [e for e in out if isinstance(e, StreamTaskMessageStart)] + deltas = [e for e in out if isinstance(e, StreamTaskMessageDelta)] + dones = [e for e in out if isinstance(e, StreamTaskMessageDone)] + + assert len(starts) == 1 + assert isinstance(starts[0].content, TextContent) + assert len(deltas) == 2 + assert isinstance(deltas[0].delta, TextDelta) + assert deltas[0].delta.text_delta == "Hello" + assert isinstance(deltas[1].delta, TextDelta) + assert deltas[1].delta.text_delta == " world" + assert len(dones) == 1 + + async def test_streamed_text_not_re_emitted_by_assistant_block(self): + """After stream_event triple, the final assistant block must not re-emit the text.""" + envelopes = [ + { + "type": "stream_event", + "event": { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text"}, + }, + }, + { + "type": "stream_event", + "event": { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "streamed"}, + }, + }, + { + "type": "stream_event", + "event": {"type": "content_block_stop", "index": 0}, + }, + # Final assistant message with same text — must NOT be re-emitted + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "streamed"}]}, + }, + ] + out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) + text_starts = [e for e in out if isinstance(e, StreamTaskMessageStart) and isinstance(e.content, TextContent)] + assert len(text_starts) == 1, "Text block must not be emitted twice" + + async def test_streamed_message_split_across_assistant_envelopes_not_duplicated(self): + """Regression: one streamed message (thinking + text) can materialise as + SEPARATE assistant envelopes. Content-based dedup must skip both streamed + blocks even though the text arrives in its own later envelope — an earlier + index-based scheme re-emitted the text (duplicate).""" + envelopes = [ + # Streamed: thinking at block index 0, then text at block index 1. + { + "type": "stream_event", + "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "thinking"}}, + }, + { + "type": "stream_event", + "event": { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "thinking_delta", "thinking": "ponder"}, + }, + }, + {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}}, + { + "type": "stream_event", + "event": {"type": "content_block_start", "index": 1, "content_block": {"type": "text"}}, + }, + { + "type": "stream_event", + "event": {"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "answer"}}, + }, + {"type": "stream_event", "event": {"type": "content_block_stop", "index": 1}}, + # Materialised as two separate assistant envelopes (thinking alone at + # idx 0, then text alone at idx 0) — the shape that caused duplicates. + {"type": "assistant", "message": {"content": [{"type": "thinking", "thinking": "ponder"}]}}, + {"type": "assistant", "message": {"content": [{"type": "text", "text": "answer"}]}}, + ] + out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) + text_starts = [e for e in out if isinstance(e, StreamTaskMessageStart) and isinstance(e.content, TextContent)] + reasoning_starts = [ + e for e in out if isinstance(e, StreamTaskMessageStart) and isinstance(e.content, ReasoningContent) + ] + assert len(text_starts) == 1, "Streamed text must not be re-emitted by its own materialised envelope" + assert len(reasoning_starts) == 1, "Streamed thinking must not be re-emitted either" + + async def test_interleaved_materialized_block_not_duplicated(self): + """Regression: the materialised `assistant` envelope can arrive MID-stream + (before the streamed block's content_block_stop). Content-recorded dedup + hasn't fired yet, so the still-open block's partial buffer is prefix-matched + against the materialised full text to suppress the duplicate.""" + envelopes = [ + { + "type": "stream_event", + "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "thinking"}}, + }, + { + "type": "stream_event", + "event": { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "thinking_delta", "thinking": "I"}, + }, + }, + # Materialised envelope interleaved before content_block_stop. + {"type": "assistant", "message": {"content": [{"type": "thinking", "thinking": "I need to load tools."}]}}, + { + "type": "stream_event", + "event": { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "thinking_delta", "thinking": " need to load tools."}, + }, + }, + {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}}, + ] + out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) + reasoning_starts = [ + e for e in out if isinstance(e, StreamTaskMessageStart) and isinstance(e.content, ReasoningContent) + ] + assert len(reasoning_starts) == 1, "Interleaved materialised reasoning must not duplicate the streamed block" + + async def test_later_turn_non_streamed_text_not_dropped(self): + """A non-streamed text block in a later turn must not be dropped because an + earlier turn streamed a block at the same index.""" + envelopes = [ + # Turn 1: streamed text at index 0 (dedup'd against the materialised msg). + { + "type": "stream_event", + "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "text"}}, + }, + { + "type": "stream_event", + "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "first"}}, + }, + {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}}, + {"type": "assistant", "message": {"content": [{"type": "text", "text": "first"}]}}, + # Turn 2: a NON-streamed text block, also at index 0. + {"type": "assistant", "message": {"content": [{"type": "text", "text": "second"}]}}, + ] + out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) + deltas = [ + e.delta.text_delta for e in out if isinstance(e, StreamTaskMessageDelta) and isinstance(e.delta, TextDelta) + ] + assert deltas == ["first", "second"], "Later turn's non-streamed text must still be delivered" + + +# --------------------------------------------------------------------------- +# Thinking / reasoning content +# --------------------------------------------------------------------------- + + +class TestThinkingContent: + async def test_thinking_block_emits_reasoning_start_delta_done(self): + envelopes = [ + { + "type": "assistant", + "message": {"content": [{"type": "thinking", "thinking": "Let me reason..."}]}, + } + ] + out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) + + assert len(out) == 3 + assert isinstance(out[0], StreamTaskMessageStart) + assert isinstance(out[0].content, ReasoningContent) + # Summary must be populated from the thinking text + assert out[0].content.summary == ["Let me reason..."] + assert isinstance(out[1], StreamTaskMessageDelta) + assert isinstance(out[1].delta, ReasoningContentDelta) + assert out[1].delta.content_delta == "Let me reason..." + assert out[1].delta.content_index == 0 + assert isinstance(out[2], StreamTaskMessageDone) + + async def test_empty_thinking_block_is_skipped(self): + envelopes = [ + { + "type": "assistant", + "message": {"content": [{"type": "thinking", "thinking": ""}]}, + } + ] + out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) + assert out == [] + + async def test_streamed_thinking_emits_reasoning_start_deltas_done(self): + envelopes = [ + { + "type": "stream_event", + "event": { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "thinking"}, + }, + }, + { + "type": "stream_event", + "event": { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "thinking_delta", "thinking": "step one"}, + }, + }, + { + "type": "stream_event", + "event": { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "thinking_delta", "thinking": " step two"}, + }, + }, + { + "type": "stream_event", + "event": {"type": "content_block_stop", "index": 0}, + }, + ] + out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) + + starts = [e for e in out if isinstance(e, StreamTaskMessageStart)] + deltas = [e for e in out if isinstance(e, StreamTaskMessageDelta)] + dones = [e for e in out if isinstance(e, StreamTaskMessageDone)] + + assert len(starts) == 1 + assert isinstance(starts[0].content, ReasoningContent) + assert len(deltas) == 2 + assert isinstance(deltas[0].delta, ReasoningContentDelta) + assert deltas[0].delta.content_delta == "step one" + assert isinstance(deltas[1].delta, ReasoningContentDelta) + assert deltas[1].delta.content_delta == " step two" + assert len(dones) == 1 + + async def test_two_streamed_thinking_blocks_not_re_emitted(self): + """A turn that streams two thinking blocks must claim both indices, so the + final assistant envelope does not re-emit the second one.""" + + def _thinking_block(idx: int, text: str) -> list: + return [ + { + "type": "stream_event", + "event": {"type": "content_block_start", "index": idx, "content_block": {"type": "thinking"}}, + }, + { + "type": "stream_event", + "event": { + "type": "content_block_delta", + "index": idx, + "delta": {"type": "thinking_delta", "thinking": text}, + }, + }, + {"type": "stream_event", "event": {"type": "content_block_stop", "index": idx}}, + ] + + envelopes = [ + *_thinking_block(0, "first thought"), + *_thinking_block(1, "second thought"), + # Final assistant envelope repeats both thinking blocks — neither should re-emit. + { + "type": "assistant", + "message": { + "content": [ + {"type": "thinking", "thinking": "first thought"}, + {"type": "thinking", "thinking": "second thought"}, + ] + }, + }, + ] + out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) + reasoning_starts = [ + e for e in out if isinstance(e, StreamTaskMessageStart) and isinstance(e.content, ReasoningContent) + ] + assert len(reasoning_starts) == 2, "each streamed thinking block emitted exactly once (no duplicate)" + + async def test_thinking_block_start_with_no_deltas_allows_assistant_to_fill(self): + """A thinking block_start without any deltas leaves the final assistant block + free to emit the thinking text (the block index is not claimed as streamed).""" + envelopes = [ + { + "type": "stream_event", + "event": { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "thinking"}, + }, + }, + # No thinking_delta — close block immediately + { + "type": "stream_event", + "event": {"type": "content_block_stop", "index": 0}, + }, + # Final assistant message has the thinking text + { + "type": "assistant", + "message": {"content": [{"type": "thinking", "thinking": "delayed thinking"}]}, + }, + ] + out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) + # The assistant block should produce a full thinking message (Start+Delta+Done) + reasoning_starts = [ + e for e in out if isinstance(e, StreamTaskMessageStart) and isinstance(e.content, ReasoningContent) + ] + # There will be the empty start from stream_event, plus the one from assistant block + reasoning_deltas = [ + e for e in out if isinstance(e, StreamTaskMessageDelta) and isinstance(e.delta, ReasoningContentDelta) + ] + assert len(reasoning_deltas) >= 1 + assert any( + isinstance(d.delta, ReasoningContentDelta) and d.delta.content_delta == "delayed thinking" + for d in reasoning_deltas + ) + + +# --------------------------------------------------------------------------- +# Tool calls and results +# --------------------------------------------------------------------------- + + +class TestToolCallsAndResults: + async def test_tool_use_block_emits_start_done(self): + envelopes = [ + { + "type": "assistant", + "message": { + "content": [ + { + "type": "tool_use", + "id": "call_abc", + "name": "Bash", + "input": {"command": "ls /"}, + } + ] + }, + } + ] + out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) + + assert len(out) == 2 + assert isinstance(out[0], StreamTaskMessageStart) + assert isinstance(out[0].content, ToolRequestContent) + assert out[0].content.tool_call_id == "call_abc" + assert out[0].content.name == "Bash" + assert out[0].content.arguments == {"command": "ls /"} + assert isinstance(out[1], StreamTaskMessageDone) + + async def test_tool_result_block_emits_full(self): + envelopes = [ + { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_abc", + "content": "file1.txt\nfile2.txt", + } + ] + }, + } + ] + out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) + + assert len(out) == 1 + assert isinstance(out[0], StreamTaskMessageFull) + assert isinstance(out[0].content, ToolResponseContent) + assert out[0].content.tool_call_id == "call_abc" + assert "file1.txt" in str(out[0].content.content) + + async def test_tool_result_list_content_joined(self): + envelopes = [ + { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "tid", + "content": [ + {"type": "text", "text": "line1"}, + {"type": "text", "text": "line2"}, + ], + } + ] + }, + } + ] + out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) + assert isinstance(out[0], StreamTaskMessageFull) + assert isinstance(out[0].content, ToolResponseContent) + payload = str(out[0].content.content) + assert "line1" in payload + assert "line2" in payload + + async def test_tool_result_error_flag_passed_through(self): + envelopes = [ + { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "err_call", + "content": "Permission denied", + "is_error": True, + } + ] + }, + } + ] + out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) + assert isinstance(out[0], StreamTaskMessageFull) + assert isinstance(out[0].content, ToolResponseContent) + assert isinstance(out[0].content.content, dict) + assert out[0].content.content.get("is_error") is True + + async def test_tool_result_truncation(self): + long_result = "x" * 5000 + envelopes = [ + { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "t", + "content": long_result, + } + ] + }, + } + ] + out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) + result_str = out[0].content.content.get("result", "") + assert len(result_str) <= 4000 + + +# --------------------------------------------------------------------------- +# on_result callback +# --------------------------------------------------------------------------- + + +class TestOnResult: + async def test_on_result_called_with_result_envelope(self): + captured: list[dict] = [] + + async def capture(envelope): + captured.append(envelope) + + envelopes = [ + { + "type": "result", + "session_id": "sess123", + "cost_usd": 0.012, + "usage": {"input_tokens": 100, "output_tokens": 50}, + } + ] + out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes), on_result=capture)) + + # result envelope does not emit any StreamTaskMessage + assert out == [] + assert len(captured) == 1 + assert captured[0]["session_id"] == "sess123" + assert captured[0]["cost_usd"] == pytest.approx(0.012) + + async def test_on_result_not_called_when_no_result_envelope(self): + captured: list[dict] = [] + + async def capture(envelope): + captured.append(envelope) + + envelopes = [ + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "Hi"}]}, + } + ] + await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes), on_result=capture)) + assert captured == [] + + async def test_no_on_result_does_not_raise(self): + envelopes = [ + { + "type": "result", + "cost_usd": 0.001, + "usage": {"input_tokens": 10, "output_tokens": 5}, + } + ] + # Should not raise even without a callback + out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) + assert out == [] + + +# --------------------------------------------------------------------------- +# Message indexing +# --------------------------------------------------------------------------- + + +class TestMessageIndexing: + async def test_multiple_blocks_get_distinct_indices(self): + envelopes = [ + { + "type": "assistant", + "message": { + "content": [ + {"type": "text", "text": "First"}, + { + "type": "tool_use", + "id": "c1", + "name": "Read", + "input": {"path": "/tmp"}, + }, + ] + }, + }, + { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "c1", + "content": "some content", + } + ] + }, + }, + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "Done"}]}, + }, + ] + out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) + + # Gather all Start/Full events and check indices are monotonically increasing + anchors = [e for e in out if isinstance(e, (StreamTaskMessageStart, StreamTaskMessageFull))] + indices = [e.index for e in anchors] + assert indices == sorted(indices), "Indices must be monotonically increasing" + assert len(set(indices)) == len(indices), "All indices must be distinct" + + async def test_system_init_and_unknown_envelopes_produce_no_output(self): + envelopes = [ + {"type": "system", "subtype": "init", "session_id": "sess"}, + {"type": "unknown_future_type", "data": "whatever"}, + ] + out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) + assert out == [] + + async def test_non_json_string_lines_are_skipped(self): + lines = [ + "not json at all", + '{"type": "assistant", "message": {"content": [{"type": "text", "text": "hi"}]}}', + ] + + async def _str_iter(): + for line in lines: + yield line + + out = await _collect(convert_claude_code_to_agentex_events(_str_iter())) + assert len(out) == 3 # Start + Delta + Done for the text block + + async def test_empty_lines_are_skipped(self): + lines = ["", " ", '{"type": "system", "subtype": "init"}'] + + async def _str_iter(): + for line in lines: + yield line + + out = await _collect(convert_claude_code_to_agentex_events(_str_iter())) + assert out == [] + + +# --------------------------------------------------------------------------- +# Author +# --------------------------------------------------------------------------- + + +class TestContentAuthors: + @pytest.mark.parametrize( + "envelope", + [ + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "hi"}]}, + }, + { + "type": "assistant", + "message": {"content": [{"type": "thinking", "thinking": "thoughts"}]}, + }, + { + "type": "assistant", + "message": { + "content": [ + { + "type": "tool_use", + "id": "c", + "name": "t", + "input": {}, + } + ] + }, + }, + { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "c", + "content": "ok", + } + ] + }, + }, + ], + ) + async def test_all_content_authored_by_agent(self, envelope: dict): + out = await _collect(convert_claude_code_to_agentex_events(_aiter([envelope]))) + for e in out: + content = getattr(e, "content", None) + if content is not None and hasattr(content, "author"): + assert content.author == "agent" diff --git a/tests/lib/adk/test_claude_code_turn.py b/tests/lib/adk/test_claude_code_turn.py new file mode 100644 index 000000000..be80dfe1f --- /dev/null +++ b/tests/lib/adk/test_claude_code_turn.py @@ -0,0 +1,351 @@ +"""Tests for ClaudeCodeTurn and claude_code_usage_to_turn_usage.""" + +from __future__ import annotations + +from typing import Any, AsyncIterator + +import pytest + +from agentex.lib.core.harness.types import TurnUsage, HarnessTurn +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.types.tool_response_content import ToolResponseContent +from agentex.lib.adk._modules._claude_code_turn import ( + ClaudeCodeTurn, + claude_code_usage_to_turn_usage, +) + + +async def _aiter(events: list[Any]) -> AsyncIterator[Any]: + for e in events: + yield e + + +async def _drain(turn: ClaudeCodeTurn) -> list[Any]: + return [e async for e in turn.events] + + +# --------------------------------------------------------------------------- +# Usage normalization +# --------------------------------------------------------------------------- + + +class TestClaudeCodeUsageToTurnUsage: + def test_full_usage_fields(self): + result = { + "usage": { + "input_tokens": 100, + "output_tokens": 50, + "cache_read_input_tokens": 20, + "cache_creation_input_tokens": 5, + }, + "cost_usd": 0.025, + "duration_ms": 3200, + "num_turns": 3, + } + usage = claude_code_usage_to_turn_usage(result) + + assert usage.input_tokens == 100 + assert usage.output_tokens == 50 + assert usage.cached_input_tokens == 25 # 20 + 5 + assert usage.total_tokens == 150 + assert usage.cost_usd == pytest.approx(0.025) + assert usage.duration_ms == 3200 + assert usage.num_llm_calls == 3 + + def test_total_cost_usd_fallback(self): + """total_cost_usd should be used when cost_usd is absent.""" + result = { + "usage": {"input_tokens": 10, "output_tokens": 5}, + "total_cost_usd": 0.001, + } + usage = claude_code_usage_to_turn_usage(result) + assert usage.cost_usd == pytest.approx(0.001) + + def test_cost_usd_takes_precedence_over_total_cost_usd(self): + result = { + "usage": {"input_tokens": 10, "output_tokens": 5}, + "cost_usd": 0.002, + "total_cost_usd": 0.999, + } + usage = claude_code_usage_to_turn_usage(result) + assert usage.cost_usd == pytest.approx(0.002) + + def test_missing_usage_key_returns_nones(self): + result: dict[str, Any] = {} + usage = claude_code_usage_to_turn_usage(result) + assert usage.input_tokens is None + assert usage.output_tokens is None + assert usage.cached_input_tokens is None + assert usage.total_tokens is None + assert usage.cost_usd is None + assert usage.duration_ms is None + assert usage.num_llm_calls is None + + def test_real_zeros_preserved(self): + result = { + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + }, + "cost_usd": 0.0, + "duration_ms": 0, + "num_turns": 0, + } + usage = claude_code_usage_to_turn_usage(result) + assert usage.input_tokens == 0 + assert usage.output_tokens == 0 + assert usage.cached_input_tokens == 0 + assert usage.total_tokens == 0 + assert usage.cost_usd == pytest.approx(0.0) + assert usage.duration_ms == 0 + assert usage.num_llm_calls == 0 + + def test_only_cache_read_no_creation(self): + result = { + "usage": { + "input_tokens": 50, + "output_tokens": 25, + "cache_read_input_tokens": 15, + } + } + usage = claude_code_usage_to_turn_usage(result) + assert usage.cached_input_tokens == 15 + + def test_only_cache_creation_no_read(self): + result = { + "usage": { + "input_tokens": 50, + "output_tokens": 25, + "cache_creation_input_tokens": 10, + } + } + usage = claude_code_usage_to_turn_usage(result) + assert usage.cached_input_tokens == 10 + + def test_no_cache_fields_gives_none(self): + result = {"usage": {"input_tokens": 10, "output_tokens": 5}} + usage = claude_code_usage_to_turn_usage(result) + assert usage.cached_input_tokens is None + + def test_total_tokens_computed_from_input_output(self): + result = {"usage": {"input_tokens": 70, "output_tokens": 30}} + usage = claude_code_usage_to_turn_usage(result) + assert usage.total_tokens == 100 + + def test_missing_output_tokens_leaves_total_none(self): + result = {"usage": {"input_tokens": 70}} + usage = claude_code_usage_to_turn_usage(result) + assert usage.total_tokens is None + + def test_returns_turn_usage_instance(self): + result = {"usage": {"input_tokens": 1, "output_tokens": 1}} + usage = claude_code_usage_to_turn_usage(result) + assert isinstance(usage, TurnUsage) + + +# --------------------------------------------------------------------------- +# ClaudeCodeTurn protocol +# --------------------------------------------------------------------------- + + +class TestClaudeCodeTurnProtocol: + def test_satisfies_harness_turn_protocol(self): + """ClaudeCodeTurn must satisfy the HarnessTurn structural protocol.""" + turn = ClaudeCodeTurn(_aiter([])) + assert isinstance(turn, HarnessTurn) + + async def test_events_yields_stream_task_messages(self): + envelopes = [ + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "Hi there"}]}, + } + ] + turn = ClaudeCodeTurn(_aiter(envelopes)) + out = await _drain(turn) + assert len(out) == 3 + assert isinstance(out[0], StreamTaskMessageStart) + assert isinstance(out[1], StreamTaskMessageDelta) + assert isinstance(out[2], StreamTaskMessageDone) + + async def test_usage_before_drain_returns_empty(self): + envelopes = [ + { + "type": "result", + "usage": {"input_tokens": 100, "output_tokens": 50}, + "cost_usd": 0.01, + } + ] + turn = ClaudeCodeTurn(_aiter(envelopes)) + # usage() called before events drained — no result envelope yet + usage = turn.usage() + assert isinstance(usage, TurnUsage) + assert usage.input_tokens is None + + async def test_usage_after_drain_reflects_result(self): + envelopes = [ + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "response"}]}, + }, + { + "type": "result", + "usage": {"input_tokens": 200, "output_tokens": 80}, + "cost_usd": 0.015, + "num_turns": 2, + }, + ] + turn = ClaudeCodeTurn(_aiter(envelopes)) + await _drain(turn) + usage = turn.usage() + + assert usage.input_tokens == 200 + assert usage.output_tokens == 80 + assert usage.cost_usd == pytest.approx(0.015) + assert usage.num_llm_calls == 2 + + async def test_usage_empty_when_no_result_envelope(self): + envelopes = [ + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "no result"}]}, + } + ] + turn = ClaudeCodeTurn(_aiter(envelopes)) + await _drain(turn) + usage = turn.usage() + assert usage.input_tokens is None + assert usage.cost_usd is None + + async def test_tool_call_and_result_round_trip(self): + envelopes = [ + { + "type": "assistant", + "message": { + "content": [ + { + "type": "tool_use", + "id": "call_1", + "name": "Read", + "input": {"path": "/etc/hosts"}, + } + ] + }, + }, + { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_1", + "content": "127.0.0.1 localhost", + } + ] + }, + }, + { + "type": "result", + "usage": {"input_tokens": 50, "output_tokens": 20}, + "cost_usd": 0.005, + }, + ] + turn = ClaudeCodeTurn(_aiter(envelopes)) + out = await _drain(turn) + usage = turn.usage() + + tool_starts = [ + e for e in out if isinstance(e, StreamTaskMessageStart) and isinstance(e.content, ToolResponseContent) + ] + tool_fulls = [ + e for e in out if isinstance(e, StreamTaskMessageFull) and isinstance(e.content, ToolResponseContent) + ] + assert len(tool_fulls) == 1 + full_content = tool_fulls[0].content + assert isinstance(full_content, ToolResponseContent) + assert full_content.tool_call_id == "call_1" + + assert usage.input_tokens == 50 + assert usage.output_tokens == 20 + + async def test_events_property_returns_same_iterator(self): + """Accessing .events multiple times returns the same iterator (not a new one each call).""" + turn = ClaudeCodeTurn(_aiter([])) + it1 = turn.events + it2 = turn.events + assert it1 is it2 + + +# --------------------------------------------------------------------------- +# Early session_id capture (resume after interrupt) +# --------------------------------------------------------------------------- + + +class TestClaudeCodeTurnSessionIdCapture: + async def test_session_id_from_result_when_turn_completes(self): + envelopes = [ + {"type": "system", "subtype": "init", "session_id": "sess-init"}, + {"type": "assistant", "message": {"content": [{"type": "text", "text": "hi"}]}}, + {"type": "result", "session_id": "sess-final", "usage": {}}, + ] + turn = ClaudeCodeTurn(_aiter(envelopes)) + await _drain(turn) + # Terminal result wins for a fully-completed turn. + assert turn.session_id == "sess-final" + + async def test_session_id_from_init_when_interrupted_before_result(self): + """No `result` envelope (turn cut short) must still yield a session_id. + + This is the resume fix: capture session_id from the early system/init + envelope so an interrupted-before-completion turn stays resumable. + """ + envelopes = [ + {"type": "system", "subtype": "init", "session_id": "sess-init"}, + {"type": "assistant", "message": {"content": [{"type": "text", "text": "partial"}]}}, + # stream ends here (interrupted) — no result envelope + ] + turn = ClaudeCodeTurn(_aiter(envelopes)) + await _drain(turn) + assert turn.session_id == "sess-init" + + async def test_session_id_none_when_no_init_or_result(self): + envelopes = [ + {"type": "assistant", "message": {"content": [{"type": "text", "text": "x"}]}}, + ] + turn = ClaudeCodeTurn(_aiter(envelopes)) + await _drain(turn) + assert turn.session_id is None + + async def test_guarded_lines_closes_source_on_early_break(self): + """The converter's finally-backstop closes the source stdout iterator. + + Breaking out of the event loop early (as a cancellation/interrupt would) + must trigger aclose() on the underlying lines iterator so the CLI stdout + handle is not leaked. + """ + closed = {"value": False} + + async def _lines(): + try: + yield {"type": "system", "subtype": "init", "session_id": "sess-init"} + yield {"type": "assistant", "message": {"content": [{"type": "text", "text": "a"}]}} + yield {"type": "assistant", "message": {"content": [{"type": "text", "text": "b"}]}} + finally: + closed["value"] = True + + turn = ClaudeCodeTurn(_lines()) + events = turn.events + # Consume only the first event, then close the stream early. + async for _ in events: + break + events_aclose = getattr(events, "aclose", None) + assert events_aclose is not None + await events_aclose() + assert closed["value"] is True diff --git a/tests/lib/adk/test_codex_sync.py b/tests/lib/adk/test_codex_sync.py new file mode 100644 index 000000000..85e67beb9 --- /dev/null +++ b/tests/lib/adk/test_codex_sync.py @@ -0,0 +1,828 @@ +"""Offline tests for the codex event-stream parser tap. + +Tests cover: +- Text streaming (agent_message items) +- Tool call streaming (command_execution, mcp_tool_call, file_change) +- Reasoning streaming (reasoning items) +- Multi-step turns +- Error events (top-level + item-level) +- Edge cases: empty events, non-JSON lines, unknown types +- on_result callback (session_id, usage, counters) +- file_change synthesized start (no item.started emitted by codex) +""" + +from __future__ import annotations + +import json +from typing import Any, AsyncIterator + +from agentex.types.reasoning_content import ReasoningContent +from agentex.types.task_message_delta import TextDelta +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.types.task_message_content import TextContent +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.lib.adk._modules._codex_sync import ( + _truncate, + _tool_args_for, + _tool_name_for, + _tool_output_for, + convert_codex_to_agentex_events, +) +from agentex.types.reasoning_content_delta import ReasoningContentDelta +from agentex.types.reasoning_summary_delta import ReasoningSummaryDelta + + +async def _aiter(items: list[Any]) -> AsyncIterator[Any]: + for item in items: + yield item + + +async def _collect(stream: AsyncIterator[Any]) -> list[Any]: + return [e async for e in stream] + + +def _result_text(event: StreamTaskMessageFull) -> str: + content = event.content + assert isinstance(content, ToolResponseContent) + assert isinstance(content.content, dict) + return str(content.content["result"]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class TestHelpers: + def test_truncate_short(self) -> None: + assert _truncate("hello", max_len=10) == "hello" + + def test_truncate_long(self) -> None: + assert _truncate("a" * 5000) == "a" * 4000 + + def test_tool_name_command_execution(self) -> None: + assert _tool_name_for("command_execution", {}) == "bash" + + def test_tool_name_file_change(self) -> None: + assert _tool_name_for("file_change", {}) == "file_change" + + def test_tool_name_mcp_with_server_and_tool(self) -> None: + assert _tool_name_for("mcp_tool_call", {"server": "fs", "tool": "read"}) == "fs.read" + + def test_tool_name_mcp_empty(self) -> None: + assert _tool_name_for("mcp_tool_call", {}) == "mcp_tool_call" + + def test_tool_name_unknown(self) -> None: + assert _tool_name_for("", {}) == "unknown" + + def test_tool_args_command(self) -> None: + assert _tool_args_for("command_execution", {"command": "ls"}) == {"command": "ls"} + + def test_tool_args_file_change(self) -> None: + assert _tool_args_for("file_change", {"changes": ["a"]}) == {"changes": ["a"]} + + def test_tool_args_mcp_dict(self) -> None: + assert _tool_args_for("mcp_tool_call", {"arguments": {"k": "v"}}) == {"k": "v"} + + def test_tool_args_mcp_non_dict(self) -> None: + assert _tool_args_for("mcp_tool_call", {"arguments": "str"}) == {"value": "str"} + + def test_tool_output_command_success(self) -> None: + text, is_err = _tool_output_for("command_execution", {"aggregated_output": "hello", "exit_code": 0}) + assert text == "hello" + assert is_err is False + + def test_tool_output_command_error(self) -> None: + _, is_err = _tool_output_for("command_execution", {"aggregated_output": "boom", "exit_code": 1}) + assert is_err is True + + def test_tool_output_mcp_error(self) -> None: + text, is_err = _tool_output_for("mcp_tool_call", {"error": {"message": "not found"}}) + assert "not found" in text + assert is_err is True + + def test_tool_output_mcp_result(self) -> None: + text, is_err = _tool_output_for("mcp_tool_call", {"result": {"data": 1}}) + assert json.loads(text) == {"data": 1} + assert is_err is False + + def test_tool_output_file_change_failed(self) -> None: + _, is_err = _tool_output_for("file_change", {"status": "failed", "changes": []}) + assert is_err is True + + def test_tool_output_file_change_ok(self) -> None: + text, is_err = _tool_output_for("file_change", {"status": "ok", "changes": [1, 2]}) + assert "2 changes" in text + assert is_err is False + + +# --------------------------------------------------------------------------- +# Text streaming +# --------------------------------------------------------------------------- + + +class TestTextStreaming: + async def test_text_start_delta_done(self) -> None: + events = [ + {"type": "item.started", "item": {"id": "m1", "type": "agent_message", "text": "Hi"}}, + {"type": "item.updated", "item": {"id": "m1", "type": "agent_message", "text": "Hi!"}}, + {"type": "item.completed", "item": {"id": "m1", "type": "agent_message", "text": "Hi! Done"}}, + ] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + + starts = [e for e in out if isinstance(e, StreamTaskMessageStart)] + deltas = [e for e in out if isinstance(e, StreamTaskMessageDelta)] + dones = [e for e in out if isinstance(e, StreamTaskMessageDone)] + + assert len(starts) == 1 + assert isinstance(starts[0].content, TextContent) + assert len(deltas) >= 1 + all_delta_text = "".join( + d.delta.text_delta for d in deltas if isinstance(d.delta, TextDelta) and d.delta.text_delta is not None + ) + assert "Hi" in all_delta_text + assert len(dones) == 1 + + async def test_text_indices_are_monotonic(self) -> None: + events = [ + {"type": "item.started", "item": {"id": "m1", "type": "agent_message", "text": "A"}}, + {"type": "item.completed", "item": {"id": "m1", "type": "agent_message", "text": "A"}}, + ] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + anchor = [e for e in out if isinstance(e, StreamTaskMessageStart)] + done = [e for e in out if isinstance(e, StreamTaskMessageDone)] + assert anchor[0].index == done[0].index + + async def test_empty_text_no_delta(self) -> None: + events = [ + {"type": "item.started", "item": {"id": "m1", "type": "agent_message", "text": ""}}, + {"type": "item.completed", "item": {"id": "m1", "type": "agent_message", "text": ""}}, + ] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + deltas = [e for e in out if isinstance(e, StreamTaskMessageDelta)] + assert deltas == [] + + async def test_text_author_is_agent(self) -> None: + events = [ + {"type": "item.started", "item": {"id": "m1", "type": "agent_message", "text": "X"}}, + ] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + for e in out: + content = getattr(e, "content", None) + if content and hasattr(content, "author"): + assert content.author == "agent" + + +# --------------------------------------------------------------------------- +# Tool call streaming +# --------------------------------------------------------------------------- + + +class TestToolCallStreaming: + async def test_command_execution_start_done_full(self) -> None: + events = [ + { + "type": "item.started", + "item": { + "id": "t1", + "type": "command_execution", + "command": "echo hello", + }, + }, + { + "type": "item.completed", + "item": { + "id": "t1", + "type": "command_execution", + "command": "echo hello", + "aggregated_output": "hello", + "exit_code": 0, + }, + }, + ] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + + starts = [e for e in out if isinstance(e, StreamTaskMessageStart)] + dones = [e for e in out if isinstance(e, StreamTaskMessageDone)] + fulls = [e for e in out if isinstance(e, StreamTaskMessageFull)] + + assert len(starts) == 1 + assert isinstance(starts[0].content, ToolRequestContent) + assert starts[0].content.name == "bash" + assert starts[0].content.arguments == {"command": "echo hello"} + assert starts[0].content.tool_call_id == "t1" + + assert len(dones) == 1 + + assert len(fulls) == 1 + assert isinstance(fulls[0].content, ToolResponseContent) + resp_content = fulls[0].content.content + assert isinstance(resp_content, dict) + assert resp_content["result"] == "hello" + assert fulls[0].content.tool_call_id == "t1" + + async def test_empty_item_id_request_response_ids_match(self) -> None: + """A tool with an empty item_id must use the SAME fallback tool_call_id + on the request (started) and response (completed) halves.""" + events = [ + {"type": "item.started", "item": {"id": "", "type": "command_execution", "command": "ls"}}, + { + "type": "item.completed", + "item": { + "id": "", + "type": "command_execution", + "command": "ls", + "aggregated_output": ".", + "exit_code": 0, + }, + }, + ] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + # Pull tool_call_id inside the comprehension so the isinstance narrows the + # content union (the narrowing would not survive a later attribute access). + req_ids = [ + e.content.tool_call_id + for e in out + if isinstance(e, StreamTaskMessageStart) and isinstance(e.content, ToolRequestContent) + ] + resp_ids = [ + e.content.tool_call_id + for e in out + if isinstance(e, StreamTaskMessageFull) and isinstance(e.content, ToolResponseContent) + ] + assert len(req_ids) == 1 and len(resp_ids) == 1 + assert req_ids[0] == resp_ids[0] + + async def test_file_change_synthesizes_start(self) -> None: + """file_change items may only emit item.completed (no started).""" + events = [ + { + "type": "item.completed", + "item": { + "id": "fc1", + "type": "file_change", + "changes": ["a.py"], + "status": "ok", + }, + } + ] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + tool_req = [ + e for e in out if isinstance(e, StreamTaskMessageFull) and isinstance(e.content, ToolRequestContent) + ] + tool_resp = [ + e for e in out if isinstance(e, StreamTaskMessageFull) and isinstance(e.content, ToolResponseContent) + ] + assert len(tool_req) == 1 + assert isinstance(tool_req[0].content, ToolRequestContent) + assert tool_req[0].content.name == "file_change" + assert len(tool_resp) == 1 + + async def test_mcp_tool_call_name(self) -> None: + events = [ + { + "type": "item.started", + "item": { + "id": "mcp1", + "type": "mcp_tool_call", + "server": "fs", + "tool": "read", + "arguments": {"path": "/x"}, + }, + }, + { + "type": "item.completed", + "item": { + "id": "mcp1", + "type": "mcp_tool_call", + "server": "fs", + "tool": "read", + "arguments": {"path": "/x"}, + "result": "content", + }, + }, + ] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + req = next( + e for e in out if isinstance(e, StreamTaskMessageStart) and isinstance(e.content, ToolRequestContent) + ) + assert isinstance(req.content, ToolRequestContent) + assert req.content.name == "fs.read" + + async def test_tool_error_marks_is_error(self) -> None: + events = [ + { + "type": "item.started", + "item": {"id": "cmd1", "type": "command_execution", "command": "bad"}, + }, + { + "type": "item.completed", + "item": { + "id": "cmd1", + "type": "command_execution", + "command": "bad", + "aggregated_output": "error output", + "exit_code": 127, + }, + }, + ] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + resp = next( + e for e in out if isinstance(e, StreamTaskMessageFull) and isinstance(e.content, ToolResponseContent) + ) + assert isinstance(resp.content, ToolResponseContent) + resp_body = resp.content.content + assert isinstance(resp_body, dict) + assert resp_body.get("is_error") is True + + async def test_tool_indices_request_before_response(self) -> None: + events = [ + { + "type": "item.started", + "item": {"id": "cmd2", "type": "command_execution", "command": "ls"}, + }, + { + "type": "item.completed", + "item": { + "id": "cmd2", + "type": "command_execution", + "command": "ls", + "aggregated_output": ".", + "exit_code": 0, + }, + }, + ] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + req = next(e for e in out if isinstance(e, StreamTaskMessageStart)) + resp = next( + e for e in out if isinstance(e, StreamTaskMessageFull) and isinstance(e.content, ToolResponseContent) + ) + assert req.index is not None and resp.index is not None + assert req.index < resp.index + + +# --------------------------------------------------------------------------- +# Progressive tool items (todo_list) +# --------------------------------------------------------------------------- + + +def _todo_item(item_id: str, *completed: bool) -> dict[str, Any]: + return { + "id": item_id, + "type": "todo_list", + "items": [{"text": f"step {i + 1}", "completed": done} for i, done in enumerate(completed)], + } + + +class TestTodoListUpdates: + async def test_each_update_republishes_the_checklist(self) -> None: + """Codex ticks ONE in-place todo_list item, so every item.updated is + forwarded as a response under the same tool_call_id. Without this a + consumer only sees the plan as first written and then, at end of turn, + as finished.""" + events = [ + {"type": "item.started", "item": _todo_item("item_1", False, False)}, + {"type": "item.updated", "item": _todo_item("item_1", True, False)}, + {"type": "item.updated", "item": _todo_item("item_1", True, True)}, + {"type": "item.completed", "item": _todo_item("item_1", True, True)}, + ] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + + requests = [ + e for e in out if isinstance(e, StreamTaskMessageStart) and isinstance(e.content, ToolRequestContent) + ] + responses = [ + e for e in out if isinstance(e, StreamTaskMessageFull) and isinstance(e.content, ToolResponseContent) + ] + + assert len(requests) == 1 + assert len(responses) == 3 + + request_content = requests[0].content + assert isinstance(request_content, ToolRequestContent) + for response in responses: + content = response.content + assert isinstance(content, ToolResponseContent) + assert content.name == "todo_list" + assert content.tool_call_id == request_content.tool_call_id + + first, second, final = (json.loads(_result_text(r)) for r in responses) + assert [i["completed"] for i in first["items"]] == [True, False] + assert [i["completed"] for i in second["items"]] == [True, True] + assert [i["completed"] for i in final["items"]] == [True, True] + + async def test_counts_the_call_once_across_its_updates(self) -> None: + events = [ + {"type": "item.started", "item": _todo_item("item_1", False)}, + {"type": "item.updated", "item": _todo_item("item_1", True)}, + {"type": "item.completed", "item": _todo_item("item_1", True)}, + ] + counters: dict[str, Any] = {} + await _collect(convert_codex_to_agentex_events(_aiter(events), on_result=counters.update)) + assert counters.get("tool_call_count") == 1 + + async def test_ignores_an_update_for_an_unopened_item(self) -> None: + """An update with no preceding start has no request to answer; a + response would dangle with a tool_call_id nothing points at.""" + events = [{"type": "item.updated", "item": _todo_item("orphan", True)}] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + assert [e for e in out if isinstance(e, StreamTaskMessageFull)] == [] + + async def test_does_not_republish_other_tool_items(self) -> None: + events = [ + { + "type": "item.started", + "item": {"id": "cmd1", "type": "command_execution", "command": "sleep 1"}, + }, + { + "type": "item.updated", + "item": { + "id": "cmd1", + "type": "command_execution", + "command": "sleep 1", + "aggregated_output": "partial", + }, + }, + { + "type": "item.completed", + "item": { + "id": "cmd1", + "type": "command_execution", + "command": "sleep 1", + "aggregated_output": "done", + "exit_code": 0, + }, + }, + ] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + responses = [ + e for e in out if isinstance(e, StreamTaskMessageFull) and isinstance(e.content, ToolResponseContent) + ] + assert len(responses) == 1 + assert _result_text(responses[0]) == "done" + + +# --------------------------------------------------------------------------- +# Reasoning +# --------------------------------------------------------------------------- + + +class TestReasoningStreaming: + async def test_reasoning_start_deltas_done(self) -> None: + """A reasoning block opens with a Start, streams the final text as + summary + content deltas, and closes with a Done. + + It must NOT emit a Full at the open Start's index: auto_send routes a + Full into a throwaway streaming context (ignoring the index), which + would leave the Start context dangling and persist a duplicate, empty + reasoning message (AGX1 codex reasoning duplicate bug). + """ + events = [ + {"type": "item.started", "item": {"id": "r1", "type": "reasoning", "text": ""}}, + { + "type": "item.updated", + "item": {"id": "r1", "type": "reasoning", "text": "thinking..."}, + }, + { + "type": "item.completed", + "item": {"id": "r1", "type": "reasoning", "text": "thinking... done"}, + }, + ] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + + starts = [e for e in out if isinstance(e, StreamTaskMessageStart)] + dones = [e for e in out if isinstance(e, StreamTaskMessageDone)] + reasoning_fulls = [ + e for e in out if isinstance(e, StreamTaskMessageFull) and isinstance(e.content, ReasoningContent) + ] + content_deltas = [ + e for e in out if isinstance(e, StreamTaskMessageDelta) and isinstance(e.delta, ReasoningContentDelta) + ] + summary_deltas = [ + e for e in out if isinstance(e, StreamTaskMessageDelta) and isinstance(e.delta, ReasoningSummaryDelta) + ] + + # Exactly one message: Start + deltas + Done, all on the same index, no Full. + assert len(starts) == 1 + assert isinstance(starts[0].content, ReasoningContent) + assert reasoning_fulls == [] + assert len(content_deltas) == 1 + content_delta = content_deltas[0].delta + assert isinstance(content_delta, ReasoningContentDelta) + assert content_delta.content_delta == "thinking... done" + assert len(summary_deltas) == 1 + summary_delta = summary_deltas[0].delta + assert isinstance(summary_delta, ReasoningSummaryDelta) + assert summary_delta.summary_delta == "thinking... done" + assert len(dones) == 1 + idx = starts[0].index + assert content_deltas[0].index == idx + assert summary_deltas[0].index == idx + assert dones[0].index == idx + + async def test_reasoning_no_started_opens_and_closes_one_message(self) -> None: + """If item.completed arrives without item.started, the converter opens a + Start lazily and closes it with a Done (still one clean message, no Full).""" + events = [ + { + "type": "item.completed", + "item": {"id": "r_orphan", "type": "reasoning", "text": "orphan thought"}, + } + ] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + + starts = [e for e in out if isinstance(e, StreamTaskMessageStart)] + dones = [e for e in out if isinstance(e, StreamTaskMessageDone)] + reasoning_fulls = [ + e for e in out if isinstance(e, StreamTaskMessageFull) and isinstance(e.content, ReasoningContent) + ] + content_deltas = [ + e for e in out if isinstance(e, StreamTaskMessageDelta) and isinstance(e.delta, ReasoningContentDelta) + ] + + assert len(starts) == 1 + assert isinstance(starts[0].content, ReasoningContent) + assert reasoning_fulls == [] + assert len(content_deltas) == 1 + content_delta = content_deltas[0].delta + assert isinstance(content_delta, ReasoningContentDelta) + assert content_delta.content_delta == "orphan thought" + assert len(dones) == 1 + assert dones[0].index == starts[0].index + + async def test_reasoning_summary_is_first_line(self) -> None: + events = [ + {"type": "item.started", "item": {"id": "r2", "type": "reasoning", "text": ""}}, + { + "type": "item.completed", + "item": {"id": "r2", "type": "reasoning", "text": "line one\nline two"}, + }, + ] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + summary_event = next( + e for e in out if isinstance(e, StreamTaskMessageDelta) and isinstance(e.delta, ReasoningSummaryDelta) + ) + summary_delta = summary_event.delta + assert isinstance(summary_delta, ReasoningSummaryDelta) + assert summary_delta.summary_delta == "line one" + + async def test_reasoning_empty_block_closes_with_done_only(self) -> None: + """A reasoning block that completes with no text still closes its Start.""" + events = [ + {"type": "item.started", "item": {"id": "r3", "type": "reasoning", "text": ""}}, + {"type": "item.completed", "item": {"id": "r3", "type": "reasoning", "text": ""}}, + ] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + + starts = [e for e in out if isinstance(e, StreamTaskMessageStart)] + dones = [e for e in out if isinstance(e, StreamTaskMessageDone)] + deltas = [e for e in out if isinstance(e, StreamTaskMessageDelta)] + + assert len(starts) == 1 + assert deltas == [] + assert len(dones) == 1 + assert dones[0].index == starts[0].index + + +# --------------------------------------------------------------------------- +# Error events +# --------------------------------------------------------------------------- + + +class TestErrorEvents: + async def test_turn_failed_emits_error_text(self) -> None: + events = [{"type": "turn.failed", "error": {"message": "context length exceeded"}}] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + assert len(out) == 1 + assert isinstance(out[0], StreamTaskMessageFull) + assert isinstance(out[0].content, TextContent) + assert "context length exceeded" in out[0].content.content + + async def test_top_level_error_emits_text(self) -> None: + events = [{"type": "error", "message": "unexpected EOF"}] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + assert len(out) == 1 + assert isinstance(out[0].content, TextContent) + assert "unexpected EOF" in out[0].content.content + + async def test_item_error_emits_on_completed_only(self) -> None: + events = [ + {"type": "item.started", "item": {"id": "e1", "type": "error", "message": "bad"}}, + {"type": "item.completed", "item": {"id": "e1", "type": "error", "message": "bad"}}, + ] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + # Only item.completed emits an event for error items + assert len(out) == 1 + assert isinstance(out[0].content, TextContent) + assert "bad" in out[0].content.content + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + + +class TestEdgeCases: + async def test_empty_stream(self) -> None: + out = await _collect(convert_codex_to_agentex_events(_aiter([]))) + assert out == [] + + async def test_non_json_lines_skipped(self) -> None: + events: list[str] = ["not json", "also not json"] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + assert out == [] + + async def test_blank_lines_skipped(self) -> None: + out = await _collect(convert_codex_to_agentex_events(_aiter(["", " ", "\n"]))) + assert out == [] + + async def test_pre_decoded_dict_events(self) -> None: + """Events passed as dicts (pre-decoded) should work without JSON parsing.""" + events: list[dict[str, Any]] = [ + {"type": "item.started", "item": {"id": "m1", "type": "agent_message", "text": "hi"}}, + { + "type": "item.completed", + "item": {"id": "m1", "type": "agent_message", "text": "hi"}, + }, + ] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + assert len(out) > 0 + + async def test_thread_started_no_message(self) -> None: + events = [{"type": "thread.started", "thread_id": "t1"}] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + assert out == [] + + async def test_turn_started_no_message(self) -> None: + out = await _collect(convert_codex_to_agentex_events(_aiter([{"type": "turn.started"}]))) + assert out == [] + + async def test_turn_completed_no_message(self) -> None: + out = await _collect( + convert_codex_to_agentex_events(_aiter([{"type": "turn.completed", "usage": {"input_tokens": 1}}])) + ) + assert out == [] + + async def test_unknown_event_type_no_message(self) -> None: + out = await _collect(convert_codex_to_agentex_events(_aiter([{"type": "some.future.event"}]))) + assert out == [] + + async def test_unknown_item_type_no_message(self) -> None: + out = await _collect( + convert_codex_to_agentex_events( + _aiter([{"type": "item.started", "item": {"id": "x", "type": "future_item"}}]) + ) + ) + assert out == [] + + +# --------------------------------------------------------------------------- +# on_result callback +# --------------------------------------------------------------------------- + + +class TestOnResult: + async def test_session_id_captured(self) -> None: + result: dict[str, Any] = {} + + def on_result(r: dict[str, Any]) -> None: + result.update(r) + + events = [ + {"type": "thread.started", "thread_id": "sess-xyz"}, + { + "type": "turn.completed", + "usage": {"input_tokens": 5, "output_tokens": 3, "total_tokens": 8}, + }, + ] + await _collect(convert_codex_to_agentex_events(_aiter(events), on_result=on_result)) + assert result["session_id"] == "sess-xyz" + + async def test_usage_forwarded(self) -> None: + result: dict[str, Any] = {} + + def on_result(r: dict[str, Any]) -> None: + result.update(r) + + events = [ + { + "type": "turn.completed", + "usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, + } + ] + await _collect(convert_codex_to_agentex_events(_aiter(events), on_result=on_result)) + assert result["usage"] == {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15} + + async def test_tool_count(self) -> None: + result: dict[str, Any] = {} + + def on_result(r: dict[str, Any]) -> None: + result.update(r) + + events = [ + { + "type": "item.started", + "item": {"id": "t1", "type": "command_execution", "command": "ls"}, + }, + { + "type": "item.completed", + "item": { + "id": "t1", + "type": "command_execution", + "command": "ls", + "aggregated_output": ".", + "exit_code": 0, + }, + }, + {"type": "turn.completed", "usage": None}, + ] + await _collect(convert_codex_to_agentex_events(_aiter(events), on_result=on_result)) + assert result["tool_call_count"] == 1 + + async def test_no_callback_when_none(self) -> None: + """Passing on_result=None should not raise.""" + events = [{"type": "turn.completed", "usage": None}] + out = await _collect(convert_codex_to_agentex_events(_aiter(events), on_result=None)) + assert out == [] + + async def test_on_result_called_even_without_turn_completed(self) -> None: + """on_result fires at end of stream even if turn.completed never arrived.""" + result: dict[str, Any] = {} + + def on_result(r: dict[str, Any]) -> None: + result.update(r) + + events: list[Any] = [] + await _collect(convert_codex_to_agentex_events(_aiter(events), on_result=on_result)) + assert result.get("usage") is None + assert result.get("session_id") is None + + +# --------------------------------------------------------------------------- +# Multi-step turn: tool → text +# --------------------------------------------------------------------------- + + +class TestMultiStepTurn: + async def test_tool_then_text_monotonic_indices(self) -> None: + events = [ + { + "type": "item.started", + "item": {"id": "cmd1", "type": "command_execution", "command": "ls"}, + }, + { + "type": "item.completed", + "item": { + "id": "cmd1", + "type": "command_execution", + "command": "ls", + "aggregated_output": "file.txt", + "exit_code": 0, + }, + }, + { + "type": "item.started", + "item": {"id": "msg1", "type": "agent_message", "text": ""}, + }, + { + "type": "item.completed", + "item": {"id": "msg1", "type": "agent_message", "text": "Done"}, + }, + ] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + indices = [e.index for e in out] + assert indices == sorted(indices), "indices must be monotonically non-decreasing" + + async def test_two_text_blocks_distinct_indices(self) -> None: + events = [ + { + "type": "item.started", + "item": {"id": "a", "type": "agent_message", "text": "first"}, + }, + {"type": "item.completed", "item": {"id": "a", "type": "agent_message", "text": "first"}}, + { + "type": "item.started", + "item": {"id": "b", "type": "agent_message", "text": "second"}, + }, + {"type": "item.completed", "item": {"id": "b", "type": "agent_message", "text": "second"}}, + ] + out = await _collect(convert_codex_to_agentex_events(_aiter(events))) + starts = [e for e in out if isinstance(e, StreamTaskMessageStart)] + assert len(starts) == 2 + assert starts[0].index != starts[1].index + + async def test_json_string_events(self) -> None: + """Events may arrive as raw newline-delimited JSON strings.""" + raw_events = [ + json.dumps({"type": "item.started", "item": {"id": "s1", "type": "agent_message", "text": "hello"}}), + json.dumps({"type": "item.completed", "item": {"id": "s1", "type": "agent_message", "text": "hello"}}), + ] + out = await _collect(convert_codex_to_agentex_events(_aiter(raw_events))) + assert len(out) > 0 + assert any(isinstance(e, StreamTaskMessageStart) for e in out) diff --git a/tests/lib/adk/test_codex_turn.py b/tests/lib/adk/test_codex_turn.py new file mode 100644 index 000000000..c2843e90e --- /dev/null +++ b/tests/lib/adk/test_codex_turn.py @@ -0,0 +1,341 @@ +"""Offline tests for CodexTurn and codex_usage_to_turn_usage. + +Tests cover: +- TurnUsage normalization from raw codex usage dicts +- Defensive handling of missing/invalid usage fields +- CodexTurn: events property yields canonical StreamTaskMessage* +- CodexTurn: usage() before and after stream exhaustion +- CodexTurn: on_result wiring (session_id, counts propagate to usage()) +- CodexTurn satisfies HarnessTurn protocol +""" + +from __future__ import annotations + +from typing import Any, AsyncIterator + +import pytest + +from agentex.lib.core.harness.types import TurnUsage, HarnessTurn +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.lib.adk._modules._codex_turn import ( + CodexTurn, + codex_usage_to_turn_usage, +) + + +async def _aiter(items: list[Any]) -> AsyncIterator[Any]: + for item in items: + yield item + + +async def _collect(turn: CodexTurn) -> list[Any]: + return [msg async for msg in turn.events] + + +# --------------------------------------------------------------------------- +# codex_usage_to_turn_usage +# --------------------------------------------------------------------------- + + +class TestCodexUsageToTurnUsage: + def test_none_raw_all_none_tokens(self) -> None: + u = codex_usage_to_turn_usage(None) + assert u.input_tokens is None + assert u.output_tokens is None + assert u.total_tokens is None + assert u.cost_usd is None + + def test_empty_dict_all_none_tokens(self) -> None: + u = codex_usage_to_turn_usage({}) + assert u.input_tokens is None + assert u.output_tokens is None + + def test_standard_usage(self) -> None: + raw = {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150} + u = codex_usage_to_turn_usage(raw, model="o4-mini") + assert u.input_tokens == 100 + assert u.output_tokens == 50 + assert u.total_tokens == 150 + assert u.model == "o4-mini" + + def test_reasoning_tokens(self) -> None: + raw = {"input_tokens": 200, "output_tokens": 80, "reasoning_tokens": 60, "total_tokens": 340} + u = codex_usage_to_turn_usage(raw) + assert u.reasoning_tokens == 60 + + def test_real_zero_preserved(self) -> None: + """Explicit zeros in the payload must survive (not be treated as missing).""" + raw = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0} + u = codex_usage_to_turn_usage(raw) + assert u.input_tokens == 0 + assert u.output_tokens == 0 + + def test_cached_input_tokens(self) -> None: + raw = {"input_tokens": 100, "cached_input_tokens": 20, "output_tokens": 40} + u = codex_usage_to_turn_usage(raw) + assert u.cached_input_tokens == 20 + + def test_invalid_token_values_become_none(self) -> None: + raw = {"input_tokens": "not_a_number", "output_tokens": None} + u = codex_usage_to_turn_usage(raw) + assert u.input_tokens is None + assert u.output_tokens is None + + def test_cost_explicit(self) -> None: + u = codex_usage_to_turn_usage(None, cost_usd=0.0042) + assert u.cost_usd == pytest.approx(0.0042) + + def test_cost_from_raw(self) -> None: + u = codex_usage_to_turn_usage({"cost_usd": 0.001}) + assert u.cost_usd == pytest.approx(0.001) + + def test_explicit_cost_overrides_raw(self) -> None: + """Explicit cost_usd kwarg takes precedence over raw dict value.""" + u = codex_usage_to_turn_usage({"cost_usd": 0.001}, cost_usd=0.002) + assert u.cost_usd == pytest.approx(0.002) + + def test_tool_and_reasoning_counts(self) -> None: + u = codex_usage_to_turn_usage(None, tool_call_count=3, reasoning_count=2) + assert u.num_tool_calls == 3 + assert u.num_reasoning_blocks == 2 + + def test_num_llm_calls_always_one(self) -> None: + u = codex_usage_to_turn_usage(None) + assert u.num_llm_calls == 1 + + def test_duration_ms(self) -> None: + u = codex_usage_to_turn_usage(None, duration_ms=1234) + assert u.duration_ms == 1234 + + def test_model_none_when_not_provided(self) -> None: + u = codex_usage_to_turn_usage(None) + assert u.model is None + + def test_non_dict_raw_treated_as_empty(self) -> None: + u = codex_usage_to_turn_usage("bad input") # type: ignore[arg-type] + assert u.input_tokens is None + + def test_returns_turn_usage_instance(self) -> None: + u = codex_usage_to_turn_usage({}) + assert isinstance(u, TurnUsage) + + +# --------------------------------------------------------------------------- +# CodexTurn protocol conformance +# --------------------------------------------------------------------------- + + +class TestCodexTurnProtocol: + def test_implements_harness_turn_protocol(self) -> None: + turn = CodexTurn(_aiter([]), model="o4-mini") + assert isinstance(turn, HarnessTurn) + + def test_usage_before_exhaustion_returns_zero_turn_usage(self) -> None: + turn = CodexTurn(_aiter([]), model="test-model") + u = turn.usage() + assert isinstance(u, TurnUsage) + assert u.model == "test-model" + assert u.input_tokens is None + assert u.num_tool_calls == 0 + + +# --------------------------------------------------------------------------- +# CodexTurn events +# --------------------------------------------------------------------------- + + +class TestCodexTurnEvents: + async def test_events_yield_stream_task_messages(self) -> None: + events = [ + {"type": "item.started", "item": {"id": "m1", "type": "agent_message", "text": "hi"}}, + {"type": "item.completed", "item": {"id": "m1", "type": "agent_message", "text": "hi"}}, + ] + turn = CodexTurn(_aiter(events), model="o4-mini") + out = await _collect(turn) + assert len(out) > 0 + for msg in out: + assert isinstance( + msg, + (StreamTaskMessageStart, StreamTaskMessageDelta, StreamTaskMessageFull, StreamTaskMessageDone), + ) + + async def test_usage_after_exhaustion_has_tokens(self) -> None: + events = [ + { + "type": "turn.completed", + "usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, + } + ] + turn = CodexTurn(_aiter(events), model="o4-mini") + await _collect(turn) + u = turn.usage() + assert u.input_tokens == 10 + assert u.output_tokens == 5 + assert u.total_tokens == 15 + + async def test_usage_model_propagated(self) -> None: + events = [{"type": "turn.completed", "usage": None}] + turn = CodexTurn(_aiter(events), model="codex-model-x") + await _collect(turn) + assert turn.usage().model == "codex-model-x" + + async def test_tool_count_in_usage(self) -> None: + events = [ + { + "type": "item.started", + "item": {"id": "t1", "type": "command_execution", "command": "ls"}, + }, + { + "type": "item.completed", + "item": { + "id": "t1", + "type": "command_execution", + "command": "ls", + "aggregated_output": ".", + "exit_code": 0, + }, + }, + {"type": "turn.completed", "usage": None}, + ] + turn = CodexTurn(_aiter(events), model="o4-mini") + await _collect(turn) + assert turn.usage().num_tool_calls == 1 + + async def test_events_property_stable_across_accesses(self) -> None: + """`.events` returns the same generator; usage survives a second access.""" + events = [ + { + "type": "item.started", + "item": {"id": "t1", "type": "command_execution", "command": "ls"}, + }, + { + "type": "item.completed", + "item": { + "id": "t1", + "type": "command_execution", + "command": "ls", + "aggregated_output": ".", + "exit_code": 0, + }, + }, + {"type": "turn.completed", "usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}}, + ] + turn = CodexTurn(_aiter(events), model="o4-mini") + assert turn.events is turn.events # same generator, not a fresh wrapper + await _collect(turn) + # A second access must NOT re-wrap the exhausted iterator and reset usage. + _ = turn.events + assert turn.usage().total_tokens == 15 + assert turn.usage().num_tool_calls == 1 + + async def test_reasoning_count_in_usage(self) -> None: + events = [ + {"type": "item.started", "item": {"id": "r1", "type": "reasoning", "text": ""}}, + { + "type": "item.completed", + "item": {"id": "r1", "type": "reasoning", "text": "thought"}, + }, + {"type": "turn.completed", "usage": None}, + ] + turn = CodexTurn(_aiter(events), model="o4-mini") + await _collect(turn) + assert turn.usage().num_reasoning_blocks == 1 + + async def test_duration_ms_passed_through(self) -> None: + events = [{"type": "turn.completed", "usage": None}] + turn = CodexTurn(_aiter(events), model="o4-mini", duration_ms=999) + await _collect(turn) + assert turn.usage().duration_ms == 999 + + async def test_cost_usd_passed_through(self) -> None: + events = [{"type": "turn.completed", "usage": None}] + turn = CodexTurn(_aiter(events), model="o4-mini", cost_usd=0.007) + await _collect(turn) + assert turn.usage().cost_usd == pytest.approx(0.007) + + async def test_empty_stream_usage_still_valid(self) -> None: + turn = CodexTurn(_aiter([]), model="o4-mini") + await _collect(turn) + u = turn.usage() + assert isinstance(u, TurnUsage) + assert u.num_llm_calls == 1 + + async def test_reasoning_tokens_propagated(self) -> None: + events = [ + { + "type": "turn.completed", + "usage": { + "input_tokens": 100, + "output_tokens": 60, + "reasoning_tokens": 40, + "total_tokens": 200, + }, + } + ] + turn = CodexTurn(_aiter(events), model="o4-mini") + await _collect(turn) + assert turn.usage().reasoning_tokens == 40 + + +# --------------------------------------------------------------------------- +# Early session_id capture (resume after interrupt) +# --------------------------------------------------------------------------- + + +class TestCodexTurnSessionIdCapture: + async def test_session_id_from_result_when_turn_completes(self) -> None: + events = [ + {"type": "thread.started", "thread_id": "thread-abc"}, + {"type": "turn.completed", "usage": None}, + ] + turn = CodexTurn(_aiter(events), model="o4-mini") + await _collect(turn) + # on_result carries session_id from processor.session_id at end of stream. + assert turn.session_id == "thread-abc" + + async def test_session_id_from_init_when_interrupted_before_completion(self) -> None: + """thread.started must yield session_id even with no turn.completed. + + Mirrors the claude-code early-capture fix: an interrupted-before-completion + codex turn never emits turn.completed, so the early thread.started capture + is what keeps it resumable. + """ + events = [ + {"type": "thread.started", "thread_id": "thread-abc"}, + # stream ends here (interrupted) — no turn.completed + ] + turn = CodexTurn(_aiter(events), model="o4-mini") + await _collect(turn) + assert turn.session_id == "thread-abc" + + async def test_session_id_none_without_thread_started(self) -> None: + turn = CodexTurn(_aiter([{"type": "turn.completed", "usage": None}]), model="o4-mini") + await _collect(turn) + assert turn.session_id is None + + async def test_guarded_events_closes_source_on_early_break(self) -> None: + closed = {"value": False} + + async def _events(): + try: + yield {"type": "thread.started", "thread_id": "thread-abc"} + yield { + "type": "item.completed", + "item": {"id": "m1", "type": "agent_message", "text": "hi"}, + } + finally: + closed["value"] = True + + turn = CodexTurn(_events(), model="o4-mini") + gen = turn.events + async for _ in gen: + break + gen_aclose = getattr(gen, "aclose", None) + assert gen_aclose is not None + await gen_aclose() + assert closed["value"] is True diff --git a/tests/lib/adk/test_langgraph_async.py b/tests/lib/adk/test_langgraph_async.py new file mode 100644 index 000000000..ebe215a15 --- /dev/null +++ b/tests/lib/adk/test_langgraph_async.py @@ -0,0 +1,282 @@ +"""Characterization tests for stream_langgraph_events (unified surface). + +These tests verify the behavior of ``stream_langgraph_events`` after it was +reimplemented on top of ``LangGraphTurn`` + ``UnifiedEmitter.auto_send_turn`` +(Task 4). They serve as a contract test for the public signature. + +Key behavioral notes (unified surface vs. old bespoke implementation): +- Tool calls/responses are posted via ``streaming_task_message_context`` (not + ``adk.messages.create``); they appear as contexts with no stream_update calls. +- ``final_text`` accumulates ALL text across the turn (the old bespoke impl + only returned the last text segment — behavior varied across models). + +NOTE: langchain_core imports are deferred to test scope because conftest.py +stubs ``langchain_core.messages`` with MagicMock. +""" + +from __future__ import annotations + +import sys +from typing import Any +from dataclasses import field, dataclass + +import pytest + +from agentex.types.task_message import TaskMessage +from agentex.types.text_content import TextContent +from agentex.types.task_message_delta import TextDelta +from agentex.types.task_message_update import StreamTaskMessageDelta +from agentex.lib.adk._modules._langgraph_turn import stream_langgraph_events + +TASK_ID = "task-test" + + +# --------------------------------------------------------------------------- +# Remove conftest stubs so real langchain_core types are used +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _real_langchain_core(): + stub_keys = [k for k in sys.modules if k.startswith("langchain_core") or k.startswith("langgraph")] + saved = {k: sys.modules.pop(k) for k in stub_keys} + import importlib + + importlib.import_module("langchain_core.messages") + yield + sys.modules.update(saved) + + +# --------------------------------------------------------------------------- +# Fake streaming infrastructure (mirrors test_pydantic_ai_async.py pattern) +# --------------------------------------------------------------------------- + + +@dataclass +class FakeContext: + initial_content: Any + task_message: TaskMessage + closed: bool = False + updates: list[StreamTaskMessageDelta] = field(default_factory=list) + + async def __aenter__(self) -> "FakeContext": + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> bool: + await self.close() + return False + + async def stream_update(self, update: StreamTaskMessageDelta) -> None: + if self.closed: + raise AssertionError("stream_update called after close") + self.updates.append(update) + + async def close(self) -> None: + self.closed = True + + +class FakeStreamingModule: + def __init__(self) -> None: + self.contexts: list[FakeContext] = [] + + def streaming_task_message_context(self, *, task_id: str, initial_content: Any, **kw: Any) -> FakeContext: + tm = TaskMessage( + id=f"m{len(self.contexts) + 1}", + task_id=task_id, + content=initial_content, + streaming_status="IN_PROGRESS", + ) + ctx = FakeContext(initial_content=initial_content, task_message=tm) + self.contexts.append(ctx) + return ctx + + +class FakeMessagesModule: + def __init__(self) -> None: + self.created: list[dict[str, Any]] = [] + + async def create(self, *, task_id: str, content: Any) -> TaskMessage: + self.created.append({"task_id": task_id, "content": content}) + return TaskMessage( + id=f"created-{len(self.created)}", + task_id=task_id, + content=content, + streaming_status="DONE", + ) + + +@pytest.fixture +def fake_adk(monkeypatch): + from agentex.lib import adk as adk_module + + streaming = FakeStreamingModule() + messages = FakeMessagesModule() + monkeypatch.setattr(adk_module, "streaming", streaming) + monkeypatch.setattr(adk_module, "messages", messages) + return streaming, messages + + +def _make_stream(events: list[tuple[str, Any]]): + async def _gen(): + for e in events: + yield e + + return _gen() + + +def _text_deltas(ctx: FakeContext) -> list[str]: + out: list[str] = [] + for u in ctx.updates: + if isinstance(u.delta, TextDelta): + out.append(u.delta.text_delta or "") + return out + + +# --------------------------------------------------------------------------- +# Characterization tests (unified surface behavior) +# --------------------------------------------------------------------------- + + +class TestCharacterization: + async def test_plain_text_streams_and_returns_final_text( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + from langchain_core.messages import AIMessage, AIMessageChunk + + streaming, messages = fake_adk + chunk = AIMessageChunk(content="Hello, world!") + ai_msg = AIMessage(content="Hello, world!") + stream = _make_stream( + [ + ("messages", (chunk, {})), + ("updates", {"agent": {"messages": [ai_msg]}}), + ] + ) + + final = await stream_langgraph_events(stream, TASK_ID) + + assert final == "Hello, world!" + assert len(streaming.contexts) == 1 + ctx = streaming.contexts[0] + assert isinstance(ctx.initial_content, TextContent) + assert _text_deltas(ctx) == ["Hello, world!"] + assert ctx.closed is True + # Unified surface: no messages.create for text + assert messages.created == [] + + async def test_empty_stream_returns_empty_string( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + streaming, _ = fake_adk + final = await stream_langgraph_events(_make_stream([]), TASK_ID) + assert final == "" + assert streaming.contexts == [] + + async def test_tool_call_posted_via_streaming_context( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + """Unified surface: tool calls go through streaming_task_message_context, + not adk.messages.create. The context is opened and immediately closed + (no deltas) so the initial_content is the tool request.""" + from langchain_core.messages import AIMessage + + streaming, messages = fake_adk + tc = {"id": "call_1", "name": "get_weather", "args": {"city": "Paris"}} + ai_msg = AIMessage(content="", tool_calls=[tc]) + stream = _make_stream([("updates", {"agent": {"messages": [ai_msg]}})]) + + await stream_langgraph_events(stream, TASK_ID) + + # Unified surface: tool messages go via streaming_task_message_context + assert len(streaming.contexts) == 1 + assert messages.created == [], "Unified surface uses streaming_task_message_context, not messages.create" + + from agentex.types.tool_request_content import ToolRequestContent + + content = streaming.contexts[0].initial_content + assert isinstance(content, ToolRequestContent) + assert content.tool_call_id == "call_1" + assert content.name == "get_weather" + assert content.arguments == {"city": "Paris"} + # Full messages close immediately (no delta updates) + assert streaming.contexts[0].closed is True + assert streaming.contexts[0].updates == [] + + async def test_tool_response_posted_via_streaming_context( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + """Unified surface: tool responses go through streaming_task_message_context.""" + from langchain_core.messages import ToolMessage + + streaming, messages = fake_adk + tool_msg = ToolMessage(content="Sunny, 72F", tool_call_id="call_1", name="get_weather") + stream = _make_stream([("updates", {"tools": {"messages": [tool_msg]}})]) + + await stream_langgraph_events(stream, TASK_ID) + + assert len(streaming.contexts) == 1 + assert messages.created == [] + + from agentex.types.tool_response_content import ToolResponseContent + + content = streaming.contexts[0].initial_content + assert isinstance(content, ToolResponseContent) + assert content.tool_call_id == "call_1" + assert content.name == "get_weather" + assert content.content == "Sunny, 72F" + assert streaming.contexts[0].closed is True + + async def test_multi_step_text_then_tool_then_text_last_segment( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + """Unified surface: final_text uses last-segment semantics. + + auto_send resets final_text_parts when a new Start(TextContent) is seen, + so multi-step turns (text -> tool -> text) return only the LAST text segment. + Both text contexts are still opened and streamed to Redis; only the + return value is last-segment. This matches stream_pydantic_ai_events. + """ + from langchain_core.messages import AIMessage, ToolMessage, AIMessageChunk + + streaming, messages = fake_adk + chunk1 = AIMessageChunk(content="Looking up...") + ai_msg1 = AIMessage(content="Looking up...", tool_calls=[{"id": "c1", "name": "search", "args": {}}]) + tool_msg = ToolMessage(content="result", tool_call_id="c1", name="search") + chunk2 = AIMessageChunk(content="Found it!") + ai_msg2 = AIMessage(content="Found it!") + + stream = _make_stream( + [ + ("messages", (chunk1, {})), + ("updates", {"agent": {"messages": [ai_msg1]}}), + ("updates", {"tools": {"messages": [tool_msg]}}), + ("messages", (chunk2, {})), + ("updates", {"agent": {"messages": [ai_msg2]}}), + ] + ) + + final = await stream_langgraph_events(stream, TASK_ID) + + # Last segment only — first text segment is NOT in final_text + assert final == "Found it!" + # Two text streaming contexts (one per text segment) — both streamed to Redis + text_ctxs = [c for c in streaming.contexts if isinstance(c.initial_content, TextContent)] + assert len(text_ctxs) == 2 + assert all(ctx.closed for ctx in text_ctxs) + # Tool request + tool response via streaming_task_message_context (not messages.create) + assert messages.created == [] + + async def test_context_closed_on_exception(self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule]) -> None: + from langchain_core.messages import AIMessageChunk + + streaming, _ = fake_adk + + async def _boom(): + chunk = AIMessageChunk(content="partial") + yield ("messages", (chunk, {})) + raise RuntimeError("upstream exploded") + + with pytest.raises(RuntimeError, match="upstream exploded"): + await stream_langgraph_events(_boom(), TASK_ID) + + assert streaming.contexts[0].closed is True diff --git a/tests/lib/adk/test_langgraph_sync.py b/tests/lib/adk/test_langgraph_sync.py new file mode 100644 index 000000000..9e8c6e4f0 --- /dev/null +++ b/tests/lib/adk/test_langgraph_sync.py @@ -0,0 +1,393 @@ +"""Tests for the sync LangGraph -> Agentex path. + +Covers: +- The bare converter ``convert_langgraph_to_agentex_events``: + * Basic text, tool call, and tool response emission + * on_final_ai_message callback for usage capture +- The unified sync (HTTP ACP) path ``UnifiedEmitter.yield_turn(LangGraphTurn(...))``: + * Passthrough: yield_turn events equal LangGraphTurn(stream).events + * Span derivation from Full tool events with a fake tracer + +NOTE: langchain_core imports must be deferred to test-function scope because +conftest.py stubs out ``langchain_core.messages`` with MagicMock for ADK +package-level tests. The real classes are imported lazily inside each test. +""" + +from __future__ import annotations + +import sys +from typing import Any, AsyncIterator +from datetime import datetime, timezone +from dataclasses import field, dataclass + +import pytest + +from agentex.lib.core.harness.tracer import SpanTracer +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.types.task_message_update import ( + StreamTaskMessageFull, +) +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.lib.adk._modules._langgraph_sync import convert_langgraph_to_agentex_events +from agentex.lib.adk._modules._langgraph_turn import LangGraphTurn + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _collect(stream: AsyncIterator[Any]) -> list[Any]: + return [e async for e in stream] + + +def _make_stream(events: list[tuple[str, Any]]) -> AsyncIterator[tuple[str, Any]]: + async def _gen(): + for e in events: + yield e + + return _gen() + + +# --------------------------------------------------------------------------- +# Remove the conftest stubs for langchain_core so real classes are used +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _real_langchain_core(): + """Remove conftest MagicMock stubs so real langchain_core types are used.""" + stub_keys = [k for k in sys.modules if k.startswith("langchain_core") or k.startswith("langgraph")] + saved = {k: sys.modules.pop(k) for k in stub_keys} + # Re-import the real modules + import importlib + + importlib.import_module("langchain_core.messages") + yield + # Restore stubs after the test + sys.modules.update(saved) + + +class TestTextStreaming: + async def test_plain_text_emits_start_delta_done(self): + from langchain_core.messages import AIMessage, AIMessageChunk + + chunk = AIMessageChunk(content="Hello, world!") + events = [ + ("messages", (chunk, {})), + ("updates", {"agent": {"messages": [AIMessage(content="Hello, world!")]}}), + ] + out = await _collect(convert_langgraph_to_agentex_events(_make_stream(events))) + types = [type(e).__name__ for e in out] + assert "StreamTaskMessageStart" in types + assert "StreamTaskMessageDelta" in types + assert "StreamTaskMessageDone" in types + + async def test_empty_chunk_content_is_skipped(self): + from langchain_core.messages import AIMessageChunk + + chunk = AIMessageChunk(content="") + events = [("messages", (chunk, {}))] + out = await _collect(convert_langgraph_to_agentex_events(_make_stream(events))) + assert out == [] + + async def test_reasoning_block_start_wraps_reasoning_content(self): + """A Responses-API reasoning block opens a Start wrapping ReasoningContent, + not TextContent (the deltas are ReasoningContentDelta).""" + from langchain_core.messages import AIMessageChunk + + from agentex.types.reasoning_content import ReasoningContent + from agentex.types.task_message_update import StreamTaskMessageDelta, StreamTaskMessageStart + from agentex.types.reasoning_content_delta import ReasoningContentDelta + + chunk = AIMessageChunk( + content=[{"type": "reasoning", "summary": [{"type": "summary_text", "text": "thinking hard"}]}] + ) + events = [("messages", (chunk, {}))] + out = await _collect(convert_langgraph_to_agentex_events(_make_stream(events))) + starts = [e for e in out if isinstance(e, StreamTaskMessageStart)] + assert len(starts) == 1 + assert isinstance(starts[0].content, ReasoningContent), "reasoning Start must wrap ReasoningContent" + # `style` must be a non-null MessageStyle: the AgentEx server's + # StreamTaskMessageStartEntity rejects `reasoning.style=None` (enum), which + # would kill the stream. Match the conformance fixture's canonical value. + assert starts[0].content.style == "active", "reasoning Start must set a non-null style ('active')" + # Pull content_delta inside the comprehension so the isinstance narrows the + # delta union (narrowing would not survive a later attribute access). + reasoning_delta_texts = [ + e.delta.content_delta + for e in out + if isinstance(e, StreamTaskMessageDelta) and isinstance(e.delta, ReasoningContentDelta) + ] + assert reasoning_delta_texts == ["thinking hard"] + + +class TestToolCallEmission: + async def test_tool_call_emits_full_message(self): + from langchain_core.messages import AIMessage + + tc = {"id": "call_1", "name": "get_weather", "args": {"city": "Paris"}} + ai_msg = AIMessage(content="", tool_calls=[tc]) + events = [("updates", {"agent": {"messages": [ai_msg]}})] + out = await _collect(convert_langgraph_to_agentex_events(_make_stream(events))) + assert len(out) == 1 + assert isinstance(out[0], StreamTaskMessageFull) + content = out[0].content + assert isinstance(content, ToolRequestContent) + assert content.tool_call_id == "call_1" + assert content.name == "get_weather" + assert content.arguments == {"city": "Paris"} + assert content.author == "agent" + + async def test_tool_response_emits_full_message(self): + from langchain_core.messages import ToolMessage + + tool_msg = ToolMessage(content="Sunny, 72F", tool_call_id="call_1", name="get_weather") + events = [("updates", {"tools": {"messages": [tool_msg]}})] + out = await _collect(convert_langgraph_to_agentex_events(_make_stream(events))) + assert len(out) == 1 + assert isinstance(out[0], StreamTaskMessageFull) + content = out[0].content + assert isinstance(content, ToolResponseContent) + assert content.tool_call_id == "call_1" + assert content.name == "get_weather" + assert content.content == "Sunny, 72F" + assert content.author == "agent" + + +class TestOnFinalAiMessageCallback: + async def test_callback_called_for_ai_message_in_agent_node(self): + from langchain_core.messages import AIMessage + + captured: list[Any] = [] + ai_msg = AIMessage(content="Hello!") + + events = [("updates", {"agent": {"messages": [ai_msg]}})] + await _collect(convert_langgraph_to_agentex_events(_make_stream(events), on_final_ai_message=captured.append)) + assert len(captured) == 1 + assert captured[0] is ai_msg + + async def test_callback_not_called_for_tool_messages(self): + from langchain_core.messages import ToolMessage + + captured: list[Any] = [] + tool_msg = ToolMessage(content="result", tool_call_id="c1", name="t") + + events = [("updates", {"tools": {"messages": [tool_msg]}})] + await _collect(convert_langgraph_to_agentex_events(_make_stream(events), on_final_ai_message=captured.append)) + assert captured == [] + + async def test_callback_receives_usage_metadata(self): + from langchain_core.messages import AIMessage + + captured: list[Any] = [] + usage = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15} + ai_msg = AIMessage(content="Answer.", usage_metadata=usage) + + events = [("updates", {"agent": {"messages": [ai_msg]}})] + await _collect(convert_langgraph_to_agentex_events(_make_stream(events), on_final_ai_message=captured.append)) + assert len(captured) == 1 + assert captured[0].usage_metadata == usage + + async def test_no_callback_is_noop(self): + from langchain_core.messages import AIMessage + + ai_msg = AIMessage(content="Hello!") + events = [("updates", {"agent": {"messages": [ai_msg]}})] + out = await _collect(convert_langgraph_to_agentex_events(_make_stream(events))) + assert isinstance(out, list) + + async def test_callback_called_multiple_times_for_multi_step(self): + from langchain_core.messages import AIMessage + + captured: list[Any] = [] + ai_msg_1 = AIMessage(content="Step 1") + ai_msg_2 = AIMessage(content="Step 2") + + events = [ + ("updates", {"agent": {"messages": [ai_msg_1]}}), + ("updates", {"agent": {"messages": [ai_msg_2]}}), + ] + await _collect(convert_langgraph_to_agentex_events(_make_stream(events), on_final_ai_message=captured.append)) + assert len(captured) == 2 + assert captured[0] is ai_msg_1 + assert captured[1] is ai_msg_2 + + async def test_callback_called_after_tool_call_events_yielded(self): + """The callback fires after all events for that AIMessage are yielded.""" + from langchain_core.messages import AIMessage + + yield_order: list[str] = [] + + async def _gen(): + tc = {"id": "c1", "name": "t", "args": {}} + ai_msg = AIMessage(content="", tool_calls=[tc]) + yield ("updates", {"agent": {"messages": [ai_msg]}}) + + def _cb(msg): + yield_order.append("callback") + + async for _ in convert_langgraph_to_agentex_events(_gen(), on_final_ai_message=_cb): + yield_order.append("event") + + # The tool call Full event is emitted before the callback fires + assert yield_order.index("event") < yield_order.index("callback") + + +# --------------------------------------------------------------------------- +# Unified sync path: LangGraphTurn + UnifiedEmitter.yield_turn +# +# Verifies the sync (HTTP ACP) delivery surface: +# 1. Passthrough: events from emitter.yield_turn(LangGraphTurn(stream)) equal +# LangGraphTurn(stream).events collected directly. +# 2. Span derivation: with trace_id + fake tracer, tool spans are derived from +# the event stream. +# --------------------------------------------------------------------------- + + +@dataclass +class _FakeTracingBackend: + spans_started: list[dict[str, Any]] = field(default_factory=list) + spans_ended: list[str] = field(default_factory=list) + + async def start_span(self, **kw) -> Any: + from agentex.types.span import Span + + sp = Span( + id=f"span-{len(self.spans_started) + 1}", + trace_id=kw.get("trace_id", "trace1"), + name=kw.get("name", ""), + start_time=datetime.now(tz=timezone.utc), + ) + self.spans_started.append(kw) + return sp + + async def end_span(self, *, trace_id: str, span: Any) -> None: + self.spans_ended.append(span.id if span else "") + + +class TestUnifiedSyncPathPassthrough: + async def test_yield_turn_events_equal_direct_events(self): + """Events from emitter.yield_turn(LangGraphTurn(stream)) must equal + LangGraphTurn(stream).events collected directly — the emitter must not + add, drop, or reorder events in yield mode.""" + from langchain_core.messages import AIMessage, AIMessageChunk + + chunk = AIMessageChunk(content="Hello!") + ai_msg = AIMessage(content="Hello!") + + events_raw = [ + ("messages", (chunk, {})), + ("updates", {"agent": {"messages": [ai_msg]}}), + ] + + direct = [e async for e in LangGraphTurn(_make_stream(events_raw)).events] + + emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) + via_emitter = [e async for e in emitter.yield_turn(LangGraphTurn(_make_stream(events_raw)))] + + assert len(direct) == len(via_emitter), "yield_turn must not add or drop events relative to direct iteration" + for a, b in zip(direct, via_emitter, strict=True): + assert type(a) == type(b), f"Event type mismatch: {type(a).__name__} vs {type(b).__name__}" + + async def test_yield_turn_passes_all_event_types(self): + """Start, Delta, Done, Full — each type is preserved.""" + from langchain_core.messages import AIMessage, AIMessageChunk + + chunk = AIMessageChunk(content="hi") + tc = {"id": "c1", "name": "t", "args": {}} + ai_msg = AIMessage(content="hi", tool_calls=[tc]) + + events_raw = [ + ("messages", (chunk, {})), + ("updates", {"agent": {"messages": [ai_msg]}}), + ] + emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) + out = [e async for e in emitter.yield_turn(LangGraphTurn(_make_stream(events_raw)))] + types = {type(e).__name__ for e in out} + # text chunk emits Start + Delta + assert "StreamTaskMessageStart" in types + assert "StreamTaskMessageDelta" in types + # tool call emits Full + assert "StreamTaskMessageFull" in types + + async def test_empty_stream_yields_no_events(self): + emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) + out = [e async for e in emitter.yield_turn(LangGraphTurn(_make_stream([])))] + assert out == [] + + +class TestUnifiedSyncPathSpanDerivation: + @pytest.fixture + def fake_tracer(self): + backend = _FakeTracingBackend() + tracer = SpanTracer( + trace_id="trace1", + parent_span_id=None, + task_id="t", + tracing=backend, # type: ignore[arg-type] + ) + return tracer, backend + + async def test_tool_span_derived_from_full_events(self, fake_tracer): + """SpanDeriver handles Full tool events for LangGraph. + + Full(ToolRequestContent) opens a tool span keyed by tool_call_id; + Full(ToolResponseContent) closes it, aligning LangGraph's Full-event + path with the Start+Done harnesses (pydantic-ai, openai-agents). + """ + from langchain_core.messages import AIMessage, ToolMessage + + tracer, backend = fake_tracer + tc = {"id": "c1", "name": "get_weather", "args": {"city": "Paris"}} + ai_msg = AIMessage(content="", tool_calls=[tc]) + tool_msg = ToolMessage(content="Sunny", tool_call_id="c1", name="get_weather") + + events_raw = [ + ("updates", {"agent": {"messages": [ai_msg]}}), + ("updates", {"tools": {"messages": [tool_msg]}}), + ] + + emitter = UnifiedEmitter(task_id="t", trace_id="trace1", parent_span_id=None, tracer=tracer) + _ = [e async for e in emitter.yield_turn(LangGraphTurn(_make_stream(events_raw)))] + + assert len(backend.spans_started) == 1, "Full(ToolRequestContent) opens one tool span" + started = backend.spans_started[0] + assert started["name"] == "get_weather" + assert started["input"] == {"city": "Paris"} + + async def test_no_spans_when_no_tool_calls(self, fake_tracer): + """yield_turn with tracer but no tool calls emits no spans.""" + from langchain_core.messages import AIMessage, AIMessageChunk + + tracer, backend = fake_tracer + chunk = AIMessageChunk(content="Hello!") + ai_msg = AIMessage(content="Hello!") + + events_raw = [ + ("messages", (chunk, {})), + ("updates", {"agent": {"messages": [ai_msg]}}), + ] + + emitter = UnifiedEmitter(task_id="t", trace_id="trace1", parent_span_id=None, tracer=tracer) + _ = [e async for e in emitter.yield_turn(LangGraphTurn(_make_stream(events_raw)))] + + assert backend.spans_started == [], "No tool spans when there are no tool calls" + + async def test_tracer_none_means_no_spans(self): + """With tracer=False, no spans should be emitted.""" + from langchain_core.messages import AIMessage, ToolMessage + + tc = {"id": "c1", "name": "t", "args": {}} + ai_msg = AIMessage(content="", tool_calls=[tc]) + tool_msg = ToolMessage(content="ok", tool_call_id="c1", name="t") + + events_raw = [ + ("updates", {"agent": {"messages": [ai_msg]}}), + ("updates", {"tools": {"messages": [tool_msg]}}), + ] + + emitter = UnifiedEmitter(task_id="t", trace_id="trace1", parent_span_id=None, tracer=False) + _ = [e async for e in emitter.yield_turn(LangGraphTurn(_make_stream(events_raw)))] + # No assertion on spans since tracer=False means emitter.tracer is None + assert emitter.tracer is None diff --git a/tests/lib/adk/test_langgraph_turn.py b/tests/lib/adk/test_langgraph_turn.py new file mode 100644 index 000000000..23aa34ba3 --- /dev/null +++ b/tests/lib/adk/test_langgraph_turn.py @@ -0,0 +1,265 @@ +"""Tests for LangGraphTurn and langgraph_usage_to_turn_usage.""" + +from __future__ import annotations + +import sys +from typing import Any + +import pytest + +from agentex.lib.core.harness.types import TurnUsage +from agentex.lib.adk._modules._langgraph_turn import LangGraphTurn, langgraph_usage_to_turn_usage + +# --------------------------------------------------------------------------- +# Remove conftest stubs so real langchain_core types are used +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _real_langchain_core(): + stub_keys = [k for k in sys.modules if k.startswith("langchain_core") or k.startswith("langgraph")] + saved = {k: sys.modules.pop(k) for k in stub_keys} + import importlib + + importlib.import_module("langchain_core.messages") + yield + sys.modules.update(saved) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_stream(events: list[tuple[str, Any]]): + async def _gen(): + for e in events: + yield e + + return _gen() + + +async def _drain(turn: LangGraphTurn) -> list[Any]: + return [e async for e in turn.events] + + +# --------------------------------------------------------------------------- +# langgraph_usage_to_turn_usage +# --------------------------------------------------------------------------- + + +class TestLangGraphUsageToTurnUsage: + def test_none_usage_returns_empty_turn_usage(self): + result = langgraph_usage_to_turn_usage(None, model="gpt-4") + assert result == TurnUsage(model="gpt-4") + + def test_basic_token_fields_mapped(self): + usage = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15} + result = langgraph_usage_to_turn_usage(usage, model="gpt-4") + assert result.input_tokens == 10 + assert result.output_tokens == 5 + assert result.total_tokens == 15 + assert result.model == "gpt-4" + + def test_zero_output_tokens_preserved_not_coerced_to_none(self): + """Real zero counts must be preserved as 0, not None.""" + usage = {"input_tokens": 10, "output_tokens": 0, "total_tokens": 10} + result = langgraph_usage_to_turn_usage(usage, model=None) + assert result.output_tokens == 0 + + def test_cache_read_mapped_to_cached_input_tokens(self): + usage = { + "input_tokens": 20, + "output_tokens": 5, + "total_tokens": 25, + "input_token_details": {"cache_read": 8}, + } + result = langgraph_usage_to_turn_usage(usage, model=None) + assert result.cached_input_tokens == 8 + + def test_reasoning_mapped_to_reasoning_tokens(self): + usage = { + "input_tokens": 10, + "output_tokens": 15, + "total_tokens": 25, + "output_token_details": {"reasoning": 6}, + } + result = langgraph_usage_to_turn_usage(usage, model=None) + assert result.reasoning_tokens == 6 + + def test_missing_optional_fields_are_none(self): + usage = {"input_tokens": 5, "output_tokens": 3, "total_tokens": 8} + result = langgraph_usage_to_turn_usage(usage, model=None) + assert result.cached_input_tokens is None + assert result.reasoning_tokens is None + + def test_full_usage_object(self): + usage = { + "input_tokens": 100, + "output_tokens": 50, + "total_tokens": 150, + "input_token_details": {"cache_read": 30}, + "output_token_details": {"reasoning": 20}, + } + result = langgraph_usage_to_turn_usage(usage, model="claude-3-5-sonnet") + assert result == TurnUsage( + model="claude-3-5-sonnet", + input_tokens=100, + output_tokens=50, + total_tokens=150, + cached_input_tokens=30, + reasoning_tokens=20, + ) + + def test_model_none_is_preserved(self): + result = langgraph_usage_to_turn_usage({"input_tokens": 1}, model=None) + assert result.model is None + + def test_empty_input_token_details_does_not_crash(self): + usage = {"input_tokens": 5, "input_token_details": {}} + result = langgraph_usage_to_turn_usage(usage, model=None) + assert result.cached_input_tokens is None + + def test_empty_output_token_details_does_not_crash(self): + usage = {"output_tokens": 5, "output_token_details": {}} + result = langgraph_usage_to_turn_usage(usage, model=None) + assert result.reasoning_tokens is None + + +# --------------------------------------------------------------------------- +# LangGraphTurn +# --------------------------------------------------------------------------- + + +class TestLangGraphTurn: + async def test_events_yields_from_sync_converter(self): + from langchain_core.messages import AIMessage, AIMessageChunk + + chunk = AIMessageChunk(content="Hello!") + ai_msg = AIMessage(content="Hello!") + stream = _make_stream( + [ + ("messages", (chunk, {})), + ("updates", {"agent": {"messages": [ai_msg]}}), + ] + ) + turn = LangGraphTurn(stream) + events = await _drain(turn) + assert len(events) > 0 + + async def test_usage_is_empty_before_stream_consumed(self): + turn = LangGraphTurn(_make_stream([])) + # usage() before events consumed should return a default TurnUsage + usage = turn.usage() + assert isinstance(usage, TurnUsage) + + async def test_usage_captured_from_ai_message(self): + from langchain_core.messages import AIMessage + + usage_meta = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15} + ai_msg = AIMessage(content="Hi!", usage_metadata=usage_meta) + stream = _make_stream([("updates", {"agent": {"messages": [ai_msg]}})]) + turn = LangGraphTurn(stream, model="gpt-4") + await _drain(turn) + + usage = turn.usage() + assert usage.input_tokens == 10 + assert usage.output_tokens == 5 + assert usage.total_tokens == 15 + assert usage.model == "gpt-4" + + async def test_usage_accumulates_across_multiple_ai_messages(self): + """A multi-step turn (>1 LLM call) sums usage instead of keeping only the last.""" + from langchain_core.messages import AIMessage + + first = AIMessage( + content="thinking", + usage_metadata={ + "input_tokens": 10, + "output_tokens": 5, + "total_tokens": 15, + "input_token_details": {"cache_read": 2}, + "output_token_details": {"reasoning": 1}, + }, + ) + second = AIMessage( + content="answer", + usage_metadata={ + "input_tokens": 20, + "output_tokens": 7, + "total_tokens": 27, + "input_token_details": {"cache_read": 3}, + "output_token_details": {"reasoning": 4}, + }, + ) + stream = _make_stream( + [ + ("updates", {"agent": {"messages": [first]}}), + ("updates", {"agent": {"messages": [second]}}), + ] + ) + turn = LangGraphTurn(stream, model="gpt-4") + await _drain(turn) + + usage = turn.usage() + assert usage.input_tokens == 30 + assert usage.output_tokens == 12 + assert usage.total_tokens == 42 + assert usage.cached_input_tokens == 5 + assert usage.reasoning_tokens == 5 + assert usage.model == "gpt-4" + + async def test_usage_not_updated_when_no_usage_metadata(self): + from langchain_core.messages import AIMessage + + ai_msg = AIMessage(content="Hi!") + stream = _make_stream([("updates", {"agent": {"messages": [ai_msg]}})]) + turn = LangGraphTurn(stream, model="gpt-4") + await _drain(turn) + + usage = turn.usage() + assert usage == TurnUsage(model="gpt-4") + + async def test_usage_captures_cache_read_and_reasoning(self): + from langchain_core.messages import AIMessage + + usage_meta = { + "input_tokens": 100, + "output_tokens": 50, + "total_tokens": 150, + "input_token_details": {"cache_read": 30}, + "output_token_details": {"reasoning": 20}, + } + ai_msg = AIMessage(content="Result", usage_metadata=usage_meta) + stream = _make_stream([("updates", {"agent": {"messages": [ai_msg]}})]) + turn = LangGraphTurn(stream, model="claude-3-5-sonnet") + await _drain(turn) + + usage = turn.usage() + assert usage.cached_input_tokens == 30 + assert usage.reasoning_tokens == 20 + + async def test_harness_turn_protocol_conformance(self): + """LangGraphTurn satisfies the HarnessTurn Protocol.""" + from agentex.lib.core.harness.types import HarnessTurn + + turn = LangGraphTurn(_make_stream([])) + assert isinstance(turn, HarnessTurn), "LangGraphTurn must satisfy HarnessTurn Protocol" + + async def test_empty_stream_yields_no_events(self): + turn = LangGraphTurn(_make_stream([])) + events = await _drain(turn) + assert events == [] + + async def test_model_none_default(self): + turn = LangGraphTurn(_make_stream([])) + assert turn.usage().model is None + + async def test_model_passed_through_to_usage(self): + from langchain_core.messages import AIMessage + + ai_msg = AIMessage(content="ok", usage_metadata={"input_tokens": 1, "output_tokens": 0, "total_tokens": 1}) + stream = _make_stream([("updates", {"agent": {"messages": [ai_msg]}})]) + turn = LangGraphTurn(stream, model="my-model") + await _drain(turn) + assert turn.usage().model == "my-model" diff --git a/tests/lib/adk/test_messages_module.py b/tests/lib/adk/test_messages_module.py new file mode 100644 index 000000000..78ef0b424 --- /dev/null +++ b/tests/lib/adk/test_messages_module.py @@ -0,0 +1,120 @@ +"""Tests for MessagesModule's workflow.now() auto-injection on create/create_batch. + +Verifies that inside a Temporal workflow context, MessagesModule.create and +create_batch default `created_at` to workflow.now(), threading it through both +the activity dispatch branch and the direct service-call branch. Outside a +workflow, created_at remains None and the server's wall clock applies. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, patch + +import agentex.lib.adk._modules.messages as _messages_mod +from agentex.types.task_message import TaskMessage +from agentex.types.text_content import TextContent +from agentex.lib.adk._modules.messages import MessagesModule +from agentex.lib.core.services.adk.messages import MessagesService + +_FIXED_NOW = datetime(2026, 5, 13, 18, 30, 0, tzinfo=timezone.utc) + + +def _make_task_message() -> TaskMessage: + return TaskMessage( + id="m1", + task_id="t1", + content=TextContent(author="agent", content="hi", format="markdown"), + streaming_status="DONE", + ) + + +def _make_module() -> tuple[AsyncMock, MessagesModule]: + mock_service = AsyncMock(spec=MessagesService) + module = MessagesModule(messages_service=mock_service) + return mock_service, module + + +class TestMessagesModuleCreate: + async def test_outside_workflow_does_not_inject_created_at(self) -> None: + mock_service, module = _make_module() + mock_service.create_message.return_value = _make_task_message() + + with patch.object(_messages_mod, "in_temporal_workflow", return_value=False): + await module.create( + task_id="t1", + content=TextContent(author="user", content="hi", format="markdown"), + ) + + kwargs = mock_service.create_message.call_args.kwargs + assert kwargs["created_at"] is None + + async def test_inside_workflow_auto_injects_workflow_now(self) -> None: + mock_service, module = _make_module() + mock_service.create_message.return_value = _make_task_message() + + # Stub the activity helper so we don't try to actually dispatch. + # Capture the params object so we can assert created_at. + captured: dict = {} + + async def fake_execute_activity(**call_kwargs): + captured.update(call_kwargs) + return _make_task_message() + + with patch.object(_messages_mod, "in_temporal_workflow", return_value=True), patch.object( + _messages_mod, "workflow_now_if_in_workflow", return_value=_FIXED_NOW + ), patch.object( + _messages_mod.ActivityHelpers, + "execute_activity", + side_effect=fake_execute_activity, + ): + await module.create( + task_id="t1", + content=TextContent(author="user", content="hi", format="markdown"), + ) + + params = captured["request"] + assert params.created_at == _FIXED_NOW + + async def test_caller_supplied_created_at_is_respected(self) -> None: + mock_service, module = _make_module() + mock_service.create_message.return_value = _make_task_message() + caller_ts = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + + # Caller already supplied a timestamp: don't overwrite. + with patch.object(_messages_mod, "in_temporal_workflow", return_value=False): + await module.create( + task_id="t1", + content=TextContent(author="user", content="hi", format="markdown"), + created_at=caller_ts, + ) + + kwargs = mock_service.create_message.call_args.kwargs + assert kwargs["created_at"] == caller_ts + + +class TestMessagesModuleCreateBatch: + async def test_inside_workflow_auto_injects_workflow_now(self) -> None: + mock_service, module = _make_module() + mock_service.create_messages_batch.return_value = [_make_task_message()] + + captured: dict = {} + + async def fake_execute_activity(**call_kwargs): + captured.update(call_kwargs) + return [_make_task_message()] + + with patch.object(_messages_mod, "in_temporal_workflow", return_value=True), patch.object( + _messages_mod, "workflow_now_if_in_workflow", return_value=_FIXED_NOW + ), patch.object( + _messages_mod.ActivityHelpers, + "execute_activity", + side_effect=fake_execute_activity, + ): + await module.create_batch( + task_id="t1", + contents=[TextContent(author="user", content="hi", format="markdown")], + ) + + params = captured["request"] + assert params.created_at == _FIXED_NOW diff --git a/tests/lib/adk/test_messages_service.py b/tests/lib/adk/test_messages_service.py new file mode 100644 index 000000000..9b18324dc --- /dev/null +++ b/tests/lib/adk/test_messages_service.py @@ -0,0 +1,98 @@ +"""Tests for MessagesService created_at forwarding to the SDK client.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import Mock, AsyncMock + +from agentex._types import omit +from agentex.types.task_message import TaskMessage +from agentex.types.text_content import TextContent +from agentex.lib.core.services.adk.messages import MessagesService + +_TS = datetime(2026, 5, 13, 18, 30, 0, tzinfo=timezone.utc) + + +def _make_task_message() -> TaskMessage: + return TaskMessage( + id="m1", + task_id="t1", + content=TextContent(author="agent", content="hi", format="markdown"), + streaming_status="DONE", + ) + + +def _mock_span(): + span = Mock() + span.output = None + + async def __aenter__(_self): + return span + + async def __aexit__(_self, *args): + return None + + span.__aenter__ = __aenter__ + span.__aexit__ = __aexit__ + return span + + +def _make_service() -> tuple[AsyncMock, MessagesService]: + client = AsyncMock() + streaming = AsyncMock() + tracer = Mock() + trace = Mock() + trace.span.return_value = _mock_span() + tracer.trace.return_value = trace + svc = MessagesService( + agentex_client=client, + streaming_service=streaming, + tracer=tracer, + ) + return client, svc + + +class TestCreateMessageForwardsCreatedAt: + async def test_forwards_when_provided(self) -> None: + client, svc = _make_service() + client.messages.create.return_value = _make_task_message() + + await svc.create_message( + task_id="t1", + content=TextContent(author="user", content="hi", format="markdown"), + emit_updates=False, + created_at=_TS, + ) + + kwargs = client.messages.create.call_args.kwargs + assert kwargs["created_at"] == _TS + + async def test_omits_when_none(self) -> None: + client, svc = _make_service() + client.messages.create.return_value = _make_task_message() + + await svc.create_message( + task_id="t1", + content=TextContent(author="user", content="hi", format="markdown"), + emit_updates=False, + ) + + kwargs = client.messages.create.call_args.kwargs + # The SDK uses an `omit` sentinel for "leave it to the server". + assert kwargs["created_at"] is omit + + +class TestBatchForwardsCreatedAt: + async def test_forwards_when_provided(self) -> None: + client, svc = _make_service() + client.messages.batch.create.return_value = [_make_task_message()] + + await svc.create_messages_batch( + task_id="t1", + contents=[TextContent(author="user", content="hi", format="markdown")], + emit_updates=False, + created_at=_TS, + ) + + kwargs = client.messages.batch.create.call_args.kwargs + assert kwargs["created_at"] == _TS diff --git a/tests/lib/adk/test_openai_sync.py b/tests/lib/adk/test_openai_sync.py new file mode 100644 index 000000000..de2a61db8 --- /dev/null +++ b/tests/lib/adk/test_openai_sync.py @@ -0,0 +1,189 @@ +"""Tests for ``convert_openai_to_agentex_events`` and its helpers. + +Focused on three previously-broken behaviors on the sync OpenAI converter: + +- ``_safe_parse_arguments`` never raises on malformed/non-dict JSON (a bad + tool-args string must not abort the whole turn). +- Every streamed item — text AND reasoning — is closed with a matching + ``StreamTaskMessageDone`` (reasoning messages used to hang open). +- Each new text ``item_id`` gets a fresh index, so a final answer cannot + collide with the preceding reasoning message on reasoning-model streams. +""" + +import types as _types + +import pytest +from openai.types.responses import ResponseTextDeltaEvent, ResponseOutputItemDoneEvent +from openai.types.responses.response_output_message import ResponseOutputMessage +from openai.types.responses.response_reasoning_item import ResponseReasoningItem +from openai.types.responses.response_reasoning_text_delta_event import ResponseReasoningTextDeltaEvent + +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.lib.adk._modules._openai_sync import ( + _safe_parse_arguments, + convert_openai_to_agentex_events, +) + +# --------------------------------------------------------------------------- +# _safe_parse_arguments +# --------------------------------------------------------------------------- + + +def test_safe_parse_arguments_valid_dict_json(): + assert _safe_parse_arguments('{"a": 1}') == {"a": 1} + + +def test_safe_parse_arguments_empty_and_none(): + assert _safe_parse_arguments("") == {} + assert _safe_parse_arguments(None) == {} + + +def test_safe_parse_arguments_passthrough_dict(): + d = {"already": "dict"} + assert _safe_parse_arguments(d) is d + + +def test_safe_parse_arguments_malformed_preserved_not_raised(): + # A truncated / malformed payload must be preserved, never raise — raising + # here would abort the whole turn before later output is delivered. + assert _safe_parse_arguments('{"a": ') == {"raw": '{"a": '} + + +def test_safe_parse_arguments_non_dict_json_wrapped(): + # Valid JSON that isn't an object is wrapped so the result stays a dict. + assert _safe_parse_arguments("[1, 2]") == {"value": [1, 2]} + assert _safe_parse_arguments("42") == {"value": 42} + + +def test_safe_parse_arguments_non_string_non_dict_always_returns_dict(): + # A provider tool may pass arguments as a list / scalar / SDK object rather + # than a JSON string. The result must still be a dict so ToolRequestContent + # (arguments: Dict[str, object]) accepts it instead of raising. + assert _safe_parse_arguments([1, 2]) == {"value": [1, 2]} + assert _safe_parse_arguments(7) == {"value": 7} + + class _Args: + def model_dump(self): + return {"q": "hi"} + + assert _safe_parse_arguments(_Args()) == {"q": "hi"} + + # An SDK object whose model_dump is not a dict still degrades to a dict. + class _BadDump: + def model_dump(self): + return ["not", "a", "dict"] + + bad = _BadDump() + assert _safe_parse_arguments(bad) == {"value": bad} + + +# --------------------------------------------------------------------------- +# convert_openai_to_agentex_events — reasoning + text sequencing +# --------------------------------------------------------------------------- + + +def _raw(data): + return _types.SimpleNamespace(type="raw_response_event", data=data) + + +async def _stream(events): + for e in events: + yield e + + +async def _collect(events): + return [e async for e in convert_openai_to_agentex_events(_stream(events))] + + +@pytest.mark.asyncio +async def test_reasoning_item_emits_done(): + """A completed reasoning item must yield a matching Done (it used to be skipped).""" + events = [ + _raw( + ResponseReasoningTextDeltaEvent( + type="response.reasoning_text.delta", + item_id="r1", + content_index=0, + delta="thinking", + output_index=0, + sequence_number=1, + ) + ), + _raw( + ResponseOutputItemDoneEvent( + type="response.output_item.done", + item=ResponseReasoningItem(id="r1", type="reasoning", summary=[]), + output_index=0, + sequence_number=2, + ) + ), + ] + out = await _collect(events) + + starts = [e for e in out if isinstance(e, StreamTaskMessageStart)] + dones = [e for e in out if isinstance(e, StreamTaskMessageDone)] + assert len(starts) == 1 + # The reasoning message is now closed instead of hanging open. + assert [d.index for d in dones] == [starts[0].index] + + +@pytest.mark.asyncio +async def test_reasoning_then_text_use_distinct_indices(): + """Final answer text must not reuse the reasoning message's index.""" + events = [ + _raw( + ResponseReasoningTextDeltaEvent( + type="response.reasoning_text.delta", + item_id="r1", + content_index=0, + delta="thinking", + output_index=0, + sequence_number=1, + ) + ), + _raw( + ResponseOutputItemDoneEvent( + type="response.output_item.done", + item=ResponseReasoningItem(id="r1", type="reasoning", summary=[]), + output_index=0, + sequence_number=2, + ) + ), + _raw( + ResponseTextDeltaEvent( + type="response.output_text.delta", + item_id="t1", + content_index=0, + delta="answer", + output_index=1, + sequence_number=3, + logprobs=[], + ) + ), + _raw( + ResponseOutputItemDoneEvent( + type="response.output_item.done", + item=ResponseOutputMessage(id="t1", type="message", role="assistant", status="completed", content=[]), + output_index=1, + sequence_number=4, + ) + ), + ] + out = await _collect(events) + + starts = [e for e in out if isinstance(e, StreamTaskMessageStart)] + assert len(starts) == 2 + reasoning_index, text_index = starts[0].index, starts[1].index + assert reasoning_index != text_index + + # Text deltas route to the text index, not the reasoning index. + text_deltas = [e for e in out if isinstance(e, StreamTaskMessageDelta) and e.delta.type == "text"] + assert text_deltas and all(d.index == text_index for d in text_deltas) + + # Both messages are closed on their own index. + done_indices = sorted(e.index for e in out if isinstance(e, StreamTaskMessageDone)) + assert done_indices == sorted({reasoning_index, text_index}) diff --git a/tests/lib/adk/test_pydantic_ai_async.py b/tests/lib/adk/test_pydantic_ai_async.py new file mode 100644 index 000000000..4ab468152 --- /dev/null +++ b/tests/lib/adk/test_pydantic_ai_async.py @@ -0,0 +1,776 @@ +"""Tests for the async Pydantic AI -> Agentex streaming helper. + +Unlike the sync converter (which yields ``StreamTaskMessage*`` events for the +caller to forward over HTTP), the async helper publishes deltas to Redis +through ``adk.streaming.streaming_task_message_context`` and full messages +through ``adk.messages.create``. These tests substitute both with in-memory +fakes so we can assert exactly what was published without touching Redis or +the AgentEx server. +""" + +from __future__ import annotations + +from typing import Any, AsyncIterator +from dataclasses import field, dataclass + +import pytest +from pydantic_ai.messages import ( + TextPart, + PartEndEvent, + ThinkingPart, + ToolCallPart, + TextPartDelta, + PartDeltaEvent, + PartStartEvent, + ToolReturnPart, + RetryPromptPart, + ThinkingPartDelta, + FunctionToolResultEvent, +) + +from agentex.types.task_message import TaskMessage +from agentex.types.text_content import TextContent +from agentex.types.reasoning_content import ReasoningContent +from agentex.types.task_message_delta import TextDelta +from agentex.types.task_message_update import StreamTaskMessageDelta +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.types.reasoning_content_delta import ReasoningContentDelta +from agentex.lib.adk._modules._pydantic_ai_turn import stream_pydantic_ai_events + +TASK_ID = "task_test" + + +async def _aiter(events: list[Any]) -> AsyncIterator[Any]: + for e in events: + yield e + + +@dataclass +class FakeContext: + """In-memory stand-in for ``StreamingTaskMessageContext``. + + Records the order of updates and whether ``close()`` was called. The + helper drives this manually via ``__aenter__`` / ``close``, so we don't + use it as an ``async with`` — we just track the calls. + """ + + initial_content: Any + task_message: TaskMessage + closed: bool = False + updates: list[StreamTaskMessageDelta] = field(default_factory=list) + + async def __aenter__(self) -> "FakeContext": + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> bool: + await self.close() + return False + + async def stream_update(self, update: StreamTaskMessageDelta) -> None: + if self.closed: + raise AssertionError("stream_update called after close — helper closed the wrong context") + self.updates.append(update) + + async def close(self) -> None: + self.closed = True + + +class FakeStreamingModule: + """Records every streaming context the helper opens, in order.""" + + def __init__(self) -> None: + self.contexts: list[FakeContext] = [] + + def streaming_task_message_context( + self, *, task_id: str, initial_content: Any, streaming_mode: str = "coalesced", created_at: Any = None + ) -> FakeContext: + tm = TaskMessage( + id=f"m{len(self.contexts) + 1}", + task_id=task_id, + content=initial_content, + streaming_status="IN_PROGRESS", + ) + ctx = FakeContext(initial_content=initial_content, task_message=tm) + self.contexts.append(ctx) + return ctx + + +class FakeMessagesModule: + """Records every ``adk.messages.create`` call.""" + + def __init__(self) -> None: + self.created: list[dict[str, Any]] = [] + + async def create(self, *, task_id: str, content: Any) -> TaskMessage: + self.created.append({"task_id": task_id, "content": content}) + return TaskMessage( + id=f"created-{len(self.created)}", + task_id=task_id, + content=content, + streaming_status="DONE", + ) + + +@pytest.fixture +def fake_adk(monkeypatch): + """Patches the lazy ``from agentex.lib import adk`` lookup inside the helper. + + Returns ``(streaming, messages)`` for assertions. + """ + from agentex.lib import adk as adk_module + + streaming = FakeStreamingModule() + messages = FakeMessagesModule() + monkeypatch.setattr(adk_module, "streaming", streaming) + monkeypatch.setattr(adk_module, "messages", messages) + return streaming, messages + + +def _text_deltas(ctx: FakeContext) -> list[str]: + out: list[str] = [] + for u in ctx.updates: + if isinstance(u.delta, TextDelta): + out.append(u.delta.text_delta or "") + return out + + +def _reasoning_deltas(ctx: FakeContext) -> list[str]: + out: list[str] = [] + for u in ctx.updates: + if isinstance(u.delta, ReasoningContentDelta): + out.append(u.delta.content_delta or "") + return out + + +class TestTextStreaming: + async def test_plain_text_opens_context_streams_deltas_and_closes( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + streaming, messages = fake_adk + events = [ + PartStartEvent(index=0, part=TextPart(content="")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="Hello")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta=", ")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="world!")), + PartEndEvent(index=0, part=TextPart(content="Hello, world!")), + ] + + final = await stream_pydantic_ai_events(_aiter(events), TASK_ID) + + assert len(streaming.contexts) == 1 + ctx = streaming.contexts[0] + assert isinstance(ctx.initial_content, TextContent) + assert ctx.initial_content.content == "" + assert _text_deltas(ctx) == ["Hello", ", ", "world!"] + assert ctx.closed is True, "PartEndEvent must close the streaming context" + assert messages.created == [], "Plain text must not emit standalone messages" + assert final == "Hello, world!" + + async def test_initial_content_in_part_start_is_streamed_as_delta( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + """Pydantic AI sometimes packs the first chunk inside ``PartStartEvent.part.content``. + + Agentex renders only Delta events as the message body, so the helper + must surface that initial chunk as a delta — otherwise the first token + is invisible to the UI. + """ + streaming, _ = fake_adk + events = [ + PartStartEvent(index=0, part=TextPart(content="Already there")), + PartEndEvent(index=0, part=TextPart(content="Already there")), + ] + final = await stream_pydantic_ai_events(_aiter(events), TASK_ID) + + ctx = streaming.contexts[0] + assert _text_deltas(ctx) == ["Already there"] + assert final == "Already there" + + async def test_returns_only_last_text_segment_in_multi_step_run( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + """Matches the documented contract / the LangGraph async helper's behavior.""" + streaming, _ = fake_adk + events = [ + PartStartEvent(index=0, part=TextPart(content="")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="Looking up...")), + PartEndEvent(index=0, part=TextPart(content="Looking up...")), + PartStartEvent(index=0, part=TextPart(content="")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="It's sunny.")), + PartEndEvent(index=0, part=TextPart(content="It's sunny.")), + ] + final = await stream_pydantic_ai_events(_aiter(events), TASK_ID) + + assert len(streaming.contexts) == 2, "Two text parts → two streaming contexts" + assert all(ctx.closed for ctx in streaming.contexts) + assert _text_deltas(streaming.contexts[0]) == ["Looking up..."] + assert _text_deltas(streaming.contexts[1]) == ["It's sunny."] + assert final == "It's sunny." + + +class TestThinkingStreaming: + async def test_thinking_opens_reasoning_context_with_reasoning_deltas( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + streaming, _ = fake_adk + events = [ + PartStartEvent(index=0, part=ThinkingPart(content="")), + PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta="step 1...")), + PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta=" step 2.")), + PartEndEvent(index=0, part=ThinkingPart(content="step 1... step 2.")), + ] + await stream_pydantic_ai_events(_aiter(events), TASK_ID) + + ctx = streaming.contexts[0] + assert isinstance(ctx.initial_content, ReasoningContent) + assert _reasoning_deltas(ctx) == ["step 1...", " step 2."] + assert ctx.closed is True + + async def test_thinking_initial_content_is_streamed_as_delta( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + streaming, _ = fake_adk + events = [ + PartStartEvent(index=0, part=ThinkingPart(content="seed reasoning")), + PartEndEvent(index=0, part=ThinkingPart(content="seed reasoning")), + ] + await stream_pydantic_ai_events(_aiter(events), TASK_ID) + + ctx = streaming.contexts[0] + assert _reasoning_deltas(ctx) == ["seed reasoning"] + + async def test_empty_thinking_delta_is_skipped( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + streaming, _ = fake_adk + events = [ + PartStartEvent(index=0, part=ThinkingPart(content="")), + PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta=None)), + PartEndEvent(index=0, part=ThinkingPart(content="")), + ] + await stream_pydantic_ai_events(_aiter(events), TASK_ID) + + ctx = streaming.contexts[0] + assert _reasoning_deltas(ctx) == [], "Empty ThinkingPartDelta must not publish a zero-length reasoning delta" + assert ctx.closed is True + + +class TestToolCallEmission: + async def test_tool_call_opens_streaming_context_with_identity( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + """Tool requests are delivered as a streaming context (Start+Delta+Done). + + auto_send delivers streamed tool-request messages natively + (Start+ToolRequestDelta+Done). The streaming context is opened + at the Start event with the initial ToolRequestContent (tool_call_id + + name + empty arguments), argument tokens are streamed as deltas, and the + context is closed on Done. + + This test uses a realistic pydantic-ai event sequence: args arrive as a + PartDeltaEvent fragment (the way OpenAI/Anthropic actually stream JSON + tool-call arguments). + """ + from pydantic_ai.messages import ToolCallPartDelta + + from agentex.types.tool_request_delta import ToolRequestDelta + + streaming, messages = fake_adk + events = [ + PartStartEvent( + index=1, + part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="c1"), + ), + # Realistic: args arrive as delta tokens (JSON string fragments). + PartDeltaEvent( + index=1, + delta=ToolCallPartDelta(args_delta='{"city":"Paris"}'), + ), + PartEndEvent( + index=1, + part=ToolCallPart(tool_name="get_weather", args='{"city":"Paris"}', tool_call_id="c1"), + ), + ] + await stream_pydantic_ai_events(_aiter(events), TASK_ID) + + # AGX1-373: tool messages arrive via streaming_task_message_context. + assert messages.created == [], "adk.messages.create must not be called" + assert len(streaming.contexts) == 1, "tool_request opens a streaming context" + ctx = streaming.contexts[0] + assert ctx.closed is True + content = ctx.initial_content + assert isinstance(content, ToolRequestContent) + assert content.tool_call_id == "c1" + assert content.name == "get_weather" + assert content.author == "agent" + # Streamed shape: initial_content has empty args (args come via delta) + assert content.arguments == {} + # The arg delta is delivered as a stream_update + assert len(ctx.updates) == 1 + assert isinstance(ctx.updates[0].delta, ToolRequestDelta) + assert ctx.updates[0].delta.arguments_delta == '{"city":"Paris"}' + + async def test_tool_call_with_dict_args_passes_through( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + """When args arrive pre-populated as a dict in PartStart, they're in initial_content.""" + streaming, messages = fake_adk + events = [ + PartStartEvent( + index=0, + part=ToolCallPart(tool_name="search", args={"q": "weather"}, tool_call_id="c"), + ), + PartEndEvent( + index=0, + part=ToolCallPart(tool_name="search", args={"q": "weather"}, tool_call_id="c"), + ), + ] + await stream_pydantic_ai_events(_aiter(events), TASK_ID) + + # AGX1-373: tool messages via streaming_task_message_context + assert messages.created == [] + assert len(streaming.contexts) == 1 + # Dict args present at PartStart land directly in initial_content.arguments + assert streaming.contexts[0].initial_content.arguments == {"q": "weather"} + assert streaming.contexts[0].updates == [], "no delta for pre-populated dict args" + + async def test_tool_call_with_invalid_json_args_surfaces_raw( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + """Malformed JSON arg delta is surfaced as a ToolRequestDelta with the raw string. + + The argument delta is delivered as-is by auto_send; the client-side + accumulator or the streaming backend handles malformed JSON gracefully. + + Parts-manager invariant: PartEnd.part is the accumulated snapshot; real + pydantic-ai conveys args via PartStart + PartDeltaEvent, so a + PartStart(None)+PartEnd(json) with no delta is not realizable. + """ + from pydantic_ai.messages import ToolCallPartDelta + + from agentex.types.tool_request_delta import ToolRequestDelta + + streaming, messages = fake_adk + events = [ + PartStartEvent( + index=0, + part=ToolCallPart(tool_name="t", args=None, tool_call_id="c"), + ), + # Malformed JSON arrives as a delta token. + PartDeltaEvent( + index=0, + delta=ToolCallPartDelta(args_delta="not-json{"), + ), + PartEndEvent( + index=0, + part=ToolCallPart(tool_name="t", args="not-json{", tool_call_id="c"), + ), + ] + await stream_pydantic_ai_events(_aiter(events), TASK_ID) + + # AGX1-373: tool messages via streaming_task_message_context + assert messages.created == [] + assert len(streaming.contexts) == 1 + ctx = streaming.contexts[0] + # Initial content has empty args (args come via delta) + assert ctx.initial_content.arguments == {} + # The malformed JSON is surfaced verbatim in the ToolRequestDelta + assert len(ctx.updates) == 1 + assert isinstance(ctx.updates[0].delta, ToolRequestDelta) + assert ctx.updates[0].delta.arguments_delta == "not-json{" + + async def test_tool_call_with_none_args_defaults_to_empty_dict( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + streaming, messages = fake_adk + events = [ + PartStartEvent( + index=0, + part=ToolCallPart(tool_name="t", args=None, tool_call_id="c"), + ), + PartEndEvent( + index=0, + part=ToolCallPart(tool_name="t", args=None, tool_call_id="c"), + ), + ] + await stream_pydantic_ai_events(_aiter(events), TASK_ID) + + # AGX1-373: tool messages via streaming_task_message_context + assert messages.created == [] + assert len(streaming.contexts) == 1 + assert streaming.contexts[0].initial_content.arguments == {} + assert streaming.contexts[0].updates == [], "no delta when args are absent" + + +class TestToolResult: + async def test_tool_return_emits_full_tool_response_message( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + # AGX1-373: tool responses arrive via streaming_task_message_context + # (open+close pair), NOT via adk.messages.create. + streaming, messages = fake_adk + events = [ + FunctionToolResultEvent( + part=ToolReturnPart(tool_name="get_weather", content="Sunny, 72F", tool_call_id="c1"), + ), + ] + await stream_pydantic_ai_events(_aiter(events), TASK_ID) + + assert messages.created == [], "adk.messages.create must not be called after reimplementation" + assert len(streaming.contexts) == 1 + ctx = streaming.contexts[0] + assert ctx.closed is True + content = ctx.initial_content + assert isinstance(content, ToolResponseContent) + assert content.tool_call_id == "c1" + assert content.name == "get_weather" + assert content.content == "Sunny, 72F" + assert content.author == "agent" + assert ctx.updates == [], "open+close only — no deltas for tool messages" + + async def test_tool_return_with_dict_content_preserves_structure( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + """Regression: structured tool results (dict / list / pydantic model) must + be preserved as structured data on ``ToolResponseContent.content``. + + The earlier ``str(content)`` path produced Python repr like + ``"{'temp': 72, 'sky': 'clear'}"`` — invalid JSON, unreadable in the UI, + and divergent from the sync converter which uses ``_tool_return_content`` + to return dicts as-is. + """ + streaming, messages = fake_adk + events = [ + FunctionToolResultEvent( + part=ToolReturnPart(tool_name="t", content={"temp": 72, "sky": "clear"}, tool_call_id="c"), + ), + ] + await stream_pydantic_ai_events(_aiter(events), TASK_ID) + + # AGX1-373: tool messages via streaming_task_message_context + assert messages.created == [] + assert len(streaming.contexts) == 1 + out = streaming.contexts[0].initial_content.content + assert out == {"temp": 72, "sky": "clear"}, ( + f"Expected the dict to survive verbatim; got {out!r}. " + "If this is a Python repr string, the helper regressed to str(content)." + ) + + async def test_tool_return_with_pydantic_model_content_uses_model_dump( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + """Pydantic model tool results must be serialized via ``model_dump()``, + not ``str(model)``.""" + from pydantic import BaseModel + + class WeatherResult(BaseModel): + temp: int + sky: str + + streaming, messages = fake_adk + events = [ + FunctionToolResultEvent( + part=ToolReturnPart( + tool_name="t", + content=WeatherResult(temp=72, sky="clear"), + tool_call_id="c", + ), + ), + ] + await stream_pydantic_ai_events(_aiter(events), TASK_ID) + + # AGX1-373: tool messages via streaming_task_message_context + assert messages.created == [] + assert len(streaming.contexts) == 1 + out = streaming.contexts[0].initial_content.content + assert out == {"temp": 72, "sky": "clear"} + + async def test_retry_prompt_part_surfaces_as_tool_response( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + streaming, messages = fake_adk + events = [ + FunctionToolResultEvent( + part=RetryPromptPart( + content="bad arguments", + tool_name="get_weather", + tool_call_id="c1", + ), + ), + ] + await stream_pydantic_ai_events(_aiter(events), TASK_ID) + + # AGX1-373: tool messages via streaming_task_message_context + assert messages.created == [] + assert len(streaming.contexts) == 1 + content = streaming.contexts[0].initial_content + assert isinstance(content, ToolResponseContent) + assert content.tool_call_id == "c1" + # RetryPromptPart.content stringifies to the error description + assert "bad arguments" in str(content.content) + + +class TestContextLifecycle: + async def test_text_then_tool_then_text_uses_separate_contexts_in_order( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + """End-to-end multi-step shape: text → tool call → tool result → more text. + + AGX1-373 envelope change: tool messages now arrive via + streaming_task_message_context (open+close pairs) instead of + adk.messages.create. All four message types open streaming contexts. + """ + streaming, messages = fake_adk + events = [ + # First model response: text + tool call. + PartStartEvent(index=0, part=TextPart(content="")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="Looking up...")), + PartEndEvent(index=0, part=TextPart(content="Looking up...")), + PartStartEvent( + index=1, + part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="c1"), + ), + PartEndEvent( + index=1, + part=ToolCallPart(tool_name="get_weather", args="{}", tool_call_id="c1"), + ), + FunctionToolResultEvent( + part=ToolReturnPart(tool_name="get_weather", content="Sunny", tool_call_id="c1"), + ), + # Second model response: more text. + PartStartEvent(index=0, part=TextPart(content="")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="It's sunny.")), + PartEndEvent(index=0, part=TextPart(content="It's sunny.")), + ] + final = await stream_pydantic_ai_events(_aiter(events), TASK_ID) + + # AGX1-373: all 4 messages (text, tool_request, tool_response, text) + # arrive via streaming_task_message_context. + assert messages.created == [], "adk.messages.create must not be called after reimplementation" + assert len(streaming.contexts) == 4 + assert all(ctx.closed for ctx in streaming.contexts) + + text_ctxs = [ctx for ctx in streaming.contexts if isinstance(ctx.initial_content, TextContent)] + tool_req_ctxs = [ctx for ctx in streaming.contexts if isinstance(ctx.initial_content, ToolRequestContent)] + tool_resp_ctxs = [ctx for ctx in streaming.contexts if isinstance(ctx.initial_content, ToolResponseContent)] + assert len(text_ctxs) == 2 + assert len(tool_req_ctxs) == 1 + assert len(tool_resp_ctxs) == 1 + + assert _text_deltas(text_ctxs[0]) == ["Looking up..."] + assert _text_deltas(text_ctxs[1]) == ["It's sunny."] + + # Tool content is preserved verbatim. + assert tool_req_ctxs[0].initial_content.tool_call_id == "c1" + assert tool_resp_ctxs[0].initial_content.tool_call_id == "c1" + + # Tool contexts carry no deltas (open+close only). + assert tool_req_ctxs[0].updates == [] + assert tool_resp_ctxs[0].updates == [] + + assert final == "It's sunny." + + async def test_new_text_part_after_text_closes_previous( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + """Defensive: two text parts in a row (same response) must not bleed deltas across contexts.""" + streaming, _ = fake_adk + events = [ + PartStartEvent(index=0, part=TextPart(content="")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="A")), + PartStartEvent(index=1, part=TextPart(content="")), + PartDeltaEvent(index=1, delta=TextPartDelta(content_delta="B")), + PartEndEvent(index=1, part=TextPart(content="B")), + ] + await stream_pydantic_ai_events(_aiter(events), TASK_ID) + + assert len(streaming.contexts) == 2 + # First context was closed when the second TextPart started. + assert streaming.contexts[0].closed is True + assert _text_deltas(streaming.contexts[0]) == ["A"] + assert _text_deltas(streaming.contexts[1]) == ["B"] + + async def test_reasoning_then_text_closes_reasoning_context( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + """Switching from a thinking part to a text part must close the reasoning context.""" + streaming, _ = fake_adk + events = [ + PartStartEvent(index=0, part=ThinkingPart(content="")), + PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta="think")), + PartStartEvent(index=1, part=TextPart(content="")), + PartDeltaEvent(index=1, delta=TextPartDelta(content_delta="answer")), + PartEndEvent(index=1, part=TextPart(content="answer")), + ] + await stream_pydantic_ai_events(_aiter(events), TASK_ID) + + assert len(streaming.contexts) == 2 + # Reasoning context closed before text opened. + assert streaming.contexts[0].closed is True + assert isinstance(streaming.contexts[0].initial_content, ReasoningContent) + assert _reasoning_deltas(streaming.contexts[0]) == ["think"] + assert isinstance(streaming.contexts[1].initial_content, TextContent) + assert _text_deltas(streaming.contexts[1]) == ["answer"] + + async def test_tool_result_closes_any_open_streaming_context( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + """A tool result arriving while a text context is open must close that context first. + + AGX1-373: the tool response itself now also opens a streaming context + (open+close pair) rather than going through adk.messages.create. + """ + streaming, messages = fake_adk + events = [ + PartStartEvent(index=0, part=TextPart(content="")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="thinking")), + # No PartEndEvent — provider sends the tool result while text is "live". + FunctionToolResultEvent( + part=ToolReturnPart(tool_name="t", content="ok", tool_call_id="c"), + ), + ] + await stream_pydantic_ai_events(_aiter(events), TASK_ID) + + assert streaming.contexts[0].closed is True, ( + "Helper must close any open streaming context before emitting a tool result message" + ) + # AGX1-373: tool response arrives via streaming_task_message_context + assert messages.created == [] + assert len(streaming.contexts) == 2 + assert isinstance(streaming.contexts[1].initial_content, ToolResponseContent) + + +class TestDeltaForOrphanIndexIgnored: + async def test_part_delta_without_matching_start_is_ignored( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + """A delta for an index we never saw a Start for must be a no-op, not a crash.""" + streaming, messages = fake_adk + events = [ + PartDeltaEvent(index=99, delta=TextPartDelta(content_delta="orphan")), + ] + final = await stream_pydantic_ai_events(_aiter(events), TASK_ID) + + assert streaming.contexts == [] + assert messages.created == [] + assert final == "" + + +class TestCleanupOnException: + async def test_open_contexts_are_closed_on_iterator_failure( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + """If the upstream Pydantic AI stream raises mid-flight, any open + streaming context must still be closed — otherwise the Agentex + ``messages.update(..., streaming_status="DONE")`` call never runs and + the UI shows a perma-streaming message.""" + streaming, _ = fake_adk + + async def boom() -> AsyncIterator[Any]: + yield PartStartEvent(index=0, part=TextPart(content="")) + yield PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="partial")) + raise RuntimeError("upstream provider exploded") + + with pytest.raises(RuntimeError, match="upstream provider exploded"): + await stream_pydantic_ai_events(boom(), TASK_ID) + + assert streaming.contexts[0].closed is True + + +# --------------------------------------------------------------------------- +# Characterization test: lock the wire-level delivery shape for a representative +# pydantic-ai run (text + tool call + tool response + more text). +# +# Step 1 (CURRENT behavior): written against the original implementation. +# - Text/reasoning use adk.streaming.streaming_task_message_context. +# - Tool messages use adk.messages.create (FakeMessagesModule.created list). +# - Final text is the last text segment. +# +# Step 2 (POST-reimplementation on UnifiedEmitter / auto_send): +# The assertions in TestCharacterizeWireShapeNew (below) lock the new shape. +# Tool messages no longer go through adk.messages.create; they arrive via +# streaming_task_message_context open+close pairs (Start+Done envelope). +# This is the AGX1-373 accepted envelope change: logical content is identical. +# --------------------------------------------------------------------------- + + +class TestCharacterizeWireShape: + """Characterization tests: lock the wire-level delivery shape after reimplementation. + + Uses FakeStreamingModule + FakeMessagesModule (the existing fake pair). + + AGX1-373 shape (post-reimplementation on UnifiedEmitter / auto_send): + - Text/reasoning: streaming_task_message_context (open + deltas + close) + - Tool messages: streaming_task_message_context (open+close, no deltas) + - adk.messages.create is NOT called. + - Final text == last text segment only. + + This class was first written to characterize the OLD shape (adk.messages.create + for tool messages) and was updated post-reimplementation to reflect the new + delivery channel. The logical content is identical; only the channel changed. + """ + + async def test_text_tool_text_new_wire_shape( + self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] + ) -> None: + """Representative run: text -> tool call -> tool response -> more text. + + Post-AGX1-373 delivery shape: + - Four streaming contexts: text, tool_request, tool_response, text. + - adk.messages.create NOT called. + - Final text == "It's sunny." (last segment only, matching the + multi-step convention). + """ + from pydantic_ai.messages import ToolReturnPart + + streaming, messages = fake_adk + events = [ + PartStartEvent(index=0, part=TextPart(content="")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="Looking up...")), + PartEndEvent(index=0, part=TextPart(content="Looking up...")), + PartStartEvent( + index=1, + part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="c1"), + ), + PartEndEvent( + index=1, + part=ToolCallPart(tool_name="get_weather", args="{}", tool_call_id="c1"), + ), + FunctionToolResultEvent( + part=ToolReturnPart(tool_name="get_weather", content="Sunny", tool_call_id="c1"), + ), + PartStartEvent(index=0, part=TextPart(content="")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="It's sunny.")), + PartEndEvent(index=0, part=TextPart(content="It's sunny.")), + ] + + final = await stream_pydantic_ai_events(_aiter(events), TASK_ID) + + assert final == "It's sunny.", "multi-step: only the last text segment is returned" + + # AGX1-373: all 4 messages arrive via streaming_task_message_context + assert messages.created == [] + assert len(streaming.contexts) == 4 + assert all(ctx.closed for ctx in streaming.contexts) + + content_types = [type(ctx.initial_content).__name__ for ctx in streaming.contexts] + assert content_types == [ + "TextContent", + "ToolRequestContent", + "ToolResponseContent", + "TextContent", + ] + + text_ctxs = [ctx for ctx in streaming.contexts if isinstance(ctx.initial_content, TextContent)] + tool_req_ctxs = [ctx for ctx in streaming.contexts if isinstance(ctx.initial_content, ToolRequestContent)] + tool_resp_ctxs = [ctx for ctx in streaming.contexts if isinstance(ctx.initial_content, ToolResponseContent)] + + assert _text_deltas(text_ctxs[0]) == ["Looking up..."] + assert _text_deltas(text_ctxs[1]) == ["It's sunny."] + assert tool_req_ctxs[0].initial_content.tool_call_id == "c1" + assert tool_req_ctxs[0].initial_content.name == "get_weather" + assert tool_req_ctxs[0].updates == [] + assert tool_resp_ctxs[0].initial_content.tool_call_id == "c1" + assert tool_resp_ctxs[0].initial_content.content == "Sunny" + assert tool_resp_ctxs[0].updates == [] diff --git a/tests/lib/adk/test_pydantic_ai_sync.py b/tests/lib/adk/test_pydantic_ai_sync.py new file mode 100644 index 000000000..ac9986f2b --- /dev/null +++ b/tests/lib/adk/test_pydantic_ai_sync.py @@ -0,0 +1,639 @@ +"""Tests for the sync Pydantic AI -> Agentex path. + +Covers: +- The bare converter ``convert_pydantic_ai_to_agentex_events`` (text/thinking/ + tool-call streaming and arg-delta handling). +- The unified sync (HTTP ACP) path ``UnifiedEmitter.yield_turn(PydanticAITurn(...))``: + * Passthrough: yield_turn events equal PydanticAITurn(stream).events + * Span derivation (tool + reasoning) with a fake tracing backend +""" + +from __future__ import annotations + +import json +import asyncio +from typing import Any, AsyncIterator + +import pytest +from pydantic_ai.run import AgentRunResult, AgentRunResultEvent +from pydantic_ai.messages import ( + TextPart, + PartEndEvent, + ThinkingPart, + ToolCallPart, + TextPartDelta, + PartDeltaEvent, + PartStartEvent, + ToolReturnPart, + RetryPromptPart, + FinalResultEvent, + ThinkingPartDelta, + ToolCallPartDelta, + FunctionToolCallEvent, + FunctionToolResultEvent, +) + +from agentex.lib.core.harness import UnifiedEmitter +from agentex.types.reasoning_content import ReasoningContent +from agentex.types.task_message_delta import TextDelta +from agentex.types.tool_request_delta import ToolRequestDelta +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.types.task_message_content import TextContent +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.types.reasoning_content_delta import ReasoningContentDelta +from agentex.lib.adk._modules._pydantic_ai_sync import ( + _args_delta_to_str, + convert_pydantic_ai_to_agentex_events, +) +from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn + +from ..core.harness._fakes import FakeTracing + + +async def _aiter(events: list[Any]) -> AsyncIterator[Any]: + for e in events: + yield e + + +async def _collect(stream: AsyncIterator[Any]) -> list[Any]: + return [e async for e in stream] + + +class TestArgsDeltaToStr: + def test_none(self): + assert _args_delta_to_str(None) == "" + + def test_string_passthrough(self): + assert _args_delta_to_str('{"k":') == '{"k":' + + def test_dict_dumps_json(self): + assert json.loads(_args_delta_to_str({"city": "Paris"})) == {"city": "Paris"} + + +class TestTextStreaming: + async def test_plain_text_emits_start_deltas_done(self): + events = [ + PartStartEvent(index=0, part=TextPart(content="")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="Hello")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta=", ")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="world!")), + PartEndEvent(index=0, part=TextPart(content="Hello, world!")), + ] + out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) + + assert len(out) == 5 + assert isinstance(out[0], StreamTaskMessageStart) + assert isinstance(out[0].content, TextContent) + assert out[0].content.content == "" + assert out[0].index == 0 + + for i, expected in enumerate(["Hello", ", ", "world!"], start=1): + assert isinstance(out[i], StreamTaskMessageDelta) + assert isinstance(out[i].delta, TextDelta) + assert out[i].delta.text_delta == expected + assert out[i].index == 0 + + assert isinstance(out[4], StreamTaskMessageDone) + assert out[4].index == 0 + + async def test_text_with_initial_content_emits_delta(self): + """Pydantic AI puts the first streaming chunk in PartStartEvent.part.content. + + The Agentex protocol only renders Delta events as the message body, so we + must emit the initial content as a Delta — not in the Start — otherwise + the first chunk disappears from the visible message. + """ + events = [ + PartStartEvent(index=0, part=TextPart(content="Already there")), + PartEndEvent(index=0, part=TextPart(content="Already there")), + ] + out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) + assert isinstance(out[0], StreamTaskMessageStart) + assert isinstance(out[0].content, TextContent) + assert out[0].content.content == "" + assert isinstance(out[1], StreamTaskMessageDelta) + assert isinstance(out[1].delta, TextDelta) + assert out[1].delta.text_delta == "Already there" + + +class TestThinkingStreaming: + async def test_thinking_emits_reasoning_deltas(self): + events = [ + PartStartEvent(index=0, part=ThinkingPart(content="")), + PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta="step 1...")), + PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta=" step 2.")), + PartEndEvent(index=0, part=ThinkingPart(content="step 1... step 2.")), + ] + out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) + + assert isinstance(out[0], StreamTaskMessageStart) + # Thinking content opens a ReasoningContent start, not a TextContent one, + # so the Start's content_type matches the ReasoningContentDelta updates + # that follow. Mismatched types here would render thinking as a plain + # text bubble (or break server-side accumulators) instead of a + # collapsible reasoning block. + assert isinstance(out[0].content, ReasoningContent) + assert isinstance(out[1], StreamTaskMessageDelta) + assert isinstance(out[1].delta, ReasoningContentDelta) + assert out[1].delta.content_delta == "step 1..." + assert out[1].delta.content_index == 0 + assert isinstance(out[2].delta, ReasoningContentDelta) + assert out[2].delta.content_delta == " step 2." + assert isinstance(out[3], StreamTaskMessageDone) + + async def test_thinking_with_initial_content_emits_delta(self): + events = [ + PartStartEvent(index=0, part=ThinkingPart(content="seed reasoning")), + ] + out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) + assert isinstance(out[0], StreamTaskMessageStart) + assert isinstance(out[1], StreamTaskMessageDelta) + assert isinstance(out[1].delta, ReasoningContentDelta) + assert out[1].delta.content_delta == "seed reasoning" + + async def test_thinking_delta_skipped_when_empty(self): + events = [ + PartStartEvent(index=0, part=ThinkingPart(content="")), + PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta=None)), + PartEndEvent(index=0, part=ThinkingPart(content="")), + ] + out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) + assert len(out) == 2 # Start + Done; no delta for None content + + +class TestToolCallStreaming: + async def test_tool_call_streamed_token_by_token(self): + """The headline use case: tool-call argument tokens streaming through to the client.""" + events = [ + PartStartEvent( + index=1, + part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="call_abc"), + ), + PartDeltaEvent( + index=1, + delta=ToolCallPartDelta(args_delta='{"city":', tool_call_id="call_abc"), + ), + PartDeltaEvent(index=1, delta=ToolCallPartDelta(args_delta='"Paris"}')), + PartEndEvent( + index=1, + part=ToolCallPart(tool_name="get_weather", args='{"city":"Paris"}', tool_call_id="call_abc"), + ), + ] + out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) + + assert len(out) == 4 + assert isinstance(out[0], StreamTaskMessageStart) + assert isinstance(out[0].content, ToolRequestContent) + assert out[0].content.tool_call_id == "call_abc" + assert out[0].content.name == "get_weather" + assert out[0].content.arguments == {} + + assert isinstance(out[1].delta, ToolRequestDelta) + assert out[1].delta.tool_call_id == "call_abc" + assert out[1].delta.name == "get_weather" + assert out[1].delta.arguments_delta == '{"city":' + + assert isinstance(out[2].delta, ToolRequestDelta) + assert out[2].delta.arguments_delta == '"Paris"}' + # tool_call_id is carried forward from the start even when the delta omits it + assert out[2].delta.tool_call_id == "call_abc" + + assert isinstance(out[3], StreamTaskMessageDone) + + async def test_tool_call_with_full_args_at_start(self): + """Some providers return a tool call in one shot — args dict is set at start.""" + events = [ + PartStartEvent( + index=0, + part=ToolCallPart(tool_name="search", args={"query": "weather"}, tool_call_id="call_xyz"), + ), + PartEndEvent( + index=0, + part=ToolCallPart(tool_name="search", args={"query": "weather"}, tool_call_id="call_xyz"), + ), + ] + out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) + assert isinstance(out[0], StreamTaskMessageStart) + assert isinstance(out[0].content, ToolRequestContent) + assert out[0].content.arguments == {"query": "weather"} + # No deltas emitted — args were already complete. + assert len(out) == 2 + assert isinstance(out[1], StreamTaskMessageDone) + + async def test_tool_call_with_full_args_string_at_start(self): + """When args is a complete JSON string at start, surface it as a single delta.""" + events = [ + PartStartEvent( + index=0, + part=ToolCallPart(tool_name="search", args='{"query":"weather"}', tool_call_id="call_z"), + ), + PartEndEvent( + index=0, + part=ToolCallPart(tool_name="search", args='{"query":"weather"}', tool_call_id="call_z"), + ), + ] + out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) + assert isinstance(out[0], StreamTaskMessageStart) + assert isinstance(out[0].content, ToolRequestContent) + assert out[0].content.arguments == {} + assert isinstance(out[1], StreamTaskMessageDelta) + assert isinstance(out[1].delta, ToolRequestDelta) + assert out[1].delta.arguments_delta == '{"query":"weather"}' + + async def test_tool_call_dict_args_delta_serialized(self): + events = [ + PartStartEvent( + index=0, + part=ToolCallPart(tool_name="t", args=None, tool_call_id="cid"), + ), + PartDeltaEvent( + index=0, + delta=ToolCallPartDelta(args_delta={"k": "v"}, tool_call_id="cid"), + ), + ] + out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) + assert json.loads(out[1].delta.arguments_delta) == {"k": "v"} + + async def test_tool_result_emits_full(self): + events = [ + PartStartEvent( + index=0, + part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="call_abc"), + ), + PartEndEvent( + index=0, + part=ToolCallPart(tool_name="get_weather", args="{}", tool_call_id="call_abc"), + ), + FunctionToolResultEvent( + part=ToolReturnPart(tool_name="get_weather", content="Sunny, 72F", tool_call_id="call_abc"), + ), + ] + out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) + + # Last event is the tool result -> Full ToolResponseContent + assert isinstance(out[-1], StreamTaskMessageFull) + assert isinstance(out[-1].content, ToolResponseContent) + assert out[-1].content.tool_call_id == "call_abc" + assert out[-1].content.name == "get_weather" + assert out[-1].content.content == "Sunny, 72F" + + async def test_tool_retry_prompt_surfaces_as_response(self): + events = [ + FunctionToolResultEvent( + part=RetryPromptPart( + content="bad arguments", + tool_name="get_weather", + tool_call_id="call_abc", + ), + ), + ] + out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) + assert isinstance(out[0], StreamTaskMessageFull) + assert isinstance(out[0].content, ToolResponseContent) + assert out[0].content.tool_call_id == "call_abc" + assert out[0].content.name == "get_weather" + # RetryPromptPart's content is the error message + assert out[0].content.content == "bad arguments" + + +class TestMultiStepRun: + async def test_text_then_tool_then_text_assigns_distinct_indices(self): + """A multi-step run: model emits text + tool call → tool runs → model emits more text. + + Pydantic AI restarts part indices at 0 for each new model response, so + the converter must assign fresh Agentex message indices. + """ + events = [ + # First model response: text at index 0, tool call at index 1 + PartStartEvent(index=0, part=TextPart(content="")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="Looking up...")), + PartEndEvent(index=0, part=TextPart(content="Looking up...")), + PartStartEvent( + index=1, + part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="c1"), + ), + PartDeltaEvent(index=1, delta=ToolCallPartDelta(args_delta="{}")), + PartEndEvent(index=1, part=ToolCallPart(tool_name="get_weather", args="{}", tool_call_id="c1")), + FunctionToolResultEvent( + part=ToolReturnPart(tool_name="get_weather", content="Sunny", tool_call_id="c1"), + ), + # Second model response: text restarts at index 0 + PartStartEvent(index=0, part=TextPart(content="")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="It's sunny.")), + PartEndEvent(index=0, part=TextPart(content="It's sunny.")), + ] + out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) + + # Pull every Start/Full event and check their assigned message indices + anchors = [e for e in out if isinstance(e, (StreamTaskMessageStart, StreamTaskMessageFull))] + indices = [e.index for e in anchors] + assert indices == [0, 1, 2, 3], ( + f"Expected 4 distinct, monotonic message indices for: text1, tool_call, tool_result, text2 — got {indices}" + ) + + # And the second text's deltas should target the second text's message index. + text2_start = anchors[3] + text2_deltas = [ + e + for e in out + if isinstance(e, StreamTaskMessageDelta) and isinstance(e.delta, TextDelta) and e.index == text2_start.index + ] + assert len(text2_deltas) == 1 + text2_delta = text2_deltas[0].delta + assert isinstance(text2_delta, TextDelta) + assert text2_delta.text_delta == "It's sunny." + + +class TestIgnoredEvents: + async def test_function_tool_call_event_is_ignored(self): + """FunctionToolCallEvent is redundant with PartStart+Delta+End and should be skipped.""" + events = [ + PartStartEvent( + index=0, + part=ToolCallPart(tool_name="t", args=None, tool_call_id="c"), + ), + FunctionToolCallEvent( + part=ToolCallPart(tool_name="t", args="{}", tool_call_id="c"), + ), + PartEndEvent(index=0, part=ToolCallPart(tool_name="t", args="{}", tool_call_id="c")), + ] + out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) + # Start + Done only — no event from FunctionToolCallEvent + assert len(out) == 2 + assert isinstance(out[0], StreamTaskMessageStart) + assert isinstance(out[1], StreamTaskMessageDone) + + async def test_final_result_event_ignored(self): + events = [ + FinalResultEvent(tool_name=None, tool_call_id=None), + ] + out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) + assert out == [] + + async def test_unknown_part_index_delta_skipped(self): + events = [ + PartDeltaEvent(index=99, delta=TextPartDelta(content_delta="orphan")), + ] + out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) + assert out == [] + + +class TestStartingTextMatchesAuthor: + """Sanity check that all emitted content is authored by the agent.""" + + @pytest.mark.parametrize( + "events", + [ + [PartStartEvent(index=0, part=TextPart(content=""))], + [PartStartEvent(index=0, part=ThinkingPart(content=""))], + [ + PartStartEvent( + index=0, + part=ToolCallPart(tool_name="t", args=None, tool_call_id="c"), + ) + ], + [ + FunctionToolResultEvent( + part=ToolReturnPart(tool_name="t", content="ok", tool_call_id="c"), + ) + ], + ], + ) + async def test_author_is_agent(self, events: list[Any]): + out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) + for e in out: + content = getattr(e, "content", None) + if content is not None and hasattr(content, "author"): + assert content.author == "agent" + + +class TestOnResultCallback: + """on_result callback: captures the terminal AgentRunResultEvent without + altering streaming output.""" + + def _make_result_event(self, output: Any = "hello") -> AgentRunResultEvent: + result = AgentRunResult(output=output, _output_tool_name=None) + return AgentRunResultEvent(result=result) + + async def test_callback_invoked_once_with_result_event(self): + """on_result is called exactly once, with the AgentRunResultEvent.""" + captured: list[AgentRunResultEvent] = [] + + def on_result(event: AgentRunResultEvent) -> None: + captured.append(event) + + result_event = self._make_result_event("the answer") + events = [result_event] + await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events), on_result=on_result)) + + assert len(captured) == 1 + assert captured[0] is result_event + assert captured[0].result.output == "the answer" + + async def test_streaming_output_unchanged_with_callback(self): + """Yielded StreamTaskMessage* sequence is identical whether on_result is set or not.""" + result_event = self._make_result_event() + events = [ + PartStartEvent(index=0, part=TextPart(content="")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hi")), + PartEndEvent(index=0, part=TextPart(content="hi")), + result_event, + ] + + captured: list[AgentRunResultEvent] = [] + out_with = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events), on_result=captured.append)) + out_without = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) + + assert len(out_with) == len(out_without) + for a, b in zip(out_with, out_without): + assert type(a) is type(b) + assert a.model_dump() == b.model_dump() + assert len(captured) == 1 + + async def test_no_callback_no_error(self): + """AgentRunResultEvent is silently ignored when on_result is None.""" + result_event = self._make_result_event() + events = [result_event] + out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) + assert out == [] + + async def test_async_callback_is_awaited(self): + """An async on_result callable is properly awaited. + + The callback suspends (``await asyncio.sleep(0)``) before recording its + side effect, so ``awaited`` is only populated if the converter actually + awaits the returned coroutine — distinguishing "awaited" from + "called-but-not-awaited." + """ + awaited: list[AgentRunResultEvent] = [] + + async def on_result_async(event: AgentRunResultEvent) -> None: + await asyncio.sleep(0) + awaited.append(event) + + result_event = self._make_result_event("async_output") + events = [result_event] + await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events), on_result=on_result_async)) + + assert len(awaited) == 1 + assert awaited[0].result.output == "async_output" + + +# --------------------------------------------------------------------------- +# Unified sync path: PydanticAITurn + UnifiedEmitter.yield_turn +# +# Exercises the path documented in _pydantic_ai_sync.py under +# "Recommended: unified surface": +# - events forwarded by yield_turn equal PydanticAITurn(stream).events (passthrough) +# - with a trace context + fake tracing backend, tool / reasoning spans are derived +# --------------------------------------------------------------------------- + + +class TestUnifiedSyncPathPassthrough: + """The events forwarded by yield_turn are identical to PydanticAITurn.events.""" + + async def test_text_stream_passthrough(self): + raw_events = [ + PartStartEvent(index=0, part=TextPart(content="")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hello")), + PartEndEvent(index=0, part=TextPart(content="hello")), + ] + + turn_a = PydanticAITurn(_aiter(raw_events), model="openai:gpt-4o") + direct = await _collect(turn_a.events) + + turn_b = PydanticAITurn(_aiter(raw_events), model="openai:gpt-4o") + emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) + via_emitter = await _collect(emitter.yield_turn(turn_b)) + + assert len(via_emitter) == len(direct) + for a, b in zip(via_emitter, direct): + assert type(a) is type(b) + assert a.model_dump() == b.model_dump() + + async def test_tool_call_stream_passthrough(self): + raw_events = [ + PartStartEvent(index=0, part=ToolCallPart(tool_name="Bash", args=None, tool_call_id="c1")), + PartDeltaEvent(index=0, delta=ToolCallPartDelta(args_delta='{"cmd":"ls"}')), + PartEndEvent( + index=0, + part=ToolCallPart(tool_name="Bash", args='{"cmd":"ls"}', tool_call_id="c1"), + ), + ] + + turn_a = PydanticAITurn(_aiter(raw_events), model="openai:gpt-4o") + direct = await _collect(turn_a.events) + + turn_b = PydanticAITurn(_aiter(raw_events), model="openai:gpt-4o") + emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) + via_emitter = await _collect(emitter.yield_turn(turn_b)) + + assert len(via_emitter) == len(direct) + for a, b in zip(via_emitter, direct): + assert type(a) is type(b) + assert a.model_dump() == b.model_dump() + + +class TestUnifiedSyncPathSpanDerivation: + """With trace context + fake tracing, spans are derived from the stream.""" + + async def test_tool_span_opened_and_closed(self): + """A tool call produces start_span + end_span on the fake tracing backend.""" + tool_events = [ + PartStartEvent( + index=0, + part=ToolCallPart(tool_name="Bash", args={"cmd": "ls"}, tool_call_id="call_1"), + ), + PartEndEvent( + index=0, + part=ToolCallPart(tool_name="Bash", args='{"cmd":"ls"}', tool_call_id="call_1"), + ), + FunctionToolResultEvent( + part=ToolReturnPart(tool_name="Bash", content="files", tool_call_id="call_1"), + ), + ] + + fake = FakeTracing() + turn = PydanticAITurn(_aiter(tool_events), model="openai:gpt-4o") + emitter = UnifiedEmitter(task_id="t", trace_id="tr", parent_span_id="p", tracing=fake) + + events = await _collect(emitter.yield_turn(turn)) + + assert len(events) >= 2, "at least Start(tool) + Done + Full(response)" + assert len(fake.started) == 1, "one tool span opened" + assert len(fake.ended) == 1, "one tool span closed" + span_name, parent_id, span_input = fake.started[0] + assert span_name == "Bash" + assert parent_id == "p" + closed_name, closed_output = fake.ended[0] + assert closed_name == "Bash" + + async def test_reasoning_span_opened_and_closed(self): + """A thinking/reasoning block produces start_span + end_span.""" + reasoning_events = [ + PartStartEvent(index=0, part=ThinkingPart(content="")), + PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta="let me think")), + PartEndEvent(index=0, part=ThinkingPart(content="let me think")), + ] + + fake = FakeTracing() + turn = PydanticAITurn(_aiter(reasoning_events), model="openai:gpt-4o") + emitter = UnifiedEmitter(task_id="t", trace_id="tr", parent_span_id="p", tracing=fake) + + await _collect(emitter.yield_turn(turn)) + + assert len(fake.started) == 1, "one reasoning span opened" + assert len(fake.ended) == 1, "one reasoning span closed" + span_name, parent_id, _ = fake.started[0] + assert span_name == "reasoning" + assert parent_id == "p" + + async def test_no_trace_id_means_no_spans(self): + """When trace_id is None, no spans are derived even with a fake tracing backend.""" + raw_events = [ + PartStartEvent( + index=0, + part=ToolCallPart(tool_name="Bash", args={"cmd": "ls"}, tool_call_id="c2"), + ), + PartEndEvent( + index=0, + part=ToolCallPart(tool_name="Bash", args='{"cmd":"ls"}', tool_call_id="c2"), + ), + ] + + fake = FakeTracing() + turn = PydanticAITurn(_aiter(raw_events), model="openai:gpt-4o") + emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None, tracing=fake) + + await _collect(emitter.yield_turn(turn)) + + assert fake.started == [], "no spans when trace_id is absent" + assert fake.ended == [] + + async def test_tracer_false_suppresses_spans_even_with_trace_id(self): + """tracer=False disables span derivation regardless of trace_id.""" + raw_events = [ + PartStartEvent( + index=0, + part=ToolCallPart(tool_name="Bash", args={"cmd": "ls"}, tool_call_id="c3"), + ), + PartEndEvent( + index=0, + part=ToolCallPart(tool_name="Bash", args='{"cmd":"ls"}', tool_call_id="c3"), + ), + ] + + fake = FakeTracing() + turn = PydanticAITurn(_aiter(raw_events), model="openai:gpt-4o") + emitter = UnifiedEmitter(task_id="t", trace_id="tr", parent_span_id="p", tracer=False, tracing=fake) + + await _collect(emitter.yield_turn(turn)) + + assert fake.started == [] + assert fake.ended == [] diff --git a/tests/lib/adk/test_pydantic_ai_turn.py b/tests/lib/adk/test_pydantic_ai_turn.py new file mode 100644 index 000000000..c57251db6 --- /dev/null +++ b/tests/lib/adk/test_pydantic_ai_turn.py @@ -0,0 +1,276 @@ +"""Tests for PydanticAITurn and pydantic_ai_usage_to_turn_usage.""" + +from __future__ import annotations + +from typing import Any, AsyncIterator + +from pydantic_ai.run import AgentRunResult, AgentRunResultEvent +from pydantic_ai.usage import RunUsage +from pydantic_ai.messages import ( + TextPart, + PartEndEvent, + TextPartDelta, + PartDeltaEvent, + PartStartEvent, +) + +from agentex.lib.core.harness import HarnessTurn +from agentex.lib.adk._modules._pydantic_ai_turn import ( + PydanticAITurn, + pydantic_ai_usage_to_turn_usage, +) + + +async def _aiter(events: list[Any]) -> AsyncIterator[Any]: + for e in events: + yield e + + +async def _collect(stream: AsyncIterator[Any]) -> list[Any]: + return [e async for e in stream] + + +def _make_result_event(output: Any = "done", usage: RunUsage | None = None) -> AgentRunResultEvent: + result = AgentRunResult(output=output, _output_tool_name=None) + if usage is not None: + result._state.usage = usage + return AgentRunResultEvent(result=result) + + +class TestUsageNormalization: + def test_usage_normalization_maps_fields(self): + """Real RunUsage fields map correctly onto TurnUsage.""" + usage = RunUsage( + requests=3, + input_tokens=200, + output_tokens=80, + cache_read_tokens=25, + ) + turn_usage = pydantic_ai_usage_to_turn_usage(usage, model="openai:gpt-4o") + + assert turn_usage.model == "openai:gpt-4o" + assert turn_usage.input_tokens == 200 + assert turn_usage.output_tokens == 80 + assert turn_usage.num_llm_calls == 3 + + def test_total_tokens_is_computed(self): + """RunUsage.total_tokens is a computed property; we surface it correctly.""" + usage = RunUsage(input_tokens=100, output_tokens=50) + turn_usage = pydantic_ai_usage_to_turn_usage(usage, model="openai:gpt-4o") + assert turn_usage.total_tokens == 150 + + def test_cache_read_tokens_mapped_to_cached_input_tokens(self): + usage = RunUsage(input_tokens=100, output_tokens=50, cache_read_tokens=20) + turn_usage = pydantic_ai_usage_to_turn_usage(usage, model="openai:gpt-4o") + assert turn_usage.cached_input_tokens == 20 + + def test_none_model(self): + """model=None is preserved.""" + usage = RunUsage() + turn_usage = pydantic_ai_usage_to_turn_usage(usage, model=None) + assert turn_usage.model is None + + def test_all_zero_usage_preserves_real_zeros(self): + """An all-zero RunUsage maps real 0s through (not None). + + RunUsage token fields are ints defaulting to 0. A 0 is a genuine + value (e.g. a cache-hit with 0 output tokens), not "unknown", so it + must survive normalization as 0 rather than being coerced to None. + """ + usage = RunUsage() + turn_usage = pydantic_ai_usage_to_turn_usage(usage, model="openai:gpt-4o") + assert turn_usage.num_llm_calls == 0 + assert turn_usage.input_tokens == 0 + assert turn_usage.output_tokens == 0 + assert turn_usage.cached_input_tokens == 0 + assert turn_usage.total_tokens == 0 + + def test_missing_field_degrades_to_none(self): + """A usage object MISSING a field maps that field to None (defensive getattr). + + Guards the version-rename guarantee: if pydantic-ai renames a field, + the absent attribute degrades to None rather than raising. + """ + + class StubUsage: + requests = 2 + input_tokens = 100 + # no output_tokens / cache_read_tokens / total_tokens attributes + + turn_usage = pydantic_ai_usage_to_turn_usage(StubUsage(), model="openai:gpt-4o") + assert turn_usage.num_llm_calls == 2 + assert turn_usage.input_tokens == 100 + assert turn_usage.output_tokens is None + assert turn_usage.cached_input_tokens is None + assert turn_usage.total_tokens is None + + +class TestPydanticAITurn: + async def test_turn_satisfies_harness_turn_protocol(self): + """PydanticAITurn is structurally compatible with HarnessTurn.""" + turn = PydanticAITurn(_aiter([]), model="openai:gpt-4o") + assert isinstance(turn, HarnessTurn) + + async def test_usage_before_exhaustion_returns_default(self): + """usage() before iterating events returns default TurnUsage (model set, tokens None).""" + result_event = _make_result_event(usage=RunUsage(requests=1, input_tokens=100, output_tokens=40)) + events = [result_event] + turn = PydanticAITurn(_aiter(events), model="openai:gpt-4o") + + # Do NOT exhaust events — check usage pre-run + pre_usage = turn.usage() + assert pre_usage.model == "openai:gpt-4o" + assert pre_usage.input_tokens is None + assert pre_usage.output_tokens is None + assert pre_usage.num_llm_calls is None + + async def test_turn_events_and_usage(self): + """Driving events to exhaustion populates usage from the terminal event.""" + known_usage = RunUsage( + requests=2, + input_tokens=300, + output_tokens=120, + cache_read_tokens=30, + ) + result_event = _make_result_event(usage=known_usage) + events = [ + PartStartEvent(index=0, part=TextPart(content="")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hi")), + PartEndEvent(index=0, part=TextPart(content="hi")), + result_event, + ] + turn = PydanticAITurn(_aiter(events), model="openai:gpt-4o") + + collected = await _collect(turn.events) + + # Events match bare converter output (Start + Delta + Done = 3 events) + assert len(collected) == 3 + + # Usage is populated after exhaustion + usage = turn.usage() + assert usage.model == "openai:gpt-4o" + assert usage.input_tokens == 300 + assert usage.output_tokens == 120 + assert usage.cached_input_tokens == 30 + assert usage.num_llm_calls == 2 + assert usage.total_tokens == 420 + + async def test_events_match_bare_converter(self): + """Yielded events are identical to bare convert_pydantic_ai_to_agentex_events output.""" + from agentex.lib.adk._modules._pydantic_ai_sync import convert_pydantic_ai_to_agentex_events + + text_events = [ + PartStartEvent(index=0, part=TextPart(content="")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="Hello")), + PartEndEvent(index=0, part=TextPart(content="Hello")), + ] + + turn = PydanticAITurn(_aiter(text_events), model="openai:gpt-4o") + turn_out = await _collect(turn.events) + + bare_out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(text_events))) + + assert len(turn_out) == len(bare_out) + for a, b in zip(turn_out, bare_out): + assert type(a) is type(b) + assert a.model_dump() == b.model_dump() + + async def test_usage_captured_via_real_usage_accessor(self): + """Drive the turn through the REAL ``result.usage`` property accessor. + + The production code reads ``getattr(run_result, "usage", None)``, which + on this pydantic-ai version resolves the ``_DeprecatedCallableRunUsage`` + property (NOT ``_state.usage`` directly). This asserts that the real + accessor path the converter uses captures the run usage. Constructing + the event without our test's ``_state`` shortcut: we set ``_state.usage`` + only because that is the sole supported way to seed an + ``AgentRunResult``, but we then assert capture happens through the + public ``.usage`` attribute access (verified below). + """ + known_usage = RunUsage(requests=4, input_tokens=512, output_tokens=64) + result = AgentRunResult(output="done", _output_tool_name=None) + result._state.usage = known_usage + result_event = AgentRunResultEvent(result=result) + + # Sanity: the value is reachable via the real public accessor the + # production code uses (not just via the private _state). The + # _DeprecatedCallableRunUsage property wraps the value, so compare by + # equality rather than identity. + accessed = getattr(result_event.result, "usage", None) + assert accessed is not None + assert accessed.input_tokens == 512 + assert accessed.requests == 4 + + events = [ + PartStartEvent(index=0, part=TextPart(content="")), + PartEndEvent(index=0, part=TextPart(content="")), + result_event, + ] + turn = PydanticAITurn(_aiter(events), model="anthropic:claude-3-5-sonnet") + await _collect(turn.events) + + usage = turn.usage() + assert usage.model == "anthropic:claude-3-5-sonnet" + assert usage.input_tokens == 512 + assert usage.output_tokens == 64 + assert usage.num_llm_calls == 4 + + async def test_no_usage_event_leaves_default_usage(self): + """If the stream has no AgentRunResultEvent, usage() returns the default (tokens None).""" + events = [ + PartStartEvent(index=0, part=TextPart(content="")), + PartEndEvent(index=0, part=TextPart(content="")), + ] + turn = PydanticAITurn(_aiter(events), model="openai:gpt-4o") + await _collect(turn.events) + + usage = turn.usage() + assert usage.model == "openai:gpt-4o" + assert usage.input_tokens is None + assert usage.num_llm_calls is None + + +class TestToolRequestStreaming: + """PydanticAITurn.events equals the bare converter output unconditionally. + + The foundation auto_send delivers Start+ToolRequestDelta+Done natively, so + no coalescing is needed on either channel. + """ + + async def test_events_match_bare_converter_for_streamed_tool_call(self): + """PydanticAITurn yields a ToolRequestDelta for a streamed-args tool call + — i.e. it is byte-for-byte the bare converter output, preserving + argument-token streaming on the sync/yield channel.""" + from pydantic_ai.messages import ToolCallPart, ToolCallPartDelta + + from agentex.types.tool_request_delta import ToolRequestDelta + from agentex.types.task_message_update import StreamTaskMessageDelta + from agentex.lib.adk._modules._pydantic_ai_sync import convert_pydantic_ai_to_agentex_events + + tool_events = [ + PartStartEvent(index=0, part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="c1")), + PartDeltaEvent(index=0, delta=ToolCallPartDelta(args_delta='{"city":"Paris"}')), + PartEndEvent( + index=0, + part=ToolCallPart(tool_name="get_weather", args='{"city":"Paris"}', tool_call_id="c1"), + ), + ] + + turn = PydanticAITurn(_aiter(tool_events), model="openai:gpt-4o") + turn_out = await _collect(turn.events) + + bare_out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(tool_events))) + + # Turn is identical to the bare converter. + assert len(turn_out) == len(bare_out) + for a, b in zip(turn_out, bare_out): + assert type(a) is type(b) + assert a.model_dump() == b.model_dump() + + # The arg-streaming delta is present. + deltas = [ + e for e in turn_out if isinstance(e, StreamTaskMessageDelta) and isinstance(e.delta, ToolRequestDelta) + ] + assert len(deltas) == 1, "streamed tool-call args must surface as a ToolRequestDelta" + assert isinstance(deltas[0].delta, ToolRequestDelta) + assert deltas[0].delta.arguments_delta == '{"city":"Paris"}' diff --git a/tests/lib/adk/test_state_service.py b/tests/lib/adk/test_state_service.py new file mode 100644 index 000000000..43b53ff39 --- /dev/null +++ b/tests/lib/adk/test_state_service.py @@ -0,0 +1,69 @@ +"""Tests for StateService forwarding task_id/agent_id to the SDK client. + +Regression guard for the 0.13.0 incident: the generated client dropped +task_id/agent_id from states.update(), so the ADK stopped sending them in the +body and every state write 422'd against backends predating scale-agentex#278. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import Mock, AsyncMock + +from agentex.types.state import State +from agentex.lib.core.services.adk.state import StateService + +_TS = datetime(2026, 5, 13, 18, 30, 0, tzinfo=timezone.utc) + + +def _make_state() -> State: + return State( + id="s1", + agent_id="a1", + task_id="t1", + state={"k": "v"}, + created_at=_TS, + ) + + +def _mock_span(): + span = Mock() + span.output = None + + async def __aenter__(_self): + return span + + async def __aexit__(_self, *args): + return None + + span.__aenter__ = __aenter__ + span.__aexit__ = __aexit__ + return span + + +def _make_service() -> tuple[AsyncMock, StateService]: + client = AsyncMock() + tracer = Mock() + trace = Mock() + trace.span.return_value = _mock_span() + tracer.trace.return_value = trace + return client, StateService(agentex_client=client, tracer=tracer) + + +class TestUpdateStateSendsParentIdentifiers: + async def test_task_id_and_agent_id_sent_in_body(self) -> None: + client, svc = _make_service() + client.states.update.return_value = _make_state() + + await svc.update_state( + state_id="s1", + task_id="t1", + agent_id="a1", + state={"k": "v"}, + ) + + kwargs = client.states.update.call_args.kwargs + assert kwargs["state_id"] == "s1" + # task_id/agent_id must ride in extra_body — the generated client dropped + # them from the typed signature, but old backends still require them. + assert kwargs["extra_body"] == {"task_id": "t1", "agent_id": "a1"} diff --git a/tests/lib/adk/test_tasks_activities.py b/tests/lib/adk/test_tasks_activities.py new file mode 100644 index 000000000..3c9505de0 --- /dev/null +++ b/tests/lib/adk/test_tasks_activities.py @@ -0,0 +1,249 @@ +from unittest.mock import AsyncMock + +from temporalio.testing import ActivityEnvironment + +from agentex.types.task import Task + + +def _make_task(**overrides) -> Task: + defaults = { + "id": "task-123", + "name": "test-task", + "status": "RUNNING", + "params": {}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + } + defaults.update(overrides) + return Task(**defaults) + + +def _make_tasks_activities(): + from agentex.lib.core.services.adk.tasks import TasksService + from agentex.lib.core.temporal.activities.adk.tasks_activities import TasksActivities + + mock_service = AsyncMock(spec=TasksService) + activities = TasksActivities(tasks_service=mock_service) + env = ActivityEnvironment() + return mock_service, activities, env + + +class TestGetTask: + async def test_get_task_by_id(self): + from agentex.lib.core.temporal.activities.adk.tasks_activities import GetTaskParams + + mock_service, activities, env = _make_tasks_activities() + expected = _make_task() + mock_service.get_task.return_value = expected + + params = GetTaskParams(task_id="task-123", trace_id="t", parent_span_id="s") + result = await env.run(activities.get_task, params) + + assert result == expected + mock_service.get_task.assert_called_once_with( + task_id="task-123", task_name=None, trace_id="t", parent_span_id="s" + ) + + async def test_get_task_by_name(self): + from agentex.lib.core.temporal.activities.adk.tasks_activities import GetTaskParams + + mock_service, activities, env = _make_tasks_activities() + expected = _make_task() + mock_service.get_task.return_value = expected + + params = GetTaskParams(task_name="test-task", trace_id="t", parent_span_id="s") + result = await env.run(activities.get_task, params) + + assert result == expected + mock_service.get_task.assert_called_once_with( + task_id=None, task_name="test-task", trace_id="t", parent_span_id="s" + ) + + +class TestDeleteTask: + async def test_delete_task_by_id(self): + from agentex.lib.core.temporal.activities.adk.tasks_activities import DeleteTaskParams + + mock_service, activities, env = _make_tasks_activities() + expected = _make_task(status="DELETED") + mock_service.delete_task.return_value = expected + + params = DeleteTaskParams(task_id="task-123", trace_id="t", parent_span_id="s") + result = await env.run(activities.delete_task, params) + + assert result == expected + mock_service.delete_task.assert_called_once_with( + task_id="task-123", task_name=None, trace_id="t", parent_span_id="s" + ) + + +class TestCancelTask: + async def test_cancel_task(self): + from agentex.lib.core.temporal.activities.adk.tasks_activities import TaskStatusTransitionParams + + mock_service, activities, env = _make_tasks_activities() + expected = _make_task(status="CANCELED", status_reason="user requested") + mock_service.cancel_task.return_value = expected + + params = TaskStatusTransitionParams( + task_id="task-123", reason="user requested", trace_id="t", parent_span_id="s" + ) + result = await env.run(activities.cancel_task, params) + + assert result == expected + assert result.status == "CANCELED" + mock_service.cancel_task.assert_called_once_with( + task_id="task-123", reason="user requested", trace_id="t", parent_span_id="s" + ) + + async def test_cancel_task_without_reason(self): + from agentex.lib.core.temporal.activities.adk.tasks_activities import TaskStatusTransitionParams + + mock_service, activities, env = _make_tasks_activities() + expected = _make_task(status="CANCELED") + mock_service.cancel_task.return_value = expected + + params = TaskStatusTransitionParams(task_id="task-123") + result = await env.run(activities.cancel_task, params) + + assert result == expected + mock_service.cancel_task.assert_called_once_with( + task_id="task-123", reason=None, trace_id=None, parent_span_id=None + ) + + +class TestCompleteTask: + async def test_complete_task(self): + from agentex.lib.core.temporal.activities.adk.tasks_activities import TaskStatusTransitionParams + + mock_service, activities, env = _make_tasks_activities() + expected = _make_task(status="COMPLETED", status_reason="all done") + mock_service.complete_task.return_value = expected + + params = TaskStatusTransitionParams( + task_id="task-123", reason="all done", trace_id="t", parent_span_id="s" + ) + result = await env.run(activities.complete_task, params) + + assert result == expected + assert result.status == "COMPLETED" + mock_service.complete_task.assert_called_once_with( + task_id="task-123", reason="all done", trace_id="t", parent_span_id="s" + ) + + +class TestFailTask: + async def test_fail_task(self): + from agentex.lib.core.temporal.activities.adk.tasks_activities import TaskStatusTransitionParams + + mock_service, activities, env = _make_tasks_activities() + expected = _make_task(status="FAILED", status_reason="something broke") + mock_service.fail_task.return_value = expected + + params = TaskStatusTransitionParams( + task_id="task-123", reason="something broke", trace_id="t", parent_span_id="s" + ) + result = await env.run(activities.fail_task, params) + + assert result == expected + assert result.status == "FAILED" + mock_service.fail_task.assert_called_once_with( + task_id="task-123", reason="something broke", trace_id="t", parent_span_id="s" + ) + + +class TestTerminateTask: + async def test_terminate_task(self): + from agentex.lib.core.temporal.activities.adk.tasks_activities import TaskStatusTransitionParams + + mock_service, activities, env = _make_tasks_activities() + expected = _make_task(status="TERMINATED", status_reason="admin kill") + mock_service.terminate_task.return_value = expected + + params = TaskStatusTransitionParams( + task_id="task-123", reason="admin kill", trace_id="t", parent_span_id="s" + ) + result = await env.run(activities.terminate_task, params) + + assert result == expected + assert result.status == "TERMINATED" + mock_service.terminate_task.assert_called_once_with( + task_id="task-123", reason="admin kill", trace_id="t", parent_span_id="s" + ) + + +class TestTimeoutTask: + async def test_timeout_task(self): + from agentex.lib.core.temporal.activities.adk.tasks_activities import TaskStatusTransitionParams + + mock_service, activities, env = _make_tasks_activities() + expected = _make_task(status="TIMED_OUT", status_reason="exceeded 30s") + mock_service.timeout_task.return_value = expected + + params = TaskStatusTransitionParams( + task_id="task-123", reason="exceeded 30s", trace_id="t", parent_span_id="s" + ) + result = await env.run(activities.timeout_task, params) + + assert result == expected + assert result.status == "TIMED_OUT" + mock_service.timeout_task.assert_called_once_with( + task_id="task-123", reason="exceeded 30s", trace_id="t", parent_span_id="s" + ) + + +class TestUpdateTask: + async def test_update_task_by_id(self): + from agentex.lib.core.temporal.activities.adk.tasks_activities import UpdateTaskParams + + mock_service, activities, env = _make_tasks_activities() + metadata = {"key": "value"} + expected = _make_task(task_metadata=metadata) + mock_service.update_task.return_value = expected + + params = UpdateTaskParams( + task_id="task-123", task_metadata=metadata, trace_id="t", parent_span_id="s" + ) + result = await env.run(activities.update_task, params) + + assert result == expected + mock_service.update_task.assert_called_once_with( + task_id="task-123", task_name=None, task_metadata=metadata, trace_id="t", parent_span_id="s" + ) + + async def test_update_task_by_name(self): + from agentex.lib.core.temporal.activities.adk.tasks_activities import UpdateTaskParams + + mock_service, activities, env = _make_tasks_activities() + metadata = {"foo": "bar"} + expected = _make_task(task_metadata=metadata) + mock_service.update_task.return_value = expected + + params = UpdateTaskParams( + task_name="test-task", task_metadata=metadata, trace_id="t", parent_span_id="s" + ) + result = await env.run(activities.update_task, params) + + assert result == expected + mock_service.update_task.assert_called_once_with( + task_id=None, task_name="test-task", task_metadata=metadata, trace_id="t", parent_span_id="s" + ) + + +class TestQueryWorkflow: + async def test_query_workflow(self): + from agentex.lib.core.temporal.activities.adk.tasks_activities import QueryWorkflowParams + + mock_service, activities, env = _make_tasks_activities() + expected = {"state": "processing", "progress": 50} + mock_service.query_workflow.return_value = expected + + params = QueryWorkflowParams( + task_id="task-123", query_name="get_progress", trace_id="t", parent_span_id="s" + ) + result = await env.run(activities.query_workflow, params) + + assert result == expected + mock_service.query_workflow.assert_called_once_with( + task_id="task-123", query_name="get_progress", trace_id="t", parent_span_id="s" + ) diff --git a/tests/lib/adk/test_tasks_module.py b/tests/lib/adk/test_tasks_module.py new file mode 100644 index 000000000..f72e50333 --- /dev/null +++ b/tests/lib/adk/test_tasks_module.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +# Reference to the actual module object for patch.object +import agentex.lib.adk._modules.tasks as _tasks_mod +from agentex.types.task import Task +from agentex.lib.adk._modules.tasks import TasksModule +from agentex.lib.core.services.adk.tasks import TasksService + + +def _make_task(**overrides) -> Task: + defaults = { + "id": "task-123", + "name": "test-task", + "status": "RUNNING", + "params": {}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + } + defaults.update(overrides) + return Task(**defaults) + + +def _make_module() -> tuple[AsyncMock, TasksModule]: + mock_service = AsyncMock(spec=TasksService) + module = TasksModule(tasks_service=mock_service) + return mock_service, module + + +class TestTasksModuleCancel: + async def test_cancel(self): + mock_service, module = _make_module() + expected = _make_task(status="CANCELED") + mock_service.cancel_task.return_value = expected + + with patch.object(_tasks_mod, "in_temporal_workflow", return_value=False): + result = await module.cancel(task_id="task-123", reason="done") + + assert result == expected + assert result.status == "CANCELED" + mock_service.cancel_task.assert_called_once_with( + task_id="task-123", reason="done", trace_id=None, parent_span_id=None + ) + + async def test_cancel_without_reason(self): + mock_service, module = _make_module() + expected = _make_task(status="CANCELED") + mock_service.cancel_task.return_value = expected + + with patch.object(_tasks_mod, "in_temporal_workflow", return_value=False): + result = await module.cancel(task_id="task-123") + + assert result == expected + mock_service.cancel_task.assert_called_once_with( + task_id="task-123", reason=None, trace_id=None, parent_span_id=None + ) + + +class TestTasksModuleComplete: + async def test_complete(self): + mock_service, module = _make_module() + expected = _make_task(status="COMPLETED") + mock_service.complete_task.return_value = expected + + with patch.object(_tasks_mod, "in_temporal_workflow", return_value=False): + result = await module.complete(task_id="task-123", reason="finished") + + assert result == expected + assert result.status == "COMPLETED" + mock_service.complete_task.assert_called_once_with( + task_id="task-123", reason="finished", trace_id=None, parent_span_id=None + ) + + +class TestTasksModuleFail: + async def test_fail(self): + mock_service, module = _make_module() + expected = _make_task(status="FAILED") + mock_service.fail_task.return_value = expected + + with patch.object(_tasks_mod, "in_temporal_workflow", return_value=False): + result = await module.fail(task_id="task-123", reason="error occurred") + + assert result == expected + assert result.status == "FAILED" + mock_service.fail_task.assert_called_once_with( + task_id="task-123", reason="error occurred", trace_id=None, parent_span_id=None + ) + + +class TestTasksModuleTerminate: + async def test_terminate(self): + mock_service, module = _make_module() + expected = _make_task(status="TERMINATED") + mock_service.terminate_task.return_value = expected + + with patch.object(_tasks_mod, "in_temporal_workflow", return_value=False): + result = await module.terminate(task_id="task-123", reason="admin kill") + + assert result == expected + assert result.status == "TERMINATED" + mock_service.terminate_task.assert_called_once_with( + task_id="task-123", reason="admin kill", trace_id=None, parent_span_id=None + ) + + +class TestTasksModuleTimeout: + async def test_timeout(self): + mock_service, module = _make_module() + expected = _make_task(status="TIMED_OUT") + mock_service.timeout_task.return_value = expected + + with patch.object(_tasks_mod, "in_temporal_workflow", return_value=False): + result = await module.timeout(task_id="task-123", reason="exceeded limit") + + assert result == expected + assert result.status == "TIMED_OUT" + mock_service.timeout_task.assert_called_once_with( + task_id="task-123", reason="exceeded limit", trace_id=None, parent_span_id=None + ) + + +class TestTasksModuleUpdate: + async def test_update_by_id(self): + mock_service, module = _make_module() + metadata = {"key": "value"} + expected = _make_task(task_metadata=metadata) + mock_service.update_task.return_value = expected + + with patch.object(_tasks_mod, "in_temporal_workflow", return_value=False): + result = await module.update(task_id="task-123", task_metadata=metadata) + + assert result == expected + mock_service.update_task.assert_called_once_with( + task_id="task-123", task_name=None, task_metadata=metadata, trace_id=None, parent_span_id=None + ) + + async def test_update_by_name(self): + mock_service, module = _make_module() + metadata = {"foo": "bar"} + expected = _make_task(task_metadata=metadata) + mock_service.update_task.return_value = expected + + with patch.object(_tasks_mod, "in_temporal_workflow", return_value=False): + result = await module.update(task_name="test-task", task_metadata=metadata) + + assert result == expected + mock_service.update_task.assert_called_once_with( + task_id=None, task_name="test-task", task_metadata=metadata, trace_id=None, parent_span_id=None + ) + + async def test_update_with_tracing(self): + mock_service, module = _make_module() + expected = _make_task() + mock_service.update_task.return_value = expected + + with patch.object(_tasks_mod, "in_temporal_workflow", return_value=False): + result = await module.update( + task_id="task-123", task_metadata={"a": "b"}, trace_id="trace-1", parent_span_id="span-1" + ) + + assert result == expected + mock_service.update_task.assert_called_once_with( + task_id="task-123", + task_name=None, + task_metadata={"a": "b"}, + trace_id="trace-1", + parent_span_id="span-1", + ) + + +class TestTasksModuleQueryWorkflow: + async def test_query_workflow(self): + mock_service, module = _make_module() + expected = {"state": "processing", "progress": 50} + mock_service.query_workflow.return_value = expected + + with patch.object(_tasks_mod, "in_temporal_workflow", return_value=False): + result = await module.query_workflow(task_id="task-123", query_name="get_progress") + + assert result == expected + mock_service.query_workflow.assert_called_once_with( + task_id="task-123", query_name="get_progress", trace_id=None, parent_span_id=None + ) + + async def test_query_workflow_with_tracing(self): + mock_service, module = _make_module() + expected = {"done": True} + mock_service.query_workflow.return_value = expected + + with patch.object(_tasks_mod, "in_temporal_workflow", return_value=False): + result = await module.query_workflow( + task_id="task-123", query_name="is_done", trace_id="t", parent_span_id="s" + ) + + assert result == expected + mock_service.query_workflow.assert_called_once_with( + task_id="task-123", query_name="is_done", trace_id="t", parent_span_id="s" + ) + + +class TestTasksModuleTemporalPath: + async def test_cancel_in_workflow(self): + mock_service, module = _make_module() + expected = _make_task(status="CANCELED") + + with patch.object(_tasks_mod, "in_temporal_workflow", return_value=True), \ + patch.object(_tasks_mod, "ActivityHelpers") as mock_helpers: + mock_helpers.execute_activity = AsyncMock(return_value=expected) + result = await module.cancel(task_id="task-123", reason="test") + + assert result == expected + mock_helpers.execute_activity.assert_called_once() + mock_service.cancel_task.assert_not_called() + + async def test_complete_in_workflow(self): + mock_service, module = _make_module() + expected = _make_task(status="COMPLETED") + + with patch.object(_tasks_mod, "in_temporal_workflow", return_value=True), \ + patch.object(_tasks_mod, "ActivityHelpers") as mock_helpers: + mock_helpers.execute_activity = AsyncMock(return_value=expected) + result = await module.complete(task_id="task-123") + + assert result == expected + mock_helpers.execute_activity.assert_called_once() + mock_service.complete_task.assert_not_called() + + async def test_update_in_workflow(self): + mock_service, module = _make_module() + expected = _make_task() + + with patch.object(_tasks_mod, "in_temporal_workflow", return_value=True), \ + patch.object(_tasks_mod, "ActivityHelpers") as mock_helpers: + mock_helpers.execute_activity = AsyncMock(return_value=expected) + result = await module.update(task_id="task-123", task_metadata={"k": "v"}) + + assert result == expected + mock_helpers.execute_activity.assert_called_once() + mock_service.update_task.assert_not_called() + + async def test_query_workflow_in_workflow(self): + mock_service, module = _make_module() + expected = {"result": 42} + + with patch.object(_tasks_mod, "in_temporal_workflow", return_value=True), \ + patch.object(_tasks_mod, "ActivityHelpers") as mock_helpers: + mock_helpers.execute_activity = AsyncMock(return_value=expected) + result = await module.query_workflow(task_id="task-123", query_name="get_result") + + assert result == expected + mock_helpers.execute_activity.assert_called_once() + mock_service.query_workflow.assert_not_called() diff --git a/tests/lib/adk/test_tasks_service.py b/tests/lib/adk/test_tasks_service.py new file mode 100644 index 000000000..8fd988070 --- /dev/null +++ b/tests/lib/adk/test_tasks_service.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from unittest.mock import Mock, AsyncMock + +import pytest + +from agentex.types.task import Task +from agentex.lib.core.services.adk.tasks import TasksService + + +def _make_task(**overrides) -> Task: + defaults = { + "id": "task-123", + "name": "test-task", + "status": "RUNNING", + "params": {}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + } + defaults.update(overrides) + return Task(**defaults) + + +def _mock_span(): + mock_span = Mock() + mock_span.output = None + + async def __aenter__(_self): + return mock_span + + async def __aexit__(_self, *args): + pass + + mock_span.__aenter__ = __aenter__ + mock_span.__aexit__ = __aexit__ + return mock_span + + +def _make_service() -> tuple[AsyncMock, TasksService]: + mock_client = AsyncMock() + mock_tracer = Mock() + mock_trace = Mock() + span = _mock_span() + mock_trace.span.return_value = span + mock_tracer.trace.return_value = mock_trace + service = TasksService(agentex_client=mock_client, tracer=mock_tracer) + return mock_client, service + + +class TestCancelTask: + async def test_cancel_task(self): + mock_client, service = _make_service() + expected = _make_task(status="CANCELED") + mock_client.tasks.cancel.return_value = expected + + result = await service.cancel_task(task_id="task-123", reason="done") + + assert result == expected + mock_client.tasks.cancel.assert_called_once_with(task_id="task-123", reason="done") + + async def test_cancel_task_without_reason(self): + mock_client, service = _make_service() + expected = _make_task(status="CANCELED") + mock_client.tasks.cancel.return_value = expected + + result = await service.cancel_task(task_id="task-123") + + assert result == expected + mock_client.tasks.cancel.assert_called_once_with(task_id="task-123", reason=None) + + +class TestCompleteTask: + async def test_complete_task(self): + mock_client, service = _make_service() + expected = _make_task(status="COMPLETED") + mock_client.tasks.complete.return_value = expected + + result = await service.complete_task(task_id="task-123", reason="finished") + + assert result == expected + mock_client.tasks.complete.assert_called_once_with(task_id="task-123", reason="finished") + + +class TestFailTask: + async def test_fail_task(self): + mock_client, service = _make_service() + expected = _make_task(status="FAILED") + mock_client.tasks.fail.return_value = expected + + result = await service.fail_task(task_id="task-123", reason="error") + + assert result == expected + mock_client.tasks.fail.assert_called_once_with(task_id="task-123", reason="error") + + +class TestTerminateTask: + async def test_terminate_task(self): + mock_client, service = _make_service() + expected = _make_task(status="TERMINATED") + mock_client.tasks.terminate.return_value = expected + + result = await service.terminate_task(task_id="task-123", reason="killed") + + assert result == expected + mock_client.tasks.terminate.assert_called_once_with(task_id="task-123", reason="killed") + + +class TestTimeoutTask: + async def test_timeout_task(self): + mock_client, service = _make_service() + expected = _make_task(status="TIMED_OUT") + mock_client.tasks.timeout.return_value = expected + + result = await service.timeout_task(task_id="task-123", reason="too slow") + + assert result == expected + mock_client.tasks.timeout.assert_called_once_with(task_id="task-123", reason="too slow") + + +class TestUpdateTask: + async def test_update_task_by_id(self): + mock_client, service = _make_service() + metadata = {"key": "value"} + expected = _make_task(task_metadata=metadata) + mock_client.tasks.update_by_id.return_value = expected + + result = await service.update_task(task_id="task-123", task_metadata=metadata) + + assert result == expected + mock_client.tasks.update_by_id.assert_called_once_with(task_id="task-123", task_metadata=metadata) + + async def test_update_task_by_name(self): + mock_client, service = _make_service() + metadata = {"key": "value"} + expected = _make_task(task_metadata=metadata) + mock_client.tasks.update_by_name.return_value = expected + + result = await service.update_task(task_name="test-task", task_metadata=metadata) + + assert result == expected + mock_client.tasks.update_by_name.assert_called_once_with(task_name="test-task", task_metadata=metadata) + + async def test_update_task_no_id_or_name_raises(self): + _, service = _make_service() + + with pytest.raises(ValueError, match="Either task_id or task_name must be provided"): + await service.update_task(task_metadata={"key": "value"}) + + +class TestQueryWorkflow: + async def test_query_workflow(self): + mock_client, service = _make_service() + expected = {"state": "processing", "progress": 50} + mock_client.tasks.query_workflow.return_value = expected + + result = await service.query_workflow(task_id="task-123", query_name="get_progress") + + assert result == expected + mock_client.tasks.query_workflow.assert_called_once_with(query_name="get_progress", task_id="task-123") diff --git a/tests/lib/adk/test_tracing_activities.py b/tests/lib/adk/test_tracing_activities.py new file mode 100644 index 000000000..248ba94a7 --- /dev/null +++ b/tests/lib/adk/test_tracing_activities.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import AsyncMock + +from temporalio.testing import ActivityEnvironment + +from agentex.types.span import Span + + +def _make_span(**overrides) -> Span: + defaults = { + "id": "span-123", + "name": "test-span", + "start_time": datetime(2026, 1, 1, tzinfo=timezone.utc), + "trace_id": "trace-123", + } + defaults.update(overrides) + return Span(**defaults) + + +def _make_tracing_activities(): + from agentex.lib.core.services.adk.tracing import TracingService + from agentex.lib.core.temporal.activities.adk.tracing_activities import TracingActivities + + mock_service = AsyncMock(spec=TracingService) + activities = TracingActivities(tracing_service=mock_service) + env = ActivityEnvironment() + return mock_service, activities, env + + +class TestStartSpanActivity: + async def test_start_span_with_task_id(self): + from agentex.lib.core.temporal.activities.adk.tracing_activities import StartSpanParams + + mock_service, activities, env = _make_tracing_activities() + expected = _make_span(task_id="task-abc") + mock_service.start_span.return_value = expected + + params = StartSpanParams( + trace_id="trace-123", + name="test-span", + task_id="task-abc", + ) + result = await env.run(activities.start_span, params) + + assert result == expected + assert result.task_id == "task-abc" + mock_service.start_span.assert_called_once_with( + trace_id="trace-123", + parent_id=None, + name="test-span", + input=None, + data=None, + task_id="task-abc", + ) + + async def test_start_span_without_task_id(self): + from agentex.lib.core.temporal.activities.adk.tracing_activities import StartSpanParams + + mock_service, activities, env = _make_tracing_activities() + expected = _make_span() + mock_service.start_span.return_value = expected + + params = StartSpanParams(trace_id="trace-123", name="test-span") + result = await env.run(activities.start_span, params) + + assert result == expected + mock_service.start_span.assert_called_once_with( + trace_id="trace-123", + parent_id=None, + name="test-span", + input=None, + data=None, + task_id=None, + ) + + +class TestEndSpanActivity: + async def test_end_span_preserves_task_id(self): + from agentex.lib.core.temporal.activities.adk.tracing_activities import EndSpanParams + + mock_service, activities, env = _make_tracing_activities() + span = _make_span(task_id="task-abc") + expected = _make_span( + task_id="task-abc", + end_time=datetime(2026, 1, 1, tzinfo=timezone.utc), + ) + mock_service.end_span.return_value = expected + + params = EndSpanParams(trace_id="trace-123", span=span) + result = await env.run(activities.end_span, params) + + assert result == expected + assert result.task_id == "task-abc" + mock_service.end_span.assert_called_once_with(trace_id="trace-123", span=span) diff --git a/tests/lib/adk/test_tracing_module.py b/tests/lib/adk/test_tracing_module.py new file mode 100644 index 000000000..c17ff5ff6 --- /dev/null +++ b/tests/lib/adk/test_tracing_module.py @@ -0,0 +1,437 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from temporalio.exceptions import ActivityError + +import agentex.lib.adk._modules.tracing as _tracing_mod +from agentex.types.span import Span +from agentex.lib.core.harness.types import TurnUsage +from agentex.lib.adk._modules.tracing import TurnSpan, TracingModule +from agentex.lib.core.tracing.span_error import get_span_error +from agentex.lib.core.services.adk.tracing import TracingService + + +def _make_span(**overrides) -> Span: + defaults = { + "id": "span-123", + "name": "test-span", + "start_time": datetime(2026, 1, 1, tzinfo=timezone.utc), + "trace_id": "trace-123", + } + defaults.update(overrides) + return Span(**defaults) + + +def _make_module() -> tuple[AsyncMock, TracingModule]: + mock_service = AsyncMock(spec=TracingService) + module = TracingModule(tracing_service=mock_service) + return mock_service, module + + +def _make_activity_error() -> ActivityError: + return ActivityError( + "activity timed out", + scheduled_event_id=1, + started_event_id=2, + identity="worker-1", + activity_type="start-span", + activity_id="activity-1", + retry_state=None, + ) + + +def _make_metric_meter() -> MagicMock: + mock_meter = MagicMock() + mock_meter.create_counter.return_value = MagicMock() + return mock_meter + + +class TestStartSpan: + async def test_start_span_with_task_id(self): + mock_service, module = _make_module() + expected = _make_span(task_id="task-abc") + mock_service.start_span.return_value = expected + + with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): + result = await module.start_span( + trace_id="trace-123", + name="test-span", + task_id="task-abc", + ) + + assert result == expected + assert result.task_id == "task-abc" + mock_service.start_span.assert_called_once_with( + trace_id="trace-123", + name="test-span", + input=None, + parent_id=None, + data=None, + task_id="task-abc", + ) + + async def test_start_span_without_task_id(self): + mock_service, module = _make_module() + expected = _make_span() + mock_service.start_span.return_value = expected + + with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): + result = await module.start_span(trace_id="trace-123", name="test-span") + + assert result == expected + mock_service.start_span.assert_called_once_with( + trace_id="trace-123", + name="test-span", + input=None, + parent_id=None, + data=None, + task_id=None, + ) + + +class TestEndSpan: + async def test_end_span_preserves_task_id(self): + mock_service, module = _make_module() + span = _make_span(task_id="task-abc") + expected = _make_span( + task_id="task-abc", + end_time=datetime(2026, 1, 1, tzinfo=timezone.utc), + ) + mock_service.end_span.return_value = expected + + with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): + result = await module.end_span(trace_id="trace-123", span=span) + + assert result == expected + assert result.task_id == "task-abc" + mock_service.end_span.assert_called_once_with(trace_id="trace-123", span=span) + + +class TestTracingModuleTemporalPath: + async def test_start_span_in_workflow_returns_none_when_activity_fails(self): + mock_service, module = _make_module() + mock_meter = _make_metric_meter() + + with patch.object(_tracing_mod, "in_temporal_workflow", return_value=True), patch.object( + _tracing_mod, "ActivityHelpers" + ) as mock_helpers, patch.object(_tracing_mod.workflow, "logger") as mock_logger, patch.object( + _tracing_mod.workflow, "metric_meter", return_value=mock_meter + ): + mock_helpers.execute_activity = AsyncMock(side_effect=_make_activity_error()) + result = await module.start_span(trace_id="trace-123", name="test-span") + + assert result is None + mock_logger.warning.assert_called_once() + mock_meter.create_counter.assert_called_once_with( + _tracing_mod.TEMPORAL_SPAN_ACTIVITY_DROPPED_METRIC, + description="Temporal tracing span activities dropped after fail-open", + unit="1", + ) + mock_meter.create_counter.return_value.add.assert_called_once_with(1, {"event_type": "start"}) + mock_helpers.execute_activity.assert_called_once() + mock_service.start_span.assert_not_called() + + async def test_end_span_in_workflow_returns_span_when_activity_fails(self): + mock_service, module = _make_module() + span = _make_span() + mock_meter = _make_metric_meter() + + with patch.object(_tracing_mod, "in_temporal_workflow", return_value=True), patch.object( + _tracing_mod, "ActivityHelpers" + ) as mock_helpers, patch.object(_tracing_mod.workflow, "logger") as mock_logger, patch.object( + _tracing_mod.workflow, "metric_meter", return_value=mock_meter + ): + mock_helpers.execute_activity = AsyncMock(side_effect=_make_activity_error()) + result = await module.end_span(trace_id="trace-123", span=span) + + assert result == span + mock_logger.warning.assert_called_once() + mock_meter.create_counter.assert_called_once_with( + _tracing_mod.TEMPORAL_SPAN_ACTIVITY_DROPPED_METRIC, + description="Temporal tracing span activities dropped after fail-open", + unit="1", + ) + mock_meter.create_counter.return_value.add.assert_called_once_with(1, {"event_type": "end"}) + mock_helpers.execute_activity.assert_called_once() + mock_service.end_span.assert_not_called() + + async def test_context_manager_skips_end_when_temporal_start_fails(self): + mock_service, module = _make_module() + + with patch.object(_tracing_mod, "in_temporal_workflow", return_value=True), patch.object( + _tracing_mod, "ActivityHelpers" + ) as mock_helpers, patch.object(_tracing_mod.workflow, "logger"): + mock_helpers.execute_activity = AsyncMock(side_effect=_make_activity_error()) + async with module.span(trace_id="trace-123", name="test-span") as span: + assert span is None + + mock_helpers.execute_activity.assert_called_once() + mock_service.start_span.assert_not_called() + mock_service.end_span.assert_not_called() + + async def test_start_span_in_workflow_propagates_unexpected_errors(self): + mock_service, module = _make_module() + + with patch.object(_tracing_mod, "in_temporal_workflow", return_value=True), patch.object( + _tracing_mod, "ActivityHelpers" + ) as mock_helpers: + mock_helpers.execute_activity = AsyncMock(side_effect=RuntimeError("bad response shape")) + try: + await module.start_span(trace_id="trace-123", name="test-span") + except RuntimeError as exc: + assert str(exc) == "bad response shape" + else: + raise AssertionError("Expected unexpected errors to propagate") + + mock_helpers.execute_activity.assert_called_once() + mock_service.start_span.assert_not_called() + + async def test_start_span_in_workflow_propagates_cancellation(self): + mock_service, module = _make_module() + activity_error = _make_activity_error() + mock_meter = _make_metric_meter() + + with patch.object(_tracing_mod, "in_temporal_workflow", return_value=True), patch.object( + _tracing_mod, "ActivityHelpers" + ) as mock_helpers, patch.object(_tracing_mod, "is_cancelled_exception", return_value=True), patch.object( + _tracing_mod.workflow, "logger" + ) as mock_logger, patch.object(_tracing_mod.workflow, "metric_meter", return_value=mock_meter): + mock_helpers.execute_activity = AsyncMock(side_effect=activity_error) + + with pytest.raises(ActivityError): + await module.start_span(trace_id="trace-123", name="test-span") + + mock_logger.warning.assert_not_called() + mock_meter.create_counter.assert_not_called() + mock_helpers.execute_activity.assert_called_once() + mock_service.start_span.assert_not_called() + + async def test_end_span_in_workflow_propagates_cancellation(self): + mock_service, module = _make_module() + span = _make_span() + activity_error = _make_activity_error() + mock_meter = _make_metric_meter() + + with patch.object(_tracing_mod, "in_temporal_workflow", return_value=True), patch.object( + _tracing_mod, "ActivityHelpers" + ) as mock_helpers, patch.object(_tracing_mod, "is_cancelled_exception", return_value=True), patch.object( + _tracing_mod.workflow, "logger" + ) as mock_logger, patch.object(_tracing_mod.workflow, "metric_meter", return_value=mock_meter): + mock_helpers.execute_activity = AsyncMock(side_effect=activity_error) + + with pytest.raises(ActivityError): + await module.end_span(trace_id="trace-123", span=span) + + mock_logger.warning.assert_not_called() + mock_meter.create_counter.assert_not_called() + mock_helpers.execute_activity.assert_called_once() + mock_service.end_span.assert_not_called() + + +class TestSpanContextManager: + async def test_span_context_manager_forwards_task_id(self): + mock_service, module = _make_module() + started = _make_span(task_id="task-abc") + mock_service.start_span.return_value = started + mock_service.end_span.return_value = started + + with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): + async with module.span( + trace_id="trace-123", + name="test-span", + task_id="task-abc", + ) as span: + assert span is not None + assert span.task_id == "task-abc" + + assert mock_service.start_span.call_args.kwargs["task_id"] == "task-abc" + mock_service.end_span.assert_called_once() + + async def test_span_context_manager_records_and_reraises_body_error(self): + mock_service, module = _make_module() + started = _make_span() + mock_service.start_span.return_value = started + mock_service.end_span.return_value = started + + with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): + with pytest.raises(RuntimeError, match="boom"): + async with module.span(trace_id="trace-123", name="test-span"): + raise RuntimeError("boom") + + assert get_span_error(started) == { + "type": "RuntimeError", + "message": "boom", + "category": "unknown", + } + mock_service.end_span.assert_called_once_with(trace_id="trace-123", span=started) + + async def test_span_context_manager_noop_when_no_trace_id(self): + mock_service, module = _make_module() + + with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): + async with module.span(trace_id="", name="test-span") as span: + assert span is None + + mock_service.start_span.assert_not_called() + mock_service.end_span.assert_not_called() + + +class TestTurnSpan: + async def test_turn_span_records_aggregate_usage_in_data(self): + mock_service, module = _make_module() + started = _make_span(task_id="task-abc") + mock_service.start_span.return_value = started + mock_service.end_span.return_value = started + + with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): + async with module.turn_span( + trace_id="trace-123", + name="turn", + task_id="task-abc", + ) as turn: + assert isinstance(turn, TurnSpan) + turn.output = {"response": "hello"} + turn.record_usage( + usage={"input_tokens": 100, "output_tokens": 40, "total_tokens": 140}, + cost_usd=0.0125, + ) + + ended_span = mock_service.end_span.call_args.kwargs["span"] + assert ended_span.data["usage"] == { + "input_tokens": 100, + "output_tokens": 40, + "total_tokens": 140, + } + assert ended_span.data["cost_usd"] == 0.0125 + # The aggregate lives in data, never in output — output stays payload-only + assert ended_span.output == {"response": "hello"} + + async def test_turn_span_record_usage_with_turn_usage(self): + mock_service, module = _make_module() + started = _make_span() + mock_service.start_span.return_value = started + mock_service.end_span.return_value = started + + turn_usage = TurnUsage( + model="gpt-4o", + input_tokens=10, + output_tokens=5, + cached_input_tokens=2, + total_tokens=15, + cost_usd=0.5, + ) + with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): + async with module.turn_span(trace_id="trace-123", name="turn") as turn: + turn.record_usage(turn_usage) + + ended_span = mock_service.end_span.call_args.kwargs["span"] + # cost_usd is lifted out of the blob to data["cost_usd"] + assert ended_span.data["cost_usd"] == 0.5 + assert ended_span.data["usage"] == { + "model": "gpt-4o", + "input_tokens": 10, + "output_tokens": 5, + "cached_input_tokens": 2, + "total_tokens": 15, + "num_tool_calls": 0, + "num_reasoning_blocks": 0, + } + + async def test_turn_span_explicit_cost_overrides_turn_usage_cost(self): + mock_service, module = _make_module() + started = _make_span() + mock_service.start_span.return_value = started + mock_service.end_span.return_value = started + + with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): + async with module.turn_span(trace_id="trace-123", name="turn") as turn: + turn.record_usage(TurnUsage(input_tokens=1, cost_usd=0.5), cost_usd=0.75) + + ended_span = mock_service.end_span.call_args.kwargs["span"] + assert ended_span.data["cost_usd"] == 0.75 + + async def test_turn_span_warns_on_unrecognized_usage_keys(self, caplog): + mock_service, module = _make_module() + started = _make_span() + mock_service.start_span.return_value = started + mock_service.end_span.return_value = started + + with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): + async with module.turn_span(trace_id="trace-123", name="turn") as turn: + with caplog.at_level("WARNING"): + turn.record_usage(usage={"inputTokens": 10}) + + assert any("no recognized token keys" in message for message in caplog.messages) + + async def test_turn_span_preserves_existing_data(self): + mock_service, module = _make_module() + started = _make_span(data={"custom": "value"}) + mock_service.start_span.return_value = started + mock_service.end_span.return_value = started + + with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): + async with module.turn_span(trace_id="trace-123", name="turn", data={"custom": "value"}) as turn: + turn.record_usage(usage={"prompt_tokens": 3, "completion_tokens": 4}) + + ended_span = mock_service.end_span.call_args.kwargs["span"] + assert ended_span.data["custom"] == "value" + assert ended_span.data["usage"] == {"prompt_tokens": 3, "completion_tokens": 4} + + async def test_turn_span_warns_and_replaces_non_dict_data(self, caplog): + mock_service, module = _make_module() + started = _make_span(data=[{"item": 1}]) + mock_service.start_span.return_value = started + mock_service.end_span.return_value = started + + with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): + async with module.turn_span(trace_id="trace-123", name="turn") as turn: + with caplog.at_level("WARNING"): + turn.record_usage(usage={"input_tokens": 1, "output_tokens": 2}) + + assert any("existing data will be replaced" in message for message in caplog.messages) + ended_span = mock_service.end_span.call_args.kwargs["span"] + assert ended_span.data == {"usage": {"input_tokens": 1, "output_tokens": 2}} + + async def test_turn_span_dict_data_does_not_warn(self, caplog): + mock_service, module = _make_module() + started = _make_span(data={"custom": "value"}) + mock_service.start_span.return_value = started + mock_service.end_span.return_value = started + + with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): + async with module.turn_span(trace_id="trace-123", name="turn") as turn: + with caplog.at_level("WARNING"): + turn.record_usage(usage={"input_tokens": 1}) + + assert not any("existing data will be replaced" in message for message in caplog.messages) + + async def test_turn_span_cost_only(self): + mock_service, module = _make_module() + started = _make_span() + mock_service.start_span.return_value = started + mock_service.end_span.return_value = started + + with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): + async with module.turn_span(trace_id="trace-123", name="turn") as turn: + turn.record_usage(cost_usd=0.5) + + ended_span = mock_service.end_span.call_args.kwargs["span"] + assert ended_span.data == {"cost_usd": 0.5} + assert "usage" not in ended_span.data + + async def test_turn_span_noop_when_no_trace_id(self): + mock_service, module = _make_module() + + with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): + async with module.turn_span(trace_id="", name="turn") as turn: + assert turn.span is None + # Must not raise when tracing is disabled + turn.record_usage(usage={"input_tokens": 1}, cost_usd=0.1) + turn.output = {"response": "x"} + assert turn.output is None + + mock_service.start_span.assert_not_called() + mock_service.end_span.assert_not_called() diff --git a/tests/lib/adk/test_tracing_service.py b/tests/lib/adk/test_tracing_service.py new file mode 100644 index 000000000..dceb000f5 --- /dev/null +++ b/tests/lib/adk/test_tracing_service.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +from agentex.types.span import Span +from agentex.lib.core.services.adk.tracing import TracingService + + +def _make_span(**overrides) -> Span: + defaults = { + "id": "span-123", + "name": "test-span", + "start_time": datetime(2026, 1, 1, tzinfo=timezone.utc), + "trace_id": "trace-123", + } + defaults.update(overrides) + return Span(**defaults) + + +def _make_service() -> tuple[MagicMock, MagicMock, TracingService]: + """Build a TracingService backed by an AsyncTracer whose + trace.start_span / trace.end_span are mocked.""" + mock_trace = MagicMock() + mock_trace.start_span = AsyncMock() + mock_trace.end_span = AsyncMock() + + mock_tracer = MagicMock() + mock_tracer.trace.return_value = mock_trace + + service = TracingService(tracer=mock_tracer) + return mock_tracer, mock_trace, service + + +class TestStartSpanService: + async def test_start_span_passes_task_id(self): + mock_tracer, mock_trace, service = _make_service() + expected = _make_span(task_id="task-abc") + mock_trace.start_span.return_value = expected + + result = await service.start_span( + trace_id="trace-123", + name="test-span", + task_id="task-abc", + ) + + assert result == expected + mock_tracer.trace.assert_called_once_with("trace-123") + mock_trace.start_span.assert_awaited_once_with( + name="test-span", + parent_id=None, + input={}, + data=None, + task_id="task-abc", + ) + + async def test_start_span_without_task_id(self): + _mock_tracer, mock_trace, service = _make_service() + expected = _make_span() + mock_trace.start_span.return_value = expected + + result = await service.start_span(trace_id="trace-123", name="test-span") + + assert result == expected + mock_trace.start_span.assert_awaited_once_with( + name="test-span", + parent_id=None, + input={}, + data=None, + task_id=None, + ) + + +class TestEndSpanService: + async def test_end_span_forwards_span(self): + mock_tracer, mock_trace, service = _make_service() + span = _make_span(task_id="task-abc") + mock_trace.end_span.return_value = span + + result = await service.end_span(trace_id="trace-123", span=span) + + assert result is span + mock_tracer.trace.assert_called_once_with("trace-123") + mock_trace.end_span.assert_awaited_once_with(span) diff --git a/tests/lib/cli/__init__.py b/tests/lib/cli/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/lib/cli/test_agent_handlers.py b/tests/lib/cli/test_agent_handlers.py new file mode 100644 index 000000000..73c29cfbb --- /dev/null +++ b/tests/lib/cli/test_agent_handlers.py @@ -0,0 +1,424 @@ +"""Tests for agent_handlers module - prepare_cloud_build_context and package CLI command.""" + +from __future__ import annotations + +import os +import tarfile +import tempfile +from pathlib import Path +from collections.abc import Iterator + +import pytest +from typer.testing import CliRunner + +from agentex.lib.cli.commands.agents import agents +from agentex.lib.cli.handlers.agent_handlers import ( + CloudBuildContext, + parse_build_args, + prepare_cloud_build_context, +) + +runner = CliRunner() + + +class TestParseBuildArgs: + """Tests for parse_build_args helper function.""" + + def test_parse_empty_build_args(self): + """Test parsing None or empty list returns empty dict.""" + assert parse_build_args(None) == {} + assert parse_build_args([]) == {} + + def test_parse_single_build_arg(self): + """Test parsing a single KEY=VALUE argument.""" + result = parse_build_args(["FOO=bar"]) + assert result == {"FOO": "bar"} + + def test_parse_multiple_build_args(self): + """Test parsing multiple KEY=VALUE arguments.""" + result = parse_build_args(["FOO=bar", "BAZ=qux", "NUM=123"]) + assert result == {"FOO": "bar", "BAZ": "qux", "NUM": "123"} + + def test_parse_build_arg_with_equals_in_value(self): + """Test that values containing '=' are handled correctly.""" + result = parse_build_args(["URL=https://example.com?foo=bar"]) + assert result == {"URL": "https://example.com?foo=bar"} + + def test_parse_invalid_build_arg_ignored(self): + """Test that invalid format args (no '=') are ignored.""" + result = parse_build_args(["VALID=value", "invalid_no_equals"]) + assert result == {"VALID": "value"} + + +class TestPrepareCloudBuildContext: + """Tests for prepare_cloud_build_context function.""" + + @pytest.fixture + def temp_agent_dir(self) -> Iterator[Path]: + """Create a temporary agent directory with minimal required files.""" + with tempfile.TemporaryDirectory() as tmpdir: + agent_dir = Path(tmpdir) + + # Create a minimal Dockerfile + dockerfile = agent_dir / "Dockerfile" + dockerfile.write_text("FROM python:3.12-slim\nCMD ['echo', 'hello']") + + # Create a simple Python file to include + src_dir = agent_dir / "src" + src_dir.mkdir() + (src_dir / "main.py").write_text("print('hello')") + + # Create manifest.yaml + manifest = agent_dir / "manifest.yaml" + manifest.write_text( + """ +build: + context: + root: . + include_paths: + - src + dockerfile: Dockerfile + +agent: + name: test-agent + acp_type: sync + description: Test agent + temporal: + enabled: false + +deployment: + image: + repository: test-repo/test-agent + tag: v1.0.0 +""" + ) + + yield agent_dir + + @pytest.fixture + def temp_agent_dir_no_deployment(self) -> Iterator[Path]: + """Create a temporary agent directory without deployment config.""" + with tempfile.TemporaryDirectory() as tmpdir: + agent_dir = Path(tmpdir) + + dockerfile = agent_dir / "Dockerfile" + dockerfile.write_text("FROM python:3.12-slim") + + src_dir = agent_dir / "src" + src_dir.mkdir() + (src_dir / "main.py").write_text("print('hello')") + + manifest = agent_dir / "manifest.yaml" + manifest.write_text( + """ +build: + context: + root: . + include_paths: + - src + dockerfile: Dockerfile + +agent: + name: test-agent-no-deploy + acp_type: sync + description: Test agent without deployment config + temporal: + enabled: false +""" + ) + + yield agent_dir + + def test_prepare_cloud_build_context_returns_cloud_build_context( + self, temp_agent_dir: Path + ): + """Test that prepare_cloud_build_context returns a CloudBuildContext.""" + manifest_path = str(temp_agent_dir / "manifest.yaml") + + result = prepare_cloud_build_context(manifest_path=manifest_path) + + assert isinstance(result, CloudBuildContext) + assert result.agent_name == "test-agent" + assert result.tag == "v1.0.0" # From manifest deployment.image.tag + assert result.image_name == "test-agent" # Last part of repository + assert result.dockerfile_path == "Dockerfile" + assert len(result.archive_bytes) > 0 + assert result.build_context_size_kb > 0 + + def test_prepare_cloud_build_context_with_tag_override(self, temp_agent_dir: Path): + """Test that tag parameter overrides manifest tag.""" + manifest_path = str(temp_agent_dir / "manifest.yaml") + + result = prepare_cloud_build_context(manifest_path=manifest_path, tag="custom-tag") + + assert result.tag == "custom-tag" + + def test_prepare_cloud_build_context_defaults_to_latest_when_no_deployment( + self, temp_agent_dir_no_deployment: Path + ): + """Test that tag defaults to 'latest' when no deployment config exists.""" + manifest_path = str(temp_agent_dir_no_deployment / "manifest.yaml") + + result = prepare_cloud_build_context(manifest_path=manifest_path) + + assert result.tag == "latest" + assert result.image_name == "" # No repository in deployment config + + def test_prepare_cloud_build_context_archive_is_valid_tarball( + self, temp_agent_dir: Path + ): + """Test that the archive bytes are a valid tar.gz file.""" + manifest_path = str(temp_agent_dir / "manifest.yaml") + + result = prepare_cloud_build_context(manifest_path=manifest_path) + + # Write to temp file and verify it's a valid tar.gz + with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as f: + f.write(result.archive_bytes) + temp_tar_path = f.name + + try: + with tarfile.open(temp_tar_path, "r:gz") as tar: + names = tar.getnames() + # Should contain Dockerfile and src/main.py + assert "Dockerfile" in names + assert "src/main.py" in names + finally: + os.unlink(temp_tar_path) + + def test_prepare_cloud_build_context_missing_dockerfile_raises_error(self): + """Test that missing Dockerfile raises FileNotFoundError.""" + with tempfile.TemporaryDirectory() as tmpdir: + agent_dir = Path(tmpdir) + + # Create manifest pointing to non-existent Dockerfile + manifest = agent_dir / "manifest.yaml" + manifest.write_text( + """ +build: + context: + root: . + include_paths: [] + dockerfile: NonExistentDockerfile + +agent: + name: test-agent + acp_type: sync + description: Test agent + temporal: + enabled: false +""" + ) + + with pytest.raises(FileNotFoundError, match="Dockerfile not found"): + prepare_cloud_build_context(manifest_path=str(manifest)) + + def test_prepare_cloud_build_context_dockerfile_is_directory_raises_error(self): + """Test that Dockerfile path pointing to directory raises ValueError.""" + with tempfile.TemporaryDirectory() as tmpdir: + agent_dir = Path(tmpdir) + + # Create a directory instead of a file for Dockerfile + dockerfile_dir = agent_dir / "Dockerfile" + dockerfile_dir.mkdir() + + manifest = agent_dir / "manifest.yaml" + manifest.write_text( + """ +build: + context: + root: . + include_paths: [] + dockerfile: Dockerfile + +agent: + name: test-agent + acp_type: sync + description: Test agent + temporal: + enabled: false +""" + ) + + with pytest.raises(ValueError, match="not a file"): + prepare_cloud_build_context(manifest_path=str(manifest)) + + def test_prepare_cloud_build_context_with_build_args(self, temp_agent_dir: Path): + """Test that build_args are accepted (they're logged but not included in archive).""" + manifest_path = str(temp_agent_dir / "manifest.yaml") + + # Should not raise - build_args are accepted even though they're just logged + result = prepare_cloud_build_context( + manifest_path=manifest_path, + build_args=["ARG1=value1", "ARG2=value2"], + ) + + assert isinstance(result, CloudBuildContext) + + +class TestPackageCommand: + """Tests for the 'agentex agents package' CLI command.""" + + @pytest.fixture + def temp_agent_dir(self) -> Iterator[Path]: + """Create a temporary agent directory with minimal required files.""" + with tempfile.TemporaryDirectory() as tmpdir: + agent_dir = Path(tmpdir) + + dockerfile = agent_dir / "Dockerfile" + dockerfile.write_text("FROM python:3.12-slim\nCMD ['echo', 'hello']") + + src_dir = agent_dir / "src" + src_dir.mkdir() + (src_dir / "main.py").write_text("print('hello')") + + manifest = agent_dir / "manifest.yaml" + manifest.write_text( + """ +build: + context: + root: . + include_paths: + - src + dockerfile: Dockerfile + +agent: + name: test-agent + acp_type: sync + description: Test agent + temporal: + enabled: false + +deployment: + image: + repository: test-repo/test-agent + tag: v1.0.0 +""" + ) + + yield agent_dir + + def test_package_command_creates_tarball(self, temp_agent_dir: Path): + """Test that package command creates a tarball file.""" + manifest_path = str(temp_agent_dir / "manifest.yaml") + + # Change to temp dir so output goes there + original_cwd = os.getcwd() + os.chdir(temp_agent_dir) + + try: + result = runner.invoke(agents, ["package", "--manifest", manifest_path]) + + assert result.exit_code == 0, f"Command failed: {result.output}" + assert "Tarball saved to:" in result.output + + # Check that tarball was created + expected_tarball = temp_agent_dir / "test-agent-v1.0.0.tar.gz" + assert expected_tarball.exists() + + # Verify it's a valid tar.gz + with tarfile.open(expected_tarball, "r:gz") as tar: + names = tar.getnames() + assert "Dockerfile" in names + finally: + os.chdir(original_cwd) + + def test_package_command_with_custom_tag(self, temp_agent_dir: Path): + """Test package command with custom tag override.""" + manifest_path = str(temp_agent_dir / "manifest.yaml") + + original_cwd = os.getcwd() + os.chdir(temp_agent_dir) + + try: + result = runner.invoke( + agents, ["package", "--manifest", manifest_path, "--tag", "custom-tag"] + ) + + assert result.exit_code == 0, f"Command failed: {result.output}" + + # Check that tarball with custom tag was created + expected_tarball = temp_agent_dir / "test-agent-custom-tag.tar.gz" + assert expected_tarball.exists() + finally: + os.chdir(original_cwd) + + def test_package_command_with_custom_output(self, temp_agent_dir: Path): + """Test package command with custom output filename.""" + manifest_path = str(temp_agent_dir / "manifest.yaml") + + original_cwd = os.getcwd() + os.chdir(temp_agent_dir) + + try: + result = runner.invoke( + agents, + ["package", "--manifest", manifest_path, "--output", "my-custom-output.tar.gz"], + ) + + assert result.exit_code == 0, f"Command failed: {result.output}" + + expected_tarball = temp_agent_dir / "my-custom-output.tar.gz" + assert expected_tarball.exists() + finally: + os.chdir(original_cwd) + + def test_package_command_missing_manifest(self, temp_agent_dir: Path): + """Test package command fails gracefully with missing manifest.""" + original_cwd = os.getcwd() + os.chdir(temp_agent_dir) + + try: + result = runner.invoke( + agents, ["package", "--manifest", "nonexistent-manifest.yaml"] + ) + + assert result.exit_code == 1 + assert "manifest not found" in result.output + finally: + os.chdir(original_cwd) + + def test_package_command_shows_build_parameters(self, temp_agent_dir: Path): + """Test that package command outputs build parameters for cloud build.""" + manifest_path = str(temp_agent_dir / "manifest.yaml") + + original_cwd = os.getcwd() + os.chdir(temp_agent_dir) + + try: + result = runner.invoke(agents, ["package", "--manifest", manifest_path]) + + assert result.exit_code == 0, f"Command failed: {result.output}" + assert "Build Parameters for Cloud Build API:" in result.output + assert "agent_name:" in result.output + assert "test-agent" in result.output + assert "image_name:" in result.output + assert "tag:" in result.output + finally: + os.chdir(original_cwd) + + def test_package_command_with_build_args(self, temp_agent_dir: Path): + """Test package command with build arguments.""" + manifest_path = str(temp_agent_dir / "manifest.yaml") + + original_cwd = os.getcwd() + os.chdir(temp_agent_dir) + + try: + result = runner.invoke( + agents, + [ + "package", + "--manifest", + manifest_path, + "--build-arg", + "ARG1=value1", + "--build-arg", + "ARG2=value2", + ], + ) + + assert result.exit_code == 0, f"Command failed: {result.output}" + assert "build_args:" in result.output + finally: + os.chdir(original_cwd) diff --git a/tests/lib/cli/test_environment_config.py b/tests/lib/cli/test_environment_config.py new file mode 100644 index 000000000..c7ed0b3f6 --- /dev/null +++ b/tests/lib/cli/test_environment_config.py @@ -0,0 +1,346 @@ +"""Tests for AgentEnvironmentsConfig.""" + +import tempfile + +import pytest + +from agentex.lib.sdk.config.environment_config import ( + AgentAuthConfig, + AgentKubernetesConfig, + AgentEnvironmentConfig, + AgentEnvironmentsConfig, + load_environments_config, +) + + +class TestAgentEnvironmentsConfig: + """Test cases for AgentEnvironmentsConfig.get_config_for_env method.""" + + @pytest.fixture + def single_env_config(self) -> AgentEnvironmentsConfig: + """Config with a single environment using direct key name.""" + return AgentEnvironmentsConfig( + schema_version="v1", + environments={ + "dev": AgentEnvironmentConfig( + kubernetes=AgentKubernetesConfig(namespace="dev-ns"), + auth=AgentAuthConfig(principal={"user_id": "dev-user"}), + ) + }, + ) + + @pytest.fixture + def multi_env_config(self) -> AgentEnvironmentsConfig: + """Config with multiple environments using direct key names.""" + return AgentEnvironmentsConfig( + schema_version="v1", + environments={ + "dev": AgentEnvironmentConfig( + kubernetes=AgentKubernetesConfig(namespace="dev-ns"), + auth=AgentAuthConfig(principal={"user_id": "dev-user"}), + ), + "staging": AgentEnvironmentConfig( + kubernetes=AgentKubernetesConfig(namespace="staging-ns"), + auth=AgentAuthConfig(principal={"user_id": "staging-user"}), + ), + "prod": AgentEnvironmentConfig( + kubernetes=AgentKubernetesConfig(namespace="prod-ns"), + auth=AgentAuthConfig(principal={"user_id": "prod-user"}), + ), + }, + ) + + @pytest.fixture + def multi_cluster_same_env_config(self) -> AgentEnvironmentsConfig: + """Config with multiple clusters mapping to the same environment keyword.""" + return AgentEnvironmentsConfig( + schema_version="v1", + environments={ + "dev-aws": AgentEnvironmentConfig( + kubernetes=AgentKubernetesConfig(namespace="dev-ns-aws"), + environment="dev", + auth=AgentAuthConfig(principal={"user_id": "dev-aws-user"}), + ), + "dev-gcp": AgentEnvironmentConfig( + kubernetes=AgentKubernetesConfig(namespace="dev-ns-gcp"), + environment="dev", + auth=AgentAuthConfig(principal={"user_id": "dev-gcp-user"}), + ), + "prod": AgentEnvironmentConfig( + kubernetes=AgentKubernetesConfig(namespace="prod-ns"), + auth=AgentAuthConfig(principal={"user_id": "prod-user"}), + ), + }, + ) + + def test_get_config_by_exact_key_match(self, single_env_config: AgentEnvironmentsConfig): + """Test that exact key match returns the correct config.""" + result = single_env_config.get_config_for_env("dev") + assert result is not None + + def test_get_config_nonexistent_env_raises_error(self, single_env_config: AgentEnvironmentsConfig): + """Test that requesting non-existent environment raises ValueError.""" + with pytest.raises(ValueError, match="not found"): + single_env_config.get_config_for_env("nonexistent") + + def test_get_config_exact_key_with_multiple_envs(self, multi_env_config: AgentEnvironmentsConfig): + """Test getting config by exact key when multiple environments exist.""" + result = multi_env_config.get_config_for_env("staging") + assert result is not None + + def test_get_config_by_specific_cluster_name(self, multi_cluster_same_env_config: AgentEnvironmentsConfig): + """Test getting config by specific cluster name (e.g., dev-aws).""" + result = multi_cluster_same_env_config.get_config_for_env("dev-aws") + assert result is not None + + def test_get_configs_without_explicit_mapping(self, single_env_config: AgentEnvironmentsConfig): + """Test getting config without explicit mapping returns a dict with env name as key.""" + result = single_env_config.get_configs_for_env("dev") + assert isinstance(result, dict) + assert len(result) == 1 + assert "dev" in result + assert result["dev"] == single_env_config.get_config_for_env("dev") + + def test_multiple_envs_same_keyword_returns_multiple(self, multi_cluster_same_env_config: AgentEnvironmentsConfig): + """Test that querying 'dev' when multiple envs have environment='dev' returns multiple. + + Returns a dict mapping env names (dev-aws, dev-gcp) to their configs. + """ + result = multi_cluster_same_env_config.get_configs_for_env("dev") + assert isinstance(result, dict) + assert len(result) == 2 + assert "dev-aws" in result + assert "dev-gcp" in result + assert result["dev-aws"].kubernetes.namespace == "dev-ns-aws" + assert result["dev-gcp"].kubernetes.namespace == "dev-ns-gcp" + + def test_list_environments(self, multi_env_config: AgentEnvironmentsConfig): + """Test listing all environment names.""" + envs = multi_env_config.list_environments() + assert set(envs) == {"dev", "staging", "prod"} + + +class TestLoadEnvironmentsConfig: + """Test cases for the load_environments_config yaml loader.""" + + def test_load_single_env_yaml(self): + """Test loading a YAML file with a single environment.""" + yaml_content = """ +schema_version: v1 +environments: + dev: + kubernetes: + namespace: dev-namespace + auth: + principal: + user_id: "user-123" + account_id: "account-456" +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write(yaml_content) + f.flush() + + config = load_environments_config(f.name) + + assert config.schema_version == "v1" + assert "dev" in config.environments + assert config.environments["dev"].kubernetes.namespace == "dev-namespace" + assert config.environments["dev"].auth.principal["user_id"] == "user-123" + + def test_load_multi_env_yaml(self): + """Test loading a YAML file with multiple environments.""" + yaml_content = """ +schema_version: v1 +environments: + dev: + kubernetes: + namespace: dev-namespace + auth: + principal: + user_id: "dev-user" + account_id: "dev-account" + prod: + kubernetes: + namespace: prod-namespace + auth: + principal: + user_id: "prod-user" + account_id: "prod-account" +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write(yaml_content) + f.flush() + + config = load_environments_config(f.name) + + assert "dev" in config.environments + assert "prod" in config.environments + assert config.environments["dev"].kubernetes.namespace == "dev-namespace" + assert config.environments["prod"].kubernetes.namespace == "prod-namespace" + + def test_load_yaml_with_environment_field_mapping(self): + """Test loading YAML where environments use the 'environment' field for mapping.""" + yaml_content = """ +schema_version: v1 +environments: + dev-aws: + environment: dev + kubernetes: + namespace: dev-aws-ns + auth: + principal: + user_id: "aws-user" + dev-gcp: + environment: dev + kubernetes: + namespace: dev-gcp-ns + auth: + principal: + user_id: "gcp-user" +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write(yaml_content) + f.flush() + + config = load_environments_config(f.name) + + assert config.environments["dev-aws"].environment == "dev" + assert config.environments["dev-gcp"].environment == "dev" + assert config.environments["dev-aws"].kubernetes.namespace == "dev-aws-ns" + assert config.environments["dev-gcp"].kubernetes.namespace == "dev-gcp-ns" + + def test_load_yaml_with_helm_overrides(self): + """Test loading YAML with helm_overrides.""" + yaml_content = """ +schema_version: v1 +environments: + dev: + kubernetes: + namespace: dev-namespace + auth: + principal: + user_id: "user-123" + helm_overrides: + replicaCount: 3 + resources: + requests: + cpu: "500m" + memory: "1Gi" +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write(yaml_content) + f.flush() + + config = load_environments_config(f.name) + + assert config.environments["dev"].helm_overrides["replicaCount"] == 3 + assert config.environments["dev"].helm_overrides["resources"]["requests"]["cpu"] == "500m" + + def test_load_yaml_with_custom_helm_repo(self): + """Test loading YAML with custom helm repository settings.""" + yaml_content = """ +schema_version: v1 +environments: + dev: + kubernetes: + namespace: dev-namespace + auth: + principal: + user_id: "user-123" + helm_repository_name: custom-repo + helm_repository_url: https://custom.example.com/charts +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write(yaml_content) + f.flush() + + config = load_environments_config(f.name) + + assert config.environments["dev"].helm_repository_name == "custom-repo" + assert config.environments["dev"].helm_repository_url == "https://custom.example.com/charts" + + def test_load_nonexistent_yaml_raises_file_not_found(self): + """Test that loading non-existent file raises FileNotFoundError.""" + with pytest.raises(FileNotFoundError, match="environments.yaml not found"): + load_environments_config("/nonexistent/path/environments.yaml") + + def test_load_empty_yaml_raises_value_error(self): + """Test that loading empty YAML file raises ValueError.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write("") + f.flush() + + with pytest.raises(ValueError, match="empty"): + load_environments_config(f.name) + + def test_load_invalid_yaml_syntax_raises_value_error(self): + """Test that loading invalid YAML syntax raises ValueError.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write("invalid: yaml: content: [") + f.flush() + + with pytest.raises(ValueError, match="Invalid YAML"): + load_environments_config(f.name) + + def test_load_yaml_missing_required_auth_raises_error(self): + """Test that YAML missing required 'auth' field raises validation error.""" + yaml_content = """ +schema_version: v1 +environments: + dev: + kubernetes: + namespace: dev-namespace +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write(yaml_content) + f.flush() + + with pytest.raises(ValueError, match="Failed to load"): + load_environments_config(f.name) + + def test_load_yaml_with_oci_registry(self): + """Test loading YAML with nested oci_registry configuration.""" + yaml_content = """ +schema_version: v1 +environments: + dev: + kubernetes: + namespace: dev-namespace + auth: + principal: + user_id: "user-123" + oci_registry: + url: us-west1-docker.pkg.dev/my-project/my-repo + provider: gar + chart_version: "0.2.0" +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write(yaml_content) + f.flush() + + config = load_environments_config(f.name) + + env = config.environments["dev"] + assert env.oci_registry is not None + assert env.oci_registry.url == "us-west1-docker.pkg.dev/my-project/my-repo" + assert env.oci_registry.provider == "gar" + assert env.oci_registry.chart_version == "0.2.0" + + def test_load_yaml_without_oci_registry(self): + """Test that oci_registry is None when not specified in YAML.""" + yaml_content = """ +schema_version: v1 +environments: + dev: + kubernetes: + namespace: dev-namespace + auth: + principal: + user_id: "user-123" +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write(yaml_content) + f.flush() + + config = load_environments_config(f.name) + assert config.environments["dev"].oci_registry is None diff --git a/tests/lib/cli/test_init_templates.py b/tests/lib/cli/test_init_templates.py new file mode 100644 index 000000000..ec809cbbf --- /dev/null +++ b/tests/lib/cli/test_init_templates.py @@ -0,0 +1,139 @@ +"""Tests for the `agentex init` project templates. + +These render the Jinja templates the way the CLI does and assert that: + +- every template type's declared project files exist and render, +- rendered Python parses (catches `.j2` syntax/templating regressions), +- the agent-specific context (names, workflow class) is substituted in, +- the Temporal + LangGraph template is fully wired (enum, file map, root files). + +The Temporal + LangGraph template is the focus, but the parametrized smoke +test covers every template so a broken `.j2` anywhere is caught early. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +from agentex.lib.cli.commands.init import ( + TemplateType, + get_project_context, + create_project_structure, +) + + +def _context(template_type: TemplateType, use_uv: bool = True) -> dict: + """Build the same render context the CLI assembles from user answers.""" + answers = { + "template_type": template_type, + "project_path": ".", + "agent_name": "my-agent", + "agent_directory_name": "my-agent", + "description": "An Agentex agent", + "use_uv": use_uv, + } + context = get_project_context(answers, Path("."), Path("../../")) + context["template_type"] = template_type.value + context["use_uv"] = use_uv + return context + + +def _render_project(tmp_path: Path, template_type: TemplateType, use_uv: bool = True) -> Path: + context = _context(template_type, use_uv=use_uv) + create_project_structure(tmp_path, context, template_type, use_uv=use_uv) + return tmp_path / context["project_name"] + + +@pytest.mark.parametrize("template_type", list(TemplateType)) +def test_all_templates_render_to_valid_python(tmp_path: Path, template_type: TemplateType): + """Every template renders, and every rendered .py file is syntactically valid.""" + project_dir = _render_project(tmp_path, template_type) + + py_files = list(project_dir.rglob("*.py")) + assert py_files, f"{template_type.value} produced no Python files" + + for py_file in py_files: + source = py_file.read_text() + # Raises SyntaxError if a rendered template is broken. + ast.parse(source, filename=str(py_file)) + + +class TestTemporalLangGraphTemplate: + """Focused coverage for the new Temporal + LangGraph template.""" + + template_type = TemplateType.TEMPORAL_LANGGRAPH + + def test_enum_and_value(self): + assert TemplateType.TEMPORAL_LANGGRAPH.value == "temporal-langgraph" + + def test_expected_project_files_exist(self, tmp_path: Path): + project_dir = _render_project(tmp_path, self.template_type) + project_pkg = project_dir / "project" + for filename in ( + "acp.py", + "workflow.py", + "run_worker.py", + "graph.py", + "tools.py", + "__init__.py", + ): + assert (project_pkg / filename).is_file(), f"missing project/{filename}" + + def test_expected_root_files_exist(self, tmp_path: Path): + project_dir = _render_project(tmp_path, self.template_type) + for filename in ( + "manifest.yaml", + "README.md", + "environments.yaml", + ".env.example", + ".dockerignore", + "Dockerfile", + "dev.ipynb", + "pyproject.toml", + ): + assert (project_dir / filename).is_file(), f"missing {filename}" + + def test_workflow_class_substituted(self, tmp_path: Path): + project_dir = _render_project(tmp_path, self.template_type) + workflow_src = (project_dir / "project" / "workflow.py").read_text() + # agent_name "my-agent" -> workflow class "MyAgentWorkflow" + assert "class MyAgentWorkflow(BaseWorkflow):" in workflow_src + assert "{{" not in workflow_src, "unrendered Jinja left in workflow.py" + + def test_nodes_run_via_langgraph_plugin(self, tmp_path: Path): + """The defining trait: nodes run as Temporal activities via the plugin.""" + project_dir = _render_project(tmp_path, self.template_type) + graph_src = (project_dir / "project" / "graph.py").read_text() + # The agent (LLM) node is an activity; the tools node runs in-workflow. + assert '"execute_in": "activity"' in graph_src + assert '"execute_in": "workflow"' in graph_src + + # Both the worker and the ACP register the LangGraph plugin. + worker_src = (project_dir / "project" / "run_worker.py").read_text() + acp_src = (project_dir / "project" / "acp.py").read_text() + assert "LangGraphPlugin" in worker_src + assert "LangGraphPlugin" in acp_src + + def test_human_in_the_loop_and_queries_present(self, tmp_path: Path): + project_dir = _render_project(tmp_path, self.template_type) + workflow_src = (project_dir / "project" / "workflow.py").read_text() + graph_src = (project_dir / "project" / "graph.py").read_text() + # HIL: graph raises a langgraph interrupt; workflow resumes via signal + Command. + assert "interrupt(" in graph_src + assert "TOOLS_REQUIRING_APPROVAL" in graph_src + assert "def provide_approval" in workflow_src + assert "Command(resume=" in workflow_src + assert "wait_condition" in workflow_src + # Graph-visualization / introspection queries + for query in ("get_status", "get_graph_mermaid", "get_graph_ascii", "get_graph_state"): + assert query in workflow_src, f"missing query {query}" + + def test_requirements_include_langgraph_plugin_and_temporal(self, tmp_path: Path): + # requirements.txt only renders in the non-uv variant + project_dir = _render_project(tmp_path, self.template_type, use_uv=False) + requirements = (project_dir / "requirements.txt").read_text() + assert "temporalio[langgraph]>=1.27.0" in requirements + assert "langchain-openai" in requirements diff --git a/tests/lib/cli/test_run_handlers_streaming.py b/tests/lib/cli/test_run_handlers_streaming.py new file mode 100644 index 000000000..8f0ab13b5 --- /dev/null +++ b/tests/lib/cli/test_run_handlers_streaming.py @@ -0,0 +1,180 @@ +"""Tests for run_handlers output streaming. + +stream_process_output is the only reader of a child's stdout pipe. If it stops +reading, the pipe fills and the child blocks forever inside write(), which +presents as a silent freeze with no traceback. These tests pin the behaviour +that prevents that: a line the reader cannot handle is skipped, not fatal. +""" + +from __future__ import annotations + +import sys +import asyncio +from typing import Any + +import pytest + +from agentex.lib.cli.debug import DebugMode, DebugConfig +from agentex.lib.cli.handlers import run_handlers +from agentex.lib.cli.debug.debug_handlers import ( + start_acp_server_debug, + start_temporal_worker_debug, +) +from agentex.lib.cli.handlers.run_handlers import ( + SUBPROCESS_STREAM_LIMIT, + start_acp_server, + start_temporal_worker, + stream_process_output, +) + +# Emits a line of MARKER over the reader's limit, then enough further output to +# more than fill a 64 KiB pipe. If the reader stops draining, the child cannot +# finish its writes and never exits. +MARKER = "X" + +CHILD_SCRIPT = """ +print("before") +print("{marker}" * {oversized}) +for i in range(2000): + print("after", i, "y" * 60) +print("done") +""" + + +async def _drain(limit: int, oversized: int) -> int | None: + """Run the child under stream_process_output. None means it never exited.""" + process = await asyncio.create_subprocess_exec( + sys.executable, + "-c", + CHILD_SCRIPT.format(marker=MARKER, oversized=oversized), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + limit=limit, + ) + streamer = asyncio.create_task(stream_process_output(process, "TEST")) + try: + await asyncio.wait_for(asyncio.gather(streamer, process.wait()), timeout=60) + except TimeoutError: + process.kill() + await process.wait() + return None + return process.returncode + + +async def test_oversized_line_is_skipped_without_stalling_the_child( + capsys: pytest.CaptureFixture[str], +) -> None: + """A line past the reader's limit is dropped, and streaming continues. + + Before this was handled per line, readline() raised, the loop exited, and the + child deadlocked on a full pipe. The child reaching exit is the assertion. + """ + limit = 64 * 1024 + oversized = limit + 16_000 + + returncode = await _drain(limit=limit, oversized=oversized) + out = capsys.readouterr().out + + assert returncode == 0, "child did not exit: the reader stopped draining its pipe" + # The offending line is gone, but everything after it still streamed. + assert out.count(MARKER) == 0 + assert "done" in out + + +async def test_large_line_within_the_limit_is_streamed_in_full( + capsys: pytest.CaptureFixture[str], +) -> None: + """A line over asyncio's 64 KiB default still reaches the console under our limit. + + Counts marker characters rather than matching the line, because rich wraps + long output across terminal-width lines. + """ + oversized = 82_000 + + returncode = await _drain(limit=SUBPROCESS_STREAM_LIMIT, oversized=oversized) + out = capsys.readouterr().out + + assert returncode == 0 + assert out.count(MARKER) == oversized, "the large line was dropped rather than streamed" + + +class _AlwaysFailingReader: + """A reader whose readline() raises without consuming anything. + + The dangerous shape: skipping it makes no progress, so an unbounded retry + would spin at 100% CPU while still not draining the pipe. + """ + + def __init__(self) -> None: + self.attempts = 0 + + async def readline(self) -> bytes: + self.attempts += 1 + raise ValueError("unreadable, and nothing was consumed") + + +class _FakeProcess: + def __init__(self, stdout: Any) -> None: + self.stdout = stdout + + +async def test_repeated_unreadable_lines_give_up_instead_of_spinning() -> None: + """A ValueError that consumes nothing must not loop forever.""" + reader = _AlwaysFailingReader() + + await asyncio.wait_for( + stream_process_output(_FakeProcess(reader), "TEST"), timeout=30 + ) + + assert reader.attempts == run_handlers.MAX_CONSECUTIVE_READ_ERRORS + 1 + + +async def test_cancellation_is_not_swallowed() -> None: + """The auto-reload path cancels these tasks, so cancel must propagate. + + CancelledError derives from BaseException, so the outer `except Exception` + does not catch it. This pins that, since swallowing it would hang restarts. + """ + + class _NeverReturns: + async def readline(self) -> bytes: + await asyncio.sleep(3600) + return b"" + + task = asyncio.create_task(stream_process_output(_FakeProcess(_NeverReturns()), "TEST")) + await asyncio.sleep(0) + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + +async def test_every_spawn_uses_the_larger_limit( + monkeypatch: pytest.MonkeyPatch, tmp_path: Any +) -> None: + """Every spawn must pass limit=, including the debug ones. + + A subprocess left on asyncio's default overruns far more easily, and enough + consecutive overruns exhaust MAX_CONSECUTIVE_READ_ERRORS and stop the reader + draining, which is the deadlock the bound exists to avoid. + """ + seen: list[int | None] = [] + + async def fake_exec(*_args: Any, **kwargs: Any) -> None: + seen.append(kwargs.get("limit")) + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec) + monkeypatch.setattr(run_handlers, "calculate_uvicorn_target_for_local", lambda *_: "project.acp") + + await start_acp_server(tmp_path / "acp.py", 8000, {}, tmp_path) + await start_temporal_worker(tmp_path / "run_worker.py", {}, tmp_path) + + # BOTH, since each helper refuses unless its own mode is enabled. + debug_config = DebugConfig( + enabled=True, mode=DebugMode.BOTH, port=5678, wait_for_attach=False, auto_port=False + ) + await start_acp_server_debug(tmp_path / "acp.py", 8000, {}, debug_config) + await start_temporal_worker_debug(tmp_path / "run_worker.py", {}, debug_config) + + assert seen == [SUBPROCESS_STREAM_LIMIT] * 4, f"a spawn is missing limit=: {seen}" + assert SUBPROCESS_STREAM_LIMIT > 64 * 1024, "asyncio's default is what breaks readline()" diff --git a/tests/lib/cli/test_validation.py b/tests/lib/cli/test_validation.py new file mode 100644 index 000000000..c921d46e3 --- /dev/null +++ b/tests/lib/cli/test_validation.py @@ -0,0 +1,76 @@ +"""Tests for the auth-principal portion of the environments.yaml validator. + +Covers the rule that an env config's principal must carry exactly one of +`user_id` or `service_account_id` — the same shape that downstream services +(agentex-auth, SGP) expect on the wire. +""" + +import pytest + +from agentex.lib.sdk.config.validation import ( + EnvironmentsValidationError, + validate_environments_config, +) +from agentex.lib.sdk.config.environment_config import ( + AgentAuthConfig, + AgentKubernetesConfig, + AgentEnvironmentConfig, + AgentEnvironmentsConfig, +) + + +def _config_with_principal(principal: dict) -> AgentEnvironmentsConfig: + return AgentEnvironmentsConfig( + schema_version="v1", + environments={ + "dev": AgentEnvironmentConfig( + kubernetes=AgentKubernetesConfig(namespace="dev-ns"), + auth=AgentAuthConfig(principal=principal), + ) + }, + ) + + +def test_user_only_principal_passes(): + """Existing user_id-only configs continue to validate (backwards compat).""" + config = _config_with_principal({"user_id": "73d0c8bd-4726-434c-9686-eb627d89f078", "account_id": "acct-1"}) + + validate_environments_config(config) + + +def test_service_account_only_principal_passes(): + """New service_account_id-only configs validate.""" + config = _config_with_principal( + {"service_account_id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", "account_id": "acct-1"} + ) + + validate_environments_config(config) + + +def test_principal_with_neither_id_is_rejected(): + """A principal with no identity id fails fast with a clear error.""" + config = _config_with_principal({"account_id": "acct-1"}) + + with pytest.raises(EnvironmentsValidationError) as exc_info: + validate_environments_config(config) + + msg = str(exc_info.value) + assert "user_id" in msg + assert "service_account_id" in msg + + +def test_principal_with_both_ids_is_rejected(): + """Setting both ids is a config error — the principal must commit to one identity type.""" + config = _config_with_principal( + { + "user_id": "73d0c8bd-4726-434c-9686-eb627d89f078", + "service_account_id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "account_id": "acct-1", + } + ) + + with pytest.raises(EnvironmentsValidationError) as exc_info: + validate_environments_config(config) + + msg = str(exc_info.value) + assert "only one of" in msg.lower() or "not both" in msg.lower() diff --git a/tests/lib/core/__init__.py b/tests/lib/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/lib/core/harness/__init__.py b/tests/lib/core/harness/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/lib/core/harness/_fakes.py b/tests/lib/core/harness/_fakes.py new file mode 100644 index 000000000..f9fd34a45 --- /dev/null +++ b/tests/lib/core/harness/_fakes.py @@ -0,0 +1,63 @@ +"""Shared test doubles for the unified harness test suites. + +A single superset implementation of the in-memory tracing backend used across +the harness tests. Three recording shapes were previously duplicated: + +- Shape-1 (richest): ``started`` = ``(name, parent_id, input)`` 3-tuples, + ``ended`` = ``(name, output)`` 2-tuples, plus an ``ended_spans`` list of the + closed ``FakeSpan`` objects (which carry ``.name``, ``.output``, ``.data``). +- Shape-2: ``started`` = ``(name, parent_id)`` 2-tuples, ``ended`` = + ``(name, output)``. +- Shape-3: ``started`` = bare names, ``ended`` = bare outputs. + +``FakeTracing`` records the richest (shape-1) form and exposes read-only +convenience properties (``started_names``, ``started_pairs``, +``ended_outputs``) so shape-2 and shape-3 assertions stay clean. +""" + +from __future__ import annotations + +from typing import Any + + +class FakeSpan: + def __init__(self, name: str) -> None: + self.name = name + self.output: Any = None + self.data: Any = None + + +class FakeTracing: + def __init__(self) -> None: + self.started: list[tuple[str, Any, Any]] = [] + self.ended: list[tuple[str, Any]] = [] + self.ended_spans: list[FakeSpan] = [] + + async def start_span( + self, + *, + trace_id: str, + name: str, + input: Any = None, + parent_id: Any = None, + data: Any = None, + task_id: Any = None, + ) -> FakeSpan: + self.started.append((name, parent_id, input)) + return FakeSpan(name) + + async def end_span(self, *, trace_id: str, span: FakeSpan) -> None: + self.ended.append((span.name, span.output)) + self.ended_spans.append(span) + + @property + def started_names(self) -> list[str]: + return [name for (name, _parent, _input) in self.started] + + @property + def started_pairs(self) -> list[tuple[str, Any]]: + return [(name, parent) for (name, parent, _input) in self.started] + + @property + def ended_outputs(self) -> list[Any]: + return [output for (_name, output) in self.ended] diff --git a/tests/lib/core/harness/conformance/__init__.py b/tests/lib/core/harness/conformance/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/lib/core/harness/conformance/conftest.py b/tests/lib/core/harness/conformance/conftest.py new file mode 100644 index 000000000..e4da7f1e2 --- /dev/null +++ b/tests/lib/core/harness/conformance/conftest.py @@ -0,0 +1,21 @@ +"""Conformance-suite test setup. + +Eagerly import every per-harness conformance module so each one's module-level +``register(...)`` calls run before any test executes. This makes +``all_fixtures()`` complete and independent of pytest's collection/import order +(the runner documents that cross-module registration order is not guaranteed), +so the cross-harness ``test_span_derivation_is_deterministic`` guard in +``test_conformance.py`` covers the full fixture set even when this directory is +run in isolation. +""" + +from __future__ import annotations + +# Importing these for their registration side effects only. +from . import ( + test_codex_conformance, # noqa: F401 + test_openai_conformance, # noqa: F401 + test_langgraph_conformance, # noqa: F401 + test_claude_code_conformance, # noqa: F401 + test_pydantic_ai_conformance, # noqa: F401 +) diff --git a/tests/lib/core/harness/conformance/runner.py b/tests/lib/core/harness/conformance/runner.py new file mode 100644 index 000000000..02a07f726 --- /dev/null +++ b/tests/lib/core/harness/conformance/runner.py @@ -0,0 +1,507 @@ +"""Shared conformance engine: every harness tap registers fixtures here. + +A fixture is (name, list[StreamTaskMessage]). The runner asserts two things: + +1. **Cross-channel logical equivalence**: yield_events and auto_send produce the + same *logical* sequence of delivered message contents. "Logical" means we + normalise away the streaming-envelope difference: + - yield channel delivers StreamTaskMessageFull(ToolResponseContent) verbatim. + - auto_send channel delivers the same tool-response by opening a streaming + context with the full content and closing it immediately (Start+Done on the + wire), not a Full event. + Both reduce to the same LogicalDelivery(type, identity, payload) tuple; the + conformance test compares those normalised sequences. + + `payload` carries the content that callers actually consume: + - text: initial_content.content prepended, then accumulated delta string + - reasoning: initial_content.summary joined, then accumulated delta string + - tool_request: the arguments dict (JSON-sorted), from Start content + - tool_response: the content value (str) + This catches a channel that delivers the right structural shape but corrupts, + drops, or omits initial_content (including reasoning summary) or payload. + +2. **Span signal equivalence**: each channel is driven with its own recording + tracer that captures every SpanSignal it actually receives in handle(); the + two channels' recorded signal lists must be identical. Comparing what each + channel genuinely emitted (rather than re-deriving from the events) catches a + regression where a channel skips deriver.observe() for some event type. + +Registry shared-state hazard: `_REGISTRY` is process-global. Every `test_*.py` +module that calls `register()` at import time contributes to it, so a module +that parametrizes over `all_fixtures()` will see fixtures registered by ANY +other conformance module imported earlier in the same pytest process (collection +order is not guaranteed). To stay deterministic, each future harness conformance +module should register and parametrize over its OWN fixtures (e.g. keep a +module-local list it both registers and parametrizes), rather than relying on +cross-module global accumulation via `all_fixtures()`. + +Design decision — Full-message handling in auto_send +---------------------------------------------------- +auto_send posts a StreamTaskMessageFull (tool_request or tool_response) by +opening a streaming context with the full content and closing it immediately, +rather than calling adk.messages.create. This open+close approach is retained +because: + - StreamingTaskMessageContext.close() persists initial_content when no deltas + have been streamed, so the message IS correctly persisted. + - It mirrors the pattern already used by the real langgraph streaming helper + (now in _langgraph_turn.py), keeping behavioural parity. + - Switching to adk.messages.create would require an additional injectable + dependency, adding surface area for no observable benefit. +The conformance test treats this as an ACCEPTABLE envelope difference: at the +logical-content level, Full(ToolResponseContent) from yield and +Start(content)+Done from auto_send are equivalent. The recorded span signals are +identical because both adapters drive the same SpanDeriver.observe() call +sequence and forward every signal to their tracer. + +auto_send DELIVERS streamed tool-request messages (Start+Done): both channels +produce a LogicalDelivery for a streamed tool_request, and the cross-channel +assertion verifies it is delivered on both. +""" + +from __future__ import annotations + +import json +from typing import Any, NamedTuple, override +from dataclasses import dataclass + +from agentex.types.text_delta import TextDelta +from agentex.types.task_message import TaskMessage +from agentex.lib.core.harness.types import SpanSignal, StreamTaskMessage +from agentex.lib.core.harness.tracer import SpanTracer +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.types.reasoning_content_delta import ReasoningContentDelta +from agentex.lib.core.harness.span_derivation import SpanDeriver + +from .._fakes import FakeTracing + + +@dataclass +class Fixture: + name: str + events: list[StreamTaskMessage] + + +_REGISTRY: list[Fixture] = [] + + +def register(fixture: Fixture) -> None: + _REGISTRY.append(fixture) + + +def all_fixtures() -> list[Fixture]: + return list(_REGISTRY) + + +def run_pure_async(coro: Any) -> Any: + """Drive a *pure* (I/O-free) coroutine to completion without an event loop. + + Conformance fixtures are built at import time so they can parametrize the + tests below. The fixture-building coroutines only iterate in-memory events + and never suspend on a real future, so we step them by hand instead of + ``asyncio.run()``. ``asyncio.run()`` at import raises ``RuntimeError`` when a + loop is already running (programmatic pytest, a Jupyter kernel, or a + session-scoped asyncio loop); this driver is unaffected by ambient loop + state. It raises if the coroutine ever suspends on real I/O. + """ + try: + coro.send(None) + except StopIteration as stop: + return stop.value + coro.close() + raise RuntimeError("conformance fixture build unexpectedly suspended on real I/O") + + +def derive_all(events: list[StreamTaskMessage]) -> list[SpanSignal]: + d = SpanDeriver() + out: list[SpanSignal] = [] + for e in events: + out.extend(d.observe(e)) + out.extend(d.flush()) + return out + + +# --------------------------------------------------------------------------- +# Logical delivery normalisation +# --------------------------------------------------------------------------- + + +class LogicalDelivery(NamedTuple): + """A single logically-delivered message, channel-agnostic. + + `content_type` is the .type of the content (e.g. "text", "reasoning", + "tool_request", "tool_response"). `identity` is a frozenset of key=value + pairs that uniquely identify the content (e.g. tool_call_id for tool + messages, or index for text/reasoning). `payload` is a stable string + representation of the content callers actually consume: + - text: initial_content.content prepended to accumulated delta strings + - reasoning: initial_content.summary joined, prepended to accumulated + reasoning-content delta strings + - tool_request: JSON-sorted arguments from Start content + - tool_response: str(content) from Full event + """ + + content_type: str + identity: frozenset[tuple[str, Any]] + payload: str = "" + + +def _yield_logical_deliveries(events: list[StreamTaskMessage]) -> list[LogicalDelivery]: + """Extract logical deliveries from the yield channel's event list. + + The yield channel forwards events verbatim. A logical delivery is: + - A Full event (tool_request / tool_response): content delivered as-is. + - A Start + ... + Done sequence for text/reasoning/tool_request content. + + The `payload` field captures the content callers consume: + - text: initial_content.content (from Start) prepended to accumulated deltas + - reasoning: initial_content.summary joined (from Start) prepended to + accumulated reasoning-content deltas (this catches a channel that drops + the summary) + - tool_request: JSON-sorted arguments from the Start content (delivered on + both channels) + - tool_response: str(content) from Full event + """ + from agentex.types.text_content import TextContent + from agentex.types.reasoning_content import ReasoningContent + from agentex.types.tool_request_content import ToolRequestContent + + deliveries: list[LogicalDelivery] = [] + # Track which indices had a Start so we can pair with Done + started: dict[int, Any] = {} # index -> initial content + # Accumulate delta text per index (seed with initial_content text if present) + accumulated: dict[int, list[str]] = {} # index -> list of delta strings + + for event in events: + if isinstance(event, StreamTaskMessageStart): + if event.index is not None: + started[event.index] = event.content + # Seed accumulator with initial_content so a channel that drops + # initial_content but delivers deltas correctly will fail. + seed: list[str] = [] + if isinstance(event.content, TextContent) and event.content.content: + seed = [event.content.content] + elif isinstance(event.content, ReasoningContent) and event.content.summary: + seed = list(event.content.summary) + accumulated[event.index] = seed + elif isinstance(event, StreamTaskMessageDelta): + if event.index is not None and event.delta is not None: + if isinstance(event.delta, TextDelta) and event.delta.text_delta: + accumulated.setdefault(event.index, []).append(event.delta.text_delta) + elif isinstance(event.delta, ReasoningContentDelta) and event.delta.content_delta: + accumulated.setdefault(event.index, []).append(event.delta.content_delta) + elif isinstance(event, StreamTaskMessageDone): + if event.index is not None and event.index in started: + content = started.pop(event.index) + deltas = accumulated.pop(event.index, []) + ctype = getattr(content, "type", None) or "" + if ctype in ("text", "reasoning"): + deliveries.append( + LogicalDelivery( + content_type=ctype, + identity=frozenset({("index", event.index)}), + payload="".join(deltas), + ) + ) + elif ctype == "tool_request" and isinstance(content, ToolRequestContent): + # auto_send delivers streamed tool-request messages. Emit a + # delivery here so the cross-channel assertion verifies it is + # present on both channels. + deliveries.append( + LogicalDelivery( + content_type=ctype, + identity=frozenset( + { + ("tool_call_id", content.tool_call_id), + ("name", content.name), + } + ), + payload=json.dumps(content.arguments, sort_keys=True), + ) + ) + elif isinstance(event, StreamTaskMessageFull): + content = event.content + ctype = getattr(content, "type", None) or "" + if ctype == "tool_response": + from agentex.types.tool_response_content import ToolResponseContent + + if isinstance(content, ToolResponseContent): + deliveries.append( + LogicalDelivery( + content_type=ctype, + identity=frozenset( + { + ("tool_call_id", content.tool_call_id), + ("name", content.name), + } + ), + payload=str(content.content), + ) + ) + elif ctype == "tool_request": + from agentex.types.tool_request_content import ToolRequestContent + + if isinstance(content, ToolRequestContent): + deliveries.append( + LogicalDelivery( + content_type=ctype, + identity=frozenset( + { + ("tool_call_id", content.tool_call_id), + ("name", content.name), + } + ), + payload=json.dumps(content.arguments, sort_keys=True), + ) + ) + + return deliveries + + +# --------------------------------------------------------------------------- +# Fake streaming backend for auto_send conformance runner +# --------------------------------------------------------------------------- + + +class _FakeCtx: + """Mirrors StreamingTaskMessageContext: __aenter__ opens, close() closes.""" + + def __init__(self, sink: list[Any], content_type: str, initial_content: Any) -> None: + self.sink = sink + self.content_type = content_type + self.task_message = TaskMessage( + id="msg-conformance", + task_id="conformance-task", + content=initial_content, + ) + + async def __aenter__(self) -> "_FakeCtx": + self.sink.append(("open", self.content_type, self.task_message.content)) + return self + + async def __aexit__(self, *args: Any) -> bool: + await self.close() + return False + + async def close(self) -> None: + self.sink.append(("close", self.content_type)) + + async def stream_update(self, update: Any) -> Any: + self.sink.append(("update", update)) + return update + + +class _FakeStreaming: + """Fake streaming backend; records every context lifecycle event.""" + + def __init__(self) -> None: + self.sink: list[Any] = [] + + def streaming_task_message_context( + self, + task_id: str, + initial_content: Any, + streaming_mode: str = "coalesced", + created_at: Any = None, + ) -> _FakeCtx: + ctype = getattr(initial_content, "type", None) or "" + self.sink.append(("ctx", ctype, initial_content)) + return _FakeCtx(self.sink, ctype, initial_content) + + +class _RecordingTracer(SpanTracer): + """SpanTracer that records every SpanSignal it actually receives. + + Each delivery channel calls `tracer.handle(signal)` for every signal it + derives from the stream, so `received_signals` captures what the channel + genuinely emitted — not a re-derivation. Comparing the two channels' + recorded lists catches regressions where a channel skips + `deriver.observe(event)` for some event type. + """ + + def __init__(self, tracing: Any) -> None: + super().__init__( + trace_id="conformance-trace", + parent_span_id="conformance-parent", + tracing=tracing, + ) + self.received_signals: list[SpanSignal] = [] + + @override + async def handle(self, signal: SpanSignal) -> None: + self.received_signals.append(signal) + await super().handle(signal) + + +async def _gen(events: list[StreamTaskMessage]): # type: ignore[return] + for e in events: + yield e + + +def _auto_send_logical_deliveries(sink: list[Any]) -> list[LogicalDelivery]: + """Extract logical deliveries from the auto_send fake streaming sink. + + Each context lifecycle in the sink looks like: + ("ctx", ctype, content) -- context created + ("open", ctype, content) -- context __aenter__ + [("update", delta), ...] -- optional deltas (StreamTaskMessageDelta) + ("close", ctype) -- context closed + + A logical delivery corresponds to each open+close pair. For text/reasoning + we identify by sequential position and build the payload by prepending the + initial_content text (TextContent.content) or summary (ReasoningContent.summary) + to accumulated deltas. This matches _yield_logical_deliveries so a channel + that drops initial_content or reasoning summary fails the comparison. + For tool messages we use tool_call_id + name and capture arguments/content. + """ + from agentex.types.text_content import TextContent + from agentex.types.reasoning_content import ReasoningContent + from agentex.types.tool_request_content import ToolRequestContent + from agentex.types.tool_response_content import ToolResponseContent + + deliveries: list[LogicalDelivery] = [] + open_idx = 0 + while open_idx < len(sink): + entry = sink[open_idx] + if entry[0] == "ctx": + ctype: str = entry[1] + content: Any = entry[2] + found_open = False + delta_parts: list[str] = [] + # Seed delta_parts with initial_content so payload comparison + # catches a channel that drops initial_content but delivers deltas. + if isinstance(content, TextContent) and content.content: + delta_parts = [content.content] + elif isinstance(content, ReasoningContent) and content.summary: + delta_parts = list(content.summary) + for j in range(open_idx + 1, len(sink)): + if sink[j][0] == "open" and sink[j][1] == ctype and not found_open: + found_open = True + elif found_open and sink[j][0] == "update": + # Accumulate delta content from StreamTaskMessageDelta + update = sink[j][1] + if isinstance(update, StreamTaskMessageDelta) and update.delta is not None: + if isinstance(update.delta, TextDelta) and update.delta.text_delta: + delta_parts.append(update.delta.text_delta) + elif isinstance(update.delta, ReasoningContentDelta) and update.delta.content_delta: + delta_parts.append(update.delta.content_delta) + elif sink[j][0] == "close" and sink[j][1] == ctype and found_open: + # Matched open+close: emit logical delivery with payload + if ctype in ("text", "reasoning"): + count = sum(1 for k in range(open_idx) if sink[k][0] == "ctx" and sink[k][1] == ctype) + deliveries.append( + LogicalDelivery( + content_type=ctype, + identity=frozenset({("seq", count)}), + payload="".join(delta_parts), + ) + ) + elif ctype == "tool_response": + if isinstance(content, ToolResponseContent): + deliveries.append( + LogicalDelivery( + content_type=ctype, + identity=frozenset( + { + ("tool_call_id", content.tool_call_id), + ("name", content.name), + } + ), + payload=str(content.content), + ) + ) + elif ctype == "tool_request": + if isinstance(content, ToolRequestContent): + deliveries.append( + LogicalDelivery( + content_type=ctype, + identity=frozenset( + { + ("tool_call_id", content.tool_call_id), + ("name", content.name), + } + ), + payload=json.dumps(content.arguments, sort_keys=True), + ) + ) + open_idx = j + 1 + break + else: + open_idx += 1 + else: + open_idx += 1 + + return deliveries + + +def _yield_text_reasoning_seq(deliveries: list[LogicalDelivery]) -> list[LogicalDelivery]: + """Re-key text/reasoning deliveries from index-based to seq-based identity. + + The yield channel uses event.index as identity; auto_send uses a sequential + counter. To compare across channels, normalise both to sequential position + within each content type. + """ + result: list[LogicalDelivery] = [] + counts: dict[str, int] = {} + for d in deliveries: + if d.content_type in ("text", "reasoning"): + seq = counts.get(d.content_type, 0) + counts[d.content_type] = seq + 1 + result.append( + LogicalDelivery( + content_type=d.content_type, + identity=frozenset({("seq", seq)}), + payload=d.payload, + ) + ) + else: + result.append(d) + return result + + +async def run_cross_channel_conformance( + fixture: Fixture, +) -> tuple[list[LogicalDelivery], list[LogicalDelivery], list[SpanSignal], list[SpanSignal]]: + """Run both channels over a fixture; return (yield_deliveries, auto_deliveries, + yield_spans, auto_spans). + + The caller asserts yield_deliveries == auto_deliveries and + yield_spans == auto_spans. The span signals are the ones each channel's + tracer ACTUALLY recorded while delivering (not a re-derivation), so a + regression where a channel skips deriver.observe() for some event type is + caught. + """ + from agentex.lib.core.harness.auto_send import auto_send + from agentex.lib.core.harness.yield_delivery import yield_events + + # --- yield channel --- + tracer_yield = _RecordingTracer(tracing=FakeTracing()) + yield_out = [e async for e in yield_events(_gen(fixture.events), tracer=tracer_yield)] + + # Span signals the yield channel actually emitted to its tracer + yield_spans = tracer_yield.received_signals + + # Logical deliveries from yield output + yield_deliveries = _yield_text_reasoning_seq(_yield_logical_deliveries(yield_out)) + + # --- auto_send channel --- + tracer_auto = _RecordingTracer(tracing=FakeTracing()) + fake_streaming = _FakeStreaming() + await auto_send( + _gen(fixture.events), + task_id="conformance-task", + tracer=tracer_auto, + streaming=fake_streaming, + ) + + # Span signals the auto_send channel actually emitted to its tracer + auto_spans = tracer_auto.received_signals + + # Logical deliveries from what the streaming backend received + auto_deliveries = _auto_send_logical_deliveries(fake_streaming.sink) + + return yield_deliveries, auto_deliveries, yield_spans, auto_spans diff --git a/tests/lib/core/harness/conformance/test_claude_code_conformance.py b/tests/lib/core/harness/conformance/test_claude_code_conformance.py new file mode 100644 index 000000000..010bc530b --- /dev/null +++ b/tests/lib/core/harness/conformance/test_claude_code_conformance.py @@ -0,0 +1,192 @@ +"""Cross-channel conformance tests for the claude-code parser tap. + +Each fixture is a representative sequence of claude-code stream-json +envelopes, converted into canonical ``StreamTaskMessage*`` events via +``ClaudeCodeTurn``, then registered into the shared conformance runner. + +The conformance runner asserts two guarantees per fixture: + +1. **Logical-delivery equivalence**: ``yield_events`` and ``auto_send`` + produce the same logically-delivered message contents. + +2. **Span signal equivalence**: both channels emit the same ``SpanSignal`` + sequence to their ``SpanTracer``. + +Fixtures +-------- +text-only: single ``assistant`` text block +tool-call-result: ``tool_use`` block followed by ``tool_result`` +thinking-block: ``thinking`` block with full text +multi-step: text + tool_use + tool_result + text (two model turns) + +Note +---- +Relative imports are used throughout (runner.py and these fixtures live in the +same package). The per-module ``_FIXTURES`` list is both registered globally +(via ``register()``) and parametrized locally so this module's tests are +self-contained regardless of global registry ordering (see runner.py docstring). +""" + +from __future__ import annotations + +import pytest + +from agentex.lib.adk._modules._claude_code_sync import convert_claude_code_to_agentex_events + +from .runner import ( + Fixture, + register, + run_pure_async, + run_cross_channel_conformance, +) + +# --------------------------------------------------------------------------- +# Convert claude-code envelopes to StreamTaskMessage* events +# --------------------------------------------------------------------------- + + +async def _envelopes_to_events(envelopes: list[dict]) -> list: + """Drive convert_claude_code_to_agentex_events and collect all events.""" + + async def _aiter(items): # type: ignore[return] + for item in items: + yield item + + return [e async for e in convert_claude_code_to_agentex_events(_aiter(envelopes))] + + +# --------------------------------------------------------------------------- +# Fixture definitions (raw claude-code envelope sequences) +# --------------------------------------------------------------------------- + +_TEXT_ENVELOPES = [ + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "The answer is 42."}]}, + } +] + +_TOOL_ENVELOPES = [ + { + "type": "assistant", + "message": { + "content": [ + { + "type": "tool_use", + "id": "call_read", + "name": "Read", + "input": {"path": "/workspace/README.md"}, + } + ] + }, + }, + { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_read", + "content": "# My Project\n\nA great project.", + } + ] + }, + }, +] + +_THINKING_ENVELOPES = [ + { + "type": "assistant", + "message": { + "content": [ + {"type": "thinking", "thinking": "Let me think about this carefully.\nStep 1: check the facts."}, + {"type": "text", "text": "Here is my answer."}, + ] + }, + } +] + +_MULTI_STEP_ENVELOPES = [ + # Turn 1: text + tool call + { + "type": "assistant", + "message": { + "content": [ + {"type": "text", "text": "Let me look that up."}, + { + "type": "tool_use", + "id": "call_bash", + "name": "Bash", + "input": {"command": "cat /etc/hostname"}, + }, + ] + }, + }, + { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_bash", + "content": "myhost", + } + ] + }, + }, + # Turn 2: final text after tool result + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "The hostname is myhost."}]}, + }, +] + + +# --------------------------------------------------------------------------- +# Build fixtures from envelopes at module load time +# --------------------------------------------------------------------------- + + +async def _build_fixture(name: str, envelopes: list[dict]) -> Fixture: + events = await _envelopes_to_events(envelopes) + return Fixture(name=name, events=events) + + +# Fixtures must exist before pytest collects (they parametrize the test below), +# so they are built at import time. The conversion only iterates in-memory +# envelopes — it never suspends on a real future — so we drive the coroutines to +# completion with the shared loop-free ``run_pure_async`` driver instead of +# asyncio.run(), which raises RuntimeError at import when an event loop is +# already running (programmatic pytest, a Jupyter kernel, or session-scoped +# asyncio loops). +_FIXTURES: list[Fixture] = [ + run_pure_async(_build_fixture("claude-code-text-only", _TEXT_ENVELOPES)), + run_pure_async(_build_fixture("claude-code-tool-call-result", _TOOL_ENVELOPES)), + run_pure_async(_build_fixture("claude-code-thinking-block", _THINKING_ENVELOPES)), + run_pure_async(_build_fixture("claude-code-multi-step", _MULTI_STEP_ENVELOPES)), +] + +# Register into the shared registry so all_fixtures() can enumerate them +for _f in _FIXTURES: + register(_f) + + +# --------------------------------------------------------------------------- +# Cross-channel conformance assertions +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("fixture", _FIXTURES, ids=lambda f: f.name) +@pytest.mark.asyncio +async def test_cross_channel_equivalence(fixture: Fixture) -> None: + """yield_events and auto_send must produce equivalent logical deliveries + and identical span signals for every claude-code fixture. + """ + yield_deliveries, auto_deliveries, yield_spans, auto_spans = await run_cross_channel_conformance(fixture) + + assert yield_deliveries == auto_deliveries, ( + f"[{fixture.name}] logical deliveries differ:\n yield: {yield_deliveries}\n auto_send: {auto_deliveries}" + ) + assert yield_spans == auto_spans, ( + f"[{fixture.name}] span signals differ:\n yield: {yield_spans}\n auto_send: {auto_spans}" + ) diff --git a/tests/lib/core/harness/conformance/test_codex_conformance.py b/tests/lib/core/harness/conformance/test_codex_conformance.py new file mode 100644 index 000000000..d51a73584 --- /dev/null +++ b/tests/lib/core/harness/conformance/test_codex_conformance.py @@ -0,0 +1,215 @@ +"""Conformance fixtures for the codex harness tap. + +Each fixture is derived from a ``CodexTurn`` and registered into the +cross-channel conformance runner so that span derivation is validated +alongside all other harness taps. + +Following the per-module registry pattern from runner.py: this module keeps +its own local list of fixtures, both registers them AND parametrizes over +them, to guarantee determinism regardless of pytest collection order. +""" + +from __future__ import annotations + +from typing import Any, AsyncIterator + +import pytest + +from agentex.lib.core.harness.types import StreamTaskMessage +from agentex.lib.adk._modules._codex_sync import convert_codex_to_agentex_events + +from .runner import Fixture, register, run_pure_async + + +async def _aiter(items: list[Any]) -> AsyncIterator[Any]: + for item in items: + yield item + + +async def _collect(events: list[Any]) -> list[StreamTaskMessage]: + return [msg async for msg in convert_codex_to_agentex_events(_aiter(events))] + + +def _build(events: list[Any]) -> list[StreamTaskMessage]: + # Loop-free driver: this runs at import time, where asyncio.run() would raise + # under an already-running loop (programmatic pytest, notebooks). + return run_pure_async(_collect(events)) + + +# --------------------------------------------------------------------------- +# Fixture 1: plain text response +# --------------------------------------------------------------------------- + +_CODEX_TEXT = Fixture( + name="codex-text", + events=_build( + [ + {"type": "thread.started", "thread_id": "thread-abc"}, + {"type": "turn.started"}, + { + "type": "item.started", + "item": {"id": "msg1", "type": "agent_message", "text": "Hello"}, + }, + { + "type": "item.updated", + "item": {"id": "msg1", "type": "agent_message", "text": "Hello, world"}, + }, + { + "type": "item.completed", + "item": {"id": "msg1", "type": "agent_message", "text": "Hello, world!"}, + }, + { + "type": "turn.completed", + "usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, + }, + ] + ), +) +register(_CODEX_TEXT) + +# --------------------------------------------------------------------------- +# Fixture 2: tool call (command_execution) +# --------------------------------------------------------------------------- + +_CODEX_TOOL = Fixture( + name="codex-tool-command", + events=_build( + [ + {"type": "thread.started", "thread_id": "thread-cmd"}, + { + "type": "item.started", + "item": { + "id": "tool1", + "type": "command_execution", + "command": "ls /workspace", + }, + }, + { + "type": "item.completed", + "item": { + "id": "tool1", + "type": "command_execution", + "command": "ls /workspace", + "aggregated_output": "file1.txt\nfile2.py", + "exit_code": 0, + }, + }, + { + "type": "turn.completed", + "usage": {"input_tokens": 20, "output_tokens": 8, "total_tokens": 28}, + }, + ] + ), +) +register(_CODEX_TOOL) + +# --------------------------------------------------------------------------- +# Fixture 3: reasoning block +# --------------------------------------------------------------------------- + +_CODEX_REASONING = Fixture( + name="codex-reasoning", + events=_build( + [ + {"type": "thread.started", "thread_id": "thread-reason"}, + { + "type": "item.started", + "item": {"id": "r1", "type": "reasoning", "text": ""}, + }, + { + "type": "item.updated", + "item": {"id": "r1", "type": "reasoning", "text": "Step 1: analyze the problem"}, + }, + { + "type": "item.completed", + "item": { + "id": "r1", + "type": "reasoning", + "text": "Step 1: analyze the problem\nStep 2: solve it", + }, + }, + { + "type": "item.started", + "item": {"id": "msg2", "type": "agent_message", "text": ""}, + }, + { + "type": "item.completed", + "item": {"id": "msg2", "type": "agent_message", "text": "The answer is 42."}, + }, + { + "type": "turn.completed", + "usage": { + "input_tokens": 30, + "output_tokens": 20, + "reasoning_tokens": 50, + "total_tokens": 100, + }, + }, + ] + ), +) +register(_CODEX_REASONING) + +# --------------------------------------------------------------------------- +# Fixture 4: multi-step (mcp_tool_call + follow-up text) +# --------------------------------------------------------------------------- + +_CODEX_MULTI = Fixture( + name="codex-multi-step", + events=_build( + [ + {"type": "thread.started", "thread_id": "thread-multi"}, + { + "type": "item.started", + "item": { + "id": "mcp1", + "type": "mcp_tool_call", + "server": "filesystem", + "tool": "read_file", + "arguments": {"path": "/workspace/README.md"}, + }, + }, + { + "type": "item.completed", + "item": { + "id": "mcp1", + "type": "mcp_tool_call", + "server": "filesystem", + "tool": "read_file", + "arguments": {"path": "/workspace/README.md"}, + "result": {"content": "# My Project"}, + }, + }, + { + "type": "item.started", + "item": {"id": "msg3", "type": "agent_message", "text": "The README says:"}, + }, + { + "type": "item.completed", + "item": { + "id": "msg3", + "type": "agent_message", + "text": "The README says: # My Project", + }, + }, + { + "type": "turn.completed", + "usage": {"input_tokens": 50, "output_tokens": 30, "total_tokens": 80}, + }, + ] + ), +) +register(_CODEX_MULTI) + + +# --------------------------------------------------------------------------- +# Local parametrized tests (cross-channel conformance) +# --------------------------------------------------------------------------- + +_LOCAL_FIXTURES = [_CODEX_TEXT, _CODEX_TOOL, _CODEX_REASONING, _CODEX_MULTI] + + +@pytest.mark.parametrize("fixture", _LOCAL_FIXTURES, ids=lambda f: f.name) +def test_codex_events_are_non_empty(fixture: Fixture) -> None: + """Every codex fixture yields at least one StreamTaskMessage*.""" + assert len(fixture.events) > 0 diff --git a/tests/lib/core/harness/conformance/test_conformance.py b/tests/lib/core/harness/conformance/test_conformance.py new file mode 100644 index 000000000..7c79f9397 --- /dev/null +++ b/tests/lib/core/harness/conformance/test_conformance.py @@ -0,0 +1,299 @@ +"""Cross-channel conformance tests: yield_events vs auto_send. + +What is asserted +---------------- +For each fixture the conformance runner drives BOTH delivery channels and +verifies two guarantees: + +1. **Logical-delivery equivalence**: the sequence of logically-delivered + messages is identical across channels. "Logical" normalises away the + streaming-envelope difference: + - yield channel delivers StreamTaskMessageFull(ToolResponseContent) as-is. + - auto_send delivers the same tool-response by opening a streaming context + with the full content and closing it immediately. + Both collapse to LogicalDelivery(content_type, identity, payload) tuples + that compare equal. The payload includes initial_content (TextContent.content + and ReasoningContent.summary) so a channel that drops initial content fails. + +2. **Span signal equivalence**: both channels feed the same pure SpanDeriver + over the same event sequence, so the derived span signals must be identical. + +What is NOT asserted +-------------------- +Raw wire-level event shapes are NOT compared (that would fail by design: the +Full vs Start+Done envelope difference is a documented, acceptable choice in +auto_send — see runner.py for the rationale). + +auto_send delivers streamed tool-request messages: both channels produce a +delivery for streamed tool_request, verified by the "streamed-tool-request" +fixture. +""" + +from __future__ import annotations + +import pytest + +from agentex.types.text_delta import TextDelta +from agentex.types.text_content import TextContent +from agentex.types.reasoning_content import ReasoningContent +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.types.reasoning_content_delta import ReasoningContentDelta + +from .runner import ( + Fixture, + register, + derive_all, + all_fixtures, + run_cross_channel_conformance, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +_FIXTURES: list[Fixture] = [ + # fixture 1: single tool call — tool_request delivered via Full (classic path) + # plus a streamed tool_response via Full. Both channels should deliver both. + Fixture( + name="builtin-single-tool", + events=[ + StreamTaskMessageStart( + type="start", + index=0, + content=ToolRequestContent( + type="tool_request", author="agent", tool_call_id="c", name="Bash", arguments={} + ), + ), + StreamTaskMessageDone(type="done", index=0), + StreamTaskMessageFull( + type="full", + index=1, + content=ToolResponseContent( + type="tool_response", author="agent", tool_call_id="c", name="Bash", content="ok" + ), + ), + ], + ), + # fixture 2: streaming text — exercises the text start/delta/done path. + # Uses non-empty initial_content so the payload comparison catches a channel + # that drops StreamTaskMessageStart.content (Greptile id 3438655533, P1). + Fixture( + name="streaming-text", + events=[ + StreamTaskMessageStart( + type="start", + index=0, + content=TextContent(type="text", author="agent", content="Init"), + ), + StreamTaskMessageDelta( + type="delta", + index=0, + delta=TextDelta(type="text", text_delta="Hello"), + ), + StreamTaskMessageDelta( + type="delta", + index=0, + delta=TextDelta(type="text", text_delta=" world"), + ), + StreamTaskMessageDone(type="done", index=0), + ], + ), + # fixture 3: reasoning block — exercises reasoning span open/close + delivery. + # ReasoningContent.summary is included in the payload so a channel that drops + # the reasoning-summary fails (Greptile id 3438655533, P1). + Fixture( + name="reasoning-block", + events=[ + StreamTaskMessageStart( + type="start", + index=0, + content=ReasoningContent( + type="reasoning", + author="agent", + summary=["Thinking..."], + ), + ), + StreamTaskMessageDelta( + type="delta", + index=0, + delta=ReasoningContentDelta( + type="reasoning_content", + content_index=0, + content_delta="step 1", + ), + ), + StreamTaskMessageDone(type="done", index=0), + ], + ), + # fixture 4: streamed tool_request — tool_request delivered via Start+Done + # (no Full). Both channels must produce a LogicalDelivery for this fixture. + Fixture( + name="streamed-tool-request", + events=[ + StreamTaskMessageStart( + type="start", + index=0, + content=ToolRequestContent( + type="tool_request", + author="agent", + tool_call_id="tr-1", + name="Read", + arguments={"path": "/tmp/foo"}, + ), + ), + StreamTaskMessageDone(type="done", index=0), + StreamTaskMessageFull( + type="full", + index=1, + content=ToolResponseContent( + type="tool_response", + author="agent", + tool_call_id="tr-1", + name="Read", + content="file contents", + ), + ), + ], + ), + # fixture 5: parallel tool calls + a tool that errors (AGX1-373 review, + # danielmillerp). The earlier fixtures only exercise one tool at a time, so + # equivalence is proven over trivially-orderable streams. This stresses the + # representative case: two tool spans open SIMULTANEOUSLY (p-ls opens via the + # streamed Start+Done path, p-read opens via Full while p-ls is still open), + # then close in a different order than they opened, and one of them returns + # an error. It guards against the two channels agreeing with each other while + # both mishandling interleaved/parallel spans or a failing tool. + # + # The failing tool sets ToolResponseContent.is_error=True (AGX1-371), which + # the span deriver threads onto the closed tool span's CloseSpan.is_error. + # Both channels feed the same deriver, so the recorded span signals — error + # status included — must match. + Fixture( + name="parallel-tools-with-error", + events=[ + # p-ls: streamed tool_request (opens its span at Done). + StreamTaskMessageStart( + type="start", + index=0, + content=ToolRequestContent( + type="tool_request", + author="agent", + tool_call_id="p-ls", + name="Bash", + arguments={"command": "ls /nope"}, + ), + ), + StreamTaskMessageDone(type="done", index=0), + # p-read: Full tool_request opens a second span while p-ls is open. + StreamTaskMessageFull( + type="full", + index=1, + content=ToolRequestContent( + type="tool_request", + author="agent", + tool_call_id="p-read", + name="Read", + arguments={"path": "/etc/hosts"}, + ), + ), + # p-ls errors and closes first (close order != open order). + StreamTaskMessageFull( + type="full", + index=2, + content=ToolResponseContent( + type="tool_response", + author="agent", + tool_call_id="p-ls", + name="Bash", + content="Error: ls: /nope: No such file or directory", + is_error=True, + ), + ), + # p-read succeeds and closes second. + StreamTaskMessageFull( + type="full", + index=3, + content=ToolResponseContent( + type="tool_response", + author="agent", + tool_call_id="p-read", + name="Read", + content="127.0.0.1 localhost", + ), + ), + ], + ), +] + +# Register all fixtures for backward-compatible use via all_fixtures() +for _f in _FIXTURES: + register(_f) + + +# --------------------------------------------------------------------------- +# Cross-channel conformance: logical equivalence + span equivalence +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("fixture", _FIXTURES, ids=lambda f: f.name) +@pytest.mark.asyncio +async def test_cross_channel_equivalence(fixture: Fixture) -> None: + """Assert that yield_events and auto_send produce equivalent logical + deliveries and identical span signals for every fixture. + + This is the real cross-channel guarantee: the two delivery adapters + agree on WHAT was delivered (logical content) and HOW spans were derived, + even though their streaming-envelope shapes differ (Full vs Start+Done for + tool messages). + + The span signals are the ones each channel's tracer ACTUALLY recorded while + delivering, not a re-derivation, so a regression where one channel skips + deriver.observe() for some event type is caught here. + """ + yield_deliveries, auto_deliveries, yield_spans, auto_spans = await run_cross_channel_conformance(fixture) + + assert yield_deliveries == auto_deliveries, ( + f"[{fixture.name}] logical deliveries differ:\n yield: {yield_deliveries}\n auto_send: {auto_deliveries}" + ) + assert yield_spans == auto_spans, ( + f"[{fixture.name}] span signals differ:\n yield: {yield_spans}\n auto_send: {auto_spans}" + ) + + +# --------------------------------------------------------------------------- +# Backward-compatible determinism test (kept for regression coverage) +# --------------------------------------------------------------------------- + + +def test_span_derivation_is_deterministic() -> None: + """Span derivation over the same event list is idempotent, for EVERY + registered fixture across all harnesses. + + ``all_fixtures()`` is read at run time (not at collection/parametrize time) + so it sees fixtures registered by every conformance module, regardless of + import/collection order. The per-harness conformance modules are imported + eagerly via ``conftest.py`` in this directory, so this test covers the full + cross-harness fixture set even when run in isolation. (Parametrizing on + ``all_fixtures()`` at import time would freeze the set to whatever happened + to be registered before this module was collected.) + + Retained as a lightweight regression guard. The primary cross-channel + guarantee is asserted in test_cross_channel_equivalence above. + """ + fixtures = all_fixtures() + assert len(fixtures) > len(_FIXTURES), ( + "expected per-harness fixtures to be registered in addition to the " + f"{len(_FIXTURES)} generic ones; got {len(fixtures)} total — a conformance " + "module's fixtures are not being registered (check conftest imports)" + ) + for fixture in fixtures: + assert derive_all(fixture.events) == derive_all(fixture.events), ( + f"[{fixture.name}] span derivation is not deterministic" + ) diff --git a/tests/lib/core/harness/conformance/test_langgraph_conformance.py b/tests/lib/core/harness/conformance/test_langgraph_conformance.py new file mode 100644 index 000000000..a8d43aef6 --- /dev/null +++ b/tests/lib/core/harness/conformance/test_langgraph_conformance.py @@ -0,0 +1,218 @@ +"""Cross-channel conformance fixtures for LangGraph harness tap. + +Each fixture is built as a canonical sequence of ``StreamTaskMessage*`` events +that matches what ``convert_langgraph_to_agentex_events`` (via ``LangGraphTurn``) +emits for the given scenario. The fixtures are registered with the shared +conformance runner and exercised by both the cross-channel equivalence test +(yield_events vs auto_send) and the backward-compatible span-derivation test. + +LangGraph-specific note +----------------------- +LangGraph emits tool *requests* as ``StreamTaskMessageFull`` events (from the +"updates" stream), NOT as Start+Delta+Done like pydantic-ai. ``auto_send`` +handles Full events by opening a streaming context with the full content and +closing it immediately, so both channels deliver the same logical payload. +No ``coalesce_tool_requests`` option is needed. +""" + +from __future__ import annotations + +import pytest + +from agentex.types.text_content import TextContent +from agentex.types.reasoning_content import ReasoningContent +from agentex.types.task_message_delta import TextDelta +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.types.reasoning_content_delta import ReasoningContentDelta + +from .runner import Fixture, register, run_cross_channel_conformance + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +_TEXT_ONLY = Fixture( + name="langgraph-text-only", + events=[ + StreamTaskMessageStart( + type="start", + index=0, + content=TextContent(type="text", author="agent", content=""), + ), + StreamTaskMessageDelta( + type="delta", + index=0, + delta=TextDelta(type="text", text_delta="Hello from LangGraph!"), + ), + StreamTaskMessageDone(type="done", index=0), + ], +) + +_SINGLE_TOOL = Fixture( + name="langgraph-single-tool", + events=[ + # LangGraph tool request is a Full event (from "updates" stream) + StreamTaskMessageFull( + type="full", + index=0, + content=ToolRequestContent( + type="tool_request", + author="agent", + tool_call_id="call_1", + name="get_weather", + arguments={"city": "Paris"}, + ), + ), + StreamTaskMessageFull( + type="full", + index=1, + content=ToolResponseContent( + type="tool_response", + author="agent", + tool_call_id="call_1", + name="get_weather", + content="Sunny, 72F", + ), + ), + StreamTaskMessageStart( + type="start", + index=2, + content=TextContent(type="text", author="agent", content=""), + ), + StreamTaskMessageDelta( + type="delta", + index=2, + delta=TextDelta(type="text", text_delta="The weather in Paris is sunny, 72F."), + ), + StreamTaskMessageDone(type="done", index=2), + ], +) + +_REASONING = Fixture( + name="langgraph-reasoning", + events=[ + StreamTaskMessageStart( + type="start", + index=0, + content=ReasoningContent( + type="reasoning", + author="agent", + summary=[], + content=[], + style="active", + ), + ), + StreamTaskMessageDelta( + type="delta", + index=0, + delta=ReasoningContentDelta( + type="reasoning_content", + content_index=0, + content_delta="Thinking about this...", + ), + ), + StreamTaskMessageDone(type="done", index=0), + StreamTaskMessageStart( + type="start", + index=1, + content=TextContent(type="text", author="agent", content=""), + ), + StreamTaskMessageDelta( + type="delta", + index=1, + delta=TextDelta(type="text", text_delta="The answer is 42."), + ), + StreamTaskMessageDone(type="done", index=1), + ], +) + +_MULTI_STEP = Fixture( + name="langgraph-multi-step", + events=[ + # Turn 1: streaming text + StreamTaskMessageStart( + type="start", + index=0, + content=TextContent(type="text", author="agent", content=""), + ), + StreamTaskMessageDelta( + type="delta", + index=0, + delta=TextDelta(type="text", text_delta="Let me search for that."), + ), + StreamTaskMessageDone(type="done", index=0), + # Tool request (Full — from "updates" stream) + StreamTaskMessageFull( + type="full", + index=1, + content=ToolRequestContent( + type="tool_request", + author="agent", + tool_call_id="call_2", + name="search", + arguments={"query": "langgraph"}, + ), + ), + StreamTaskMessageFull( + type="full", + index=2, + content=ToolResponseContent( + type="tool_response", + author="agent", + tool_call_id="call_2", + name="search", + content="LangGraph is a framework for...", + ), + ), + # Turn 2: final streaming text + StreamTaskMessageStart( + type="start", + index=3, + content=TextContent(type="text", author="agent", content=""), + ), + StreamTaskMessageDelta( + type="delta", + index=3, + delta=TextDelta(type="text", text_delta="Based on my research, LangGraph is..."), + ), + StreamTaskMessageDone(type="done", index=3), + ], +) + +_LANGGRAPH_FIXTURES = [_TEXT_ONLY, _SINGLE_TOOL, _REASONING, _MULTI_STEP] + +for _fixture in _LANGGRAPH_FIXTURES: + register(_fixture) + + +# --------------------------------------------------------------------------- +# Cross-channel conformance: logical equivalence + span equivalence +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("fixture", _LANGGRAPH_FIXTURES, ids=lambda f: f.name) +@pytest.mark.asyncio +async def test_cross_channel_equivalence(fixture: Fixture) -> None: + """Assert that yield_events and auto_send produce equivalent logical + deliveries and identical span signals for each LangGraph fixture. + + See runner.py for the full contract. The key LangGraph difference: tool + requests arrive as Full events rather than Start+Delta+Done, so auto_send + handles them by opening a streaming context with the full content and + closing it immediately — both channels produce the same LogicalDelivery. + """ + yield_deliveries, auto_deliveries, yield_spans, auto_spans = await run_cross_channel_conformance(fixture) + + assert yield_deliveries == auto_deliveries, ( + f"[{fixture.name}] logical deliveries differ:\n yield: {yield_deliveries}\n auto_send: {auto_deliveries}" + ) + assert yield_spans == auto_spans, ( + f"[{fixture.name}] span signals differ:\n yield: {yield_spans}\n auto_send: {auto_spans}" + ) diff --git a/tests/lib/core/harness/conformance/test_openai_conformance.py b/tests/lib/core/harness/conformance/test_openai_conformance.py new file mode 100644 index 000000000..e8630ca7f --- /dev/null +++ b/tests/lib/core/harness/conformance/test_openai_conformance.py @@ -0,0 +1,206 @@ +"""OpenAI conformance fixtures for the shared harness span-derivation engine. + +The cross-channel guarantee is that yield-delivery and auto_send observe the +SAME canonical StreamTaskMessage* stream, so span derivation and logical +delivery over that stream must be equivalent regardless of channel. These +fixtures express the canonical sequences an OpenAI turn produces (text, +tool-call, reasoning, and a combined multi-step turn) and assert that property +via run_cross_channel_conformance. + +Registry hazard (see conformance/runner.py): _REGISTRY is process-global and +collection order across modules is not guaranteed. To stay deterministic this +module keeps its OWN fixture list and parametrizes over THAT list, rather than +over all_fixtures(). It still calls register() so the cross-module conformance +suite can see these fixtures too. +""" + +from __future__ import annotations + +import pytest + +from agentex.types.text_delta import TextDelta +from agentex.types.text_content import TextContent +from agentex.types.reasoning_content import ReasoningContent +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.types.reasoning_content_delta import ReasoningContentDelta + +from .runner import Fixture, register, run_cross_channel_conformance + +_OPENAI_FIXTURES: list[Fixture] = [] + + +def _add(fixture: Fixture) -> None: + """Register both module-locally (for parametrization) and globally.""" + _OPENAI_FIXTURES.append(fixture) + register(fixture) + + +# Text-only turn: start -> deltas -> done. +# Uses non-empty initial_content so payload comparison catches a channel that +# drops StreamTaskMessageStart.content. +_add( + Fixture( + name="openai-text-only", + events=[ + StreamTaskMessageStart( + type="start", + index=0, + content=TextContent(type="text", author="agent", content="Init"), + ), + StreamTaskMessageDelta(type="delta", index=0, delta=TextDelta(type="text", text_delta="Hel")), + StreamTaskMessageDelta(type="delta", index=0, delta=TextDelta(type="text", text_delta="lo")), + StreamTaskMessageDone(type="done", index=0), + ], + ) +) + +# Tool-call turn: Full(ToolRequestContent) for the call + Full(ToolResponseContent) +# for the result, matched by tool_call_id. Mirrors the OpenAI converter's tool path. +_add( + Fixture( + name="openai-tool-call", + events=[ + StreamTaskMessageFull( + type="full", + index=0, + content=ToolRequestContent( + type="tool_request", + author="agent", + tool_call_id="call_1", + name="get_weather", + arguments={"city": "SF"}, + ), + ), + StreamTaskMessageFull( + type="full", + index=1, + content=ToolResponseContent( + type="tool_response", + author="agent", + tool_call_id="call_1", + name="get_weather", + content="72F", + ), + ), + ], + ) +) + +# Reasoning turn: start(ReasoningContent) -> content deltas -> done. +# ReasoningContent.summary is seeded in the payload so a channel that drops the +# summary fails the cross-channel comparison. +_add( + Fixture( + name="openai-reasoning", + events=[ + StreamTaskMessageStart( + type="start", + index=0, + content=ReasoningContent( + type="reasoning", + author="agent", + summary=["Thinking..."], + ), + ), + StreamTaskMessageDelta( + type="delta", + index=0, + delta=ReasoningContentDelta( + type="reasoning_content", + content_index=0, + content_delta="step 1", + ), + ), + StreamTaskMessageDone(type="done", index=0), + ], + ) +) + +# Multi-step turn: reasoning, then a tool round, then the final answer text. +_add( + Fixture( + name="openai-multi-step", + events=[ + StreamTaskMessageStart( + type="start", + index=0, + content=ReasoningContent( + type="reasoning", + author="agent", + summary=["plan"], + ), + ), + StreamTaskMessageDelta( + type="delta", + index=0, + delta=ReasoningContentDelta( + type="reasoning_content", + content_index=0, + content_delta="elaboration", + ), + ), + StreamTaskMessageDone(type="done", index=0), + StreamTaskMessageFull( + type="full", + index=1, + content=ToolRequestContent( + type="tool_request", + author="agent", + tool_call_id="call_2", + name="search", + arguments={"q": "x"}, + ), + ), + StreamTaskMessageFull( + type="full", + index=2, + content=ToolResponseContent( + type="tool_response", + author="agent", + tool_call_id="call_2", + name="search", + content="result", + ), + ), + StreamTaskMessageStart( + type="start", + index=3, + content=TextContent(type="text", author="agent", content=""), + ), + StreamTaskMessageDelta(type="delta", index=3, delta=TextDelta(type="text", text_delta="done")), + StreamTaskMessageDone(type="done", index=3), + ], + ) +) + + +@pytest.mark.parametrize("fixture", _OPENAI_FIXTURES, ids=lambda f: f.name) +@pytest.mark.asyncio +async def test_openai_cross_channel_equivalence(fixture: Fixture) -> None: + """Assert that yield_events and auto_send produce equivalent logical + deliveries and identical span signals for every OpenAI fixture. + + This is the cross-channel guarantee: the two delivery adapters agree on + WHAT was delivered (logical content) and HOW spans were derived, even + though their streaming-envelope shapes differ (Full vs Start+Done for tool + messages). + + The span signals are the ones each channel's tracer ACTUALLY recorded while + delivering, not a re-derivation, so a regression where one channel skips + deriver.observe() for some event type is caught here. + """ + yield_deliveries, auto_deliveries, yield_spans, auto_spans = await run_cross_channel_conformance(fixture) + + assert yield_deliveries == auto_deliveries, ( + f"[{fixture.name}] logical deliveries differ:\n yield: {yield_deliveries}\n auto_send: {auto_deliveries}" + ) + assert yield_spans == auto_spans, ( + f"[{fixture.name}] span signals differ:\n yield: {yield_spans}\n auto_send: {auto_spans}" + ) diff --git a/tests/lib/core/harness/conformance/test_pydantic_ai_conformance.py b/tests/lib/core/harness/conformance/test_pydantic_ai_conformance.py new file mode 100644 index 000000000..5d9952334 --- /dev/null +++ b/tests/lib/core/harness/conformance/test_pydantic_ai_conformance.py @@ -0,0 +1,187 @@ +"""Cross-channel conformance fixtures derived from real pydantic-ai event sequences. + +Each fixture is built by running a pydantic_ai event stream through PydanticAITurn +and collecting the canonical StreamTaskMessage* output. These canonical event lists are +then registered with the conformance runner and exercised by the cross-channel test +(yield_events vs auto_send). + +Streamed tool requests +---------------------- +The pydantic-ai stream emits a tool REQUEST as Start + ToolRequestDelta + Done (not a +Full event). Both the conformance runner and auto_send deliver the +Start+Delta+Done(tool_request) shape, so the cross-channel test asserts full +delivery-equivalence for streamed tool requests. The fixtures below retain the +ToolRequestDelta events as the streamed tool-request inputs. +""" + +from __future__ import annotations + +from typing import Any, AsyncIterator + +import pytest +from pydantic_ai.messages import ( + TextPart, + PartEndEvent, + ThinkingPart, + ToolCallPart, + TextPartDelta, + PartDeltaEvent, + PartStartEvent, + ToolReturnPart, + ThinkingPartDelta, + ToolCallPartDelta, + FunctionToolResultEvent, +) + +from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn + +from .runner import ( + Fixture, + register, + run_pure_async, + run_cross_channel_conformance, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _aiter(events: list[Any]) -> AsyncIterator[Any]: + for e in events: + yield e + + +async def _canonical(pydantic_events: list[Any]) -> list[Any]: + """Run pydantic_ai events through PydanticAITurn and collect the output. + + The output equals the bare convert_pydantic_ai_to_agentex_events output. + """ + turn = PydanticAITurn(_aiter(pydantic_events), model=None) + return [e async for e in turn.events] + + +def _build_fixtures() -> list[Fixture]: + """Build all pydantic-ai conformance fixtures synchronously at import time. + + Uses the loop-free ``run_pure_async`` driver rather than ``asyncio.run()``, + which would raise under an already-running loop (programmatic pytest, + notebooks) since this runs during module import. + """ + + # ------------------------------------------------------------------ # + # 1. Text-only run: simple streaming text response. + # ------------------------------------------------------------------ # + text_only_pydantic = [ + PartStartEvent(index=0, part=TextPart(content="")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="Hello, ")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="world!")), + PartEndEvent(index=0, part=TextPart(content="Hello, world!")), + ] + + # ------------------------------------------------------------------ # + # 2. Single tool call + tool response. + # The canonical stream emits Start+ToolRequestDelta+Done for the request + # and Full(ToolResponseContent) for the response. Both are asserted + # delivery-equivalent cross-channel (see the module docstring). + # ------------------------------------------------------------------ # + tool_call_pydantic = [ + PartStartEvent( + index=0, + part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="call_01"), + ), + PartDeltaEvent( + index=0, + delta=ToolCallPartDelta(args_delta='{"city":"Paris"}', tool_call_id="call_01"), + ), + PartEndEvent( + index=0, + part=ToolCallPart(tool_name="get_weather", args='{"city":"Paris"}', tool_call_id="call_01"), + ), + FunctionToolResultEvent( + part=ToolReturnPart(tool_name="get_weather", content="Sunny, 22C", tool_call_id="call_01"), + ), + ] + + # ------------------------------------------------------------------ # + # 3. Reasoning/thinking block: produces ReasoningContent Start+Delta+Done. + # ------------------------------------------------------------------ # + reasoning_pydantic = [ + PartStartEvent(index=0, part=ThinkingPart(content="")), + PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta="First, let me think...")), + PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta=" Then conclude.")), + PartEndEvent(index=0, part=ThinkingPart(content="First, let me think... Then conclude.")), + ] + + # ------------------------------------------------------------------ # + # 4. Multi-step run: text -> tool call + response -> text. + # Pydantic AI restarts part indices at 0 for each model response; the + # converter assigns globally-monotonic indices to Agentex messages. + # ------------------------------------------------------------------ # + multi_step_pydantic = [ + # First model turn: text then tool call + PartStartEvent(index=0, part=TextPart(content="")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="Let me check the weather.")), + PartEndEvent(index=0, part=TextPart(content="Let me check the weather.")), + PartStartEvent( + index=1, + part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="call_ms1"), + ), + PartDeltaEvent( + index=1, + delta=ToolCallPartDelta(args_delta='{"city":"London"}', tool_call_id="call_ms1"), + ), + PartEndEvent( + index=1, + part=ToolCallPart(tool_name="get_weather", args='{"city":"London"}', tool_call_id="call_ms1"), + ), + FunctionToolResultEvent( + part=ToolReturnPart(tool_name="get_weather", content="Cloudy, 15C", tool_call_id="call_ms1"), + ), + # Second model turn: text response (pydantic restarts index at 0) + PartStartEvent(index=0, part=TextPart(content="")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="It's cloudy and 15C in London.")), + PartEndEvent(index=0, part=TextPart(content="It's cloudy and 15C in London.")), + ] + + text_only_events = run_pure_async(_canonical(text_only_pydantic)) + tool_call_events = run_pure_async(_canonical(tool_call_pydantic)) + reasoning_events = run_pure_async(_canonical(reasoning_pydantic)) + multi_step_events = run_pure_async(_canonical(multi_step_pydantic)) + + return [ + Fixture(name="pydantic-ai-text-only", events=text_only_events), + Fixture(name="pydantic-ai-single-tool-call", events=tool_call_events), + Fixture(name="pydantic-ai-reasoning-block", events=reasoning_events), + Fixture(name="pydantic-ai-multi-step", events=multi_step_events), + ] + + +_FIXTURES: list[Fixture] = _build_fixtures() + +for _f in _FIXTURES: + register(_f) + + +# --------------------------------------------------------------------------- +# Cross-channel conformance: logical equivalence + span equivalence +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("fixture", _FIXTURES, ids=lambda f: f.name) +@pytest.mark.asyncio +async def test_cross_channel_equivalence(fixture: Fixture) -> None: + """Assert that yield_events and auto_send produce equivalent logical + deliveries and identical span signals for each pydantic-ai fixture. + + See runner.py for the full contract, including streamed-tool-request + delivery equivalence. + """ + yield_deliveries, auto_deliveries, yield_spans, auto_spans = await run_cross_channel_conformance(fixture) + + assert yield_deliveries == auto_deliveries, ( + f"[{fixture.name}] logical deliveries differ:\n yield: {yield_deliveries}\n auto_send: {auto_deliveries}" + ) + assert yield_spans == auto_spans, ( + f"[{fixture.name}] span signals differ:\n yield: {yield_spans}\n auto_send: {auto_spans}" + ) diff --git a/tests/lib/core/harness/test_auto_send.py b/tests/lib/core/harness/test_auto_send.py new file mode 100644 index 000000000..8133a488c --- /dev/null +++ b/tests/lib/core/harness/test_auto_send.py @@ -0,0 +1,480 @@ +"""Tests for auto_send delivery adapter. + +The fake mirrors the real StreamingTaskMessageContext API exactly: +- streaming_task_message_context(...) returns a context object (synchronously) +- open the context via __aenter__ (returns self after creating the task message) +- stream deltas via ctx.stream_update(StreamTaskMessageDelta(...)) +- close via ctx.close() (NOT __aexit__) + +This mirrors _langgraph_async.py lines 62-78 and 100-127. +""" + +from datetime import datetime + +import pytest + +from agentex.types.task_message import TaskMessage +from agentex.types.text_content import TextContent +from agentex.lib.core.harness.tracer import SpanTracer +from agentex.types.task_message_delta import TextDelta +from agentex.types.tool_request_delta import ToolRequestDelta +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.lib.core.harness.auto_send import auto_send +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent + +from ._fakes import FakeTracing + + +class _FakeCtx: + """Mirrors StreamingTaskMessageContext: __aenter__ opens (returns self with task_message set), + close() closes. stream_update records the call. + + task_message is a real TaskMessage instance so that auto_send can use it + as parent_task_message in StreamTaskMessageDelta without Pydantic validation errors. + """ + + def __init__(self, sink, content_type, initial_content): + self.sink = sink + self.content_type = content_type + # Real TaskMessage so StreamTaskMessageDelta(parent_task_message=...) passes validation + self.task_message = TaskMessage(id="msg-1", task_id="task1", content=initial_content) + + async def __aenter__(self): + self.sink.append(("open", self.content_type)) + return self + + async def __aexit__(self, *a): + # __aexit__ delegates to close in the real impl; keep for safety + await self.close() + return False + + async def close(self): + self.sink.append(("close", self.content_type)) + + async def stream_update(self, update): + self.sink.append(("update", update)) + return update + + +class _FakeStreaming: + """Mirrors StreamingService: streaming_task_message_context returns a context object.""" + + def __init__(self): + self.sink = [] + self.recorded_created_at: list[datetime | None] = [] + + def streaming_task_message_context(self, task_id, initial_content, streaming_mode="coalesced", created_at=None): + ctype = getattr(initial_content, "type", None) + self.sink.append(("ctx", ctype)) + self.recorded_created_at.append(created_at) + return _FakeCtx(self.sink, ctype, initial_content) + + +async def _gen(events): + for e in events: + yield e + + +# --------------------------------------------------------------------------- +# Test 1: text streaming — open, stream deltas, close; return accumulated text +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_auto_send_streams_text_and_returns_final_text(): + streaming = _FakeStreaming() + events = [ + StreamTaskMessageStart( + type="start", + index=0, + content=TextContent(type="text", author="agent", content=""), + ), + StreamTaskMessageDelta( + type="delta", + index=0, + delta=TextDelta(type="text", text_delta="Hel"), + ), + StreamTaskMessageDelta( + type="delta", + index=0, + delta=TextDelta(type="text", text_delta="lo"), + ), + StreamTaskMessageDone(type="done", index=0), + ] + result = await auto_send(_gen(events), task_id="task1", tracer=None, streaming=streaming) + + assert result.final_text == "Hello" + + kinds = [s[0] for s in streaming.sink] + # A context was created for the text content + assert kinds[0] == "ctx" + # It was opened and closed + assert "open" in kinds + assert "close" in kinds + # Exactly two updates were streamed (one per delta) + updates = [s for s in streaming.sink if s[0] == "update"] + assert len(updates) == 2 + + +# --------------------------------------------------------------------------- +# Test 2: tool_request Full + tool_response Full — each posts one full message +# (open context with the content, no deltas, close immediately) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_auto_send_posts_full_tool_messages(): + streaming = _FakeStreaming() + events = [ + # Two Full events post two messages (open+close immediately, no deltas). + StreamTaskMessageFull( + type="full", + index=0, + content=ToolRequestContent( + type="tool_request", + author="agent", + tool_call_id="c1", + name="Bash", + arguments={"cmd": "ls"}, + ), + ), + StreamTaskMessageFull( + type="full", + index=1, + content=ToolResponseContent( + type="tool_response", + author="agent", + tool_call_id="c1", + name="Bash", + content="file.py", + ), + ), + ] + result = await auto_send(_gen(events), task_id="task1", tracer=None, streaming=streaming) + + assert result.final_text == "" + + # Each Full event opens and closes exactly one context. + ctx_events = [s for s in streaming.sink if s[0] == "ctx"] + assert len(ctx_events) == 2 + content_types = [s[1] for s in ctx_events] + assert content_types == ["tool_request", "tool_response"] + + # Each context is opened and closed + opens = [s for s in streaming.sink if s[0] == "open"] + closes = [s for s in streaming.sink if s[0] == "close"] + assert len(opens) == 2 + assert len(closes) == 2 + + # No stream_update calls (full messages have no deltas) + updates = [s for s in streaming.sink if s[0] == "update"] + assert len(updates) == 0 + + +# --------------------------------------------------------------------------- +# Test 3: tracing — spans are derived and handed to the tracer +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_auto_send_derives_tool_spans_via_tracer(): + fake_tracing = FakeTracing() + tracer = SpanTracer(trace_id="t", parent_span_id="p", tracing=fake_tracing) + streaming = _FakeStreaming() + + events = [ + StreamTaskMessageStart( + type="start", + index=0, + content=ToolRequestContent( + type="tool_request", + author="agent", + tool_call_id="c1", + name="Bash", + arguments={}, + ), + ), + StreamTaskMessageDone(type="done", index=0), + StreamTaskMessageFull( + type="full", + index=1, + content=ToolResponseContent( + type="tool_response", + author="agent", + tool_call_id="c1", + name="Bash", + content="ok", + ), + ), + ] + + result = await auto_send(_gen(events), task_id="task1", tracer=tracer, streaming=streaming) + + assert result.final_text == "" + assert fake_tracing.started_names == ["Bash"] + # String tool output is wrapped in a dict (SGP spans require an object). + assert fake_tracing.ended_outputs == [{"output": "ok"}] + + +# --------------------------------------------------------------------------- +# Test 4: text followed by a tool Full — text context is closed before Full +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_auto_send_closes_text_context_before_full_message(): + streaming = _FakeStreaming() + events = [ + StreamTaskMessageStart( + type="start", + index=0, + content=TextContent(type="text", author="agent", content=""), + ), + StreamTaskMessageDelta( + type="delta", + index=0, + delta=TextDelta(type="text", text_delta="Hi"), + ), + StreamTaskMessageDone(type="done", index=0), + StreamTaskMessageFull( + type="full", + index=1, + content=ToolRequestContent( + type="tool_request", + author="agent", + tool_call_id="c2", + name="read_file", + arguments={}, + ), + ), + ] + result = await auto_send(_gen(events), task_id="task1", tracer=None, streaming=streaming) + assert result.final_text == "Hi" + + # Verify ordering: text ctx opens, updates, closes; then tool_request ctx opens, closes + event_sequence = [(s[0], s[1]) for s in streaming.sink] + text_open_idx = next(i for i, s in enumerate(event_sequence) if s == ("open", "text")) + text_close_idx = next(i for i, s in enumerate(event_sequence) if s == ("close", "text")) + tool_open_idx = next(i for i, s in enumerate(event_sequence) if s == ("open", "tool_request")) + assert text_open_idx < text_close_idx < tool_open_idx + + +# --------------------------------------------------------------------------- +# Test 5: midstream error — propagates AND the open context is closed (finally) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_open_context_closed_on_midstream_error(): + streaming = _FakeStreaming() + + async def _exploding_gen(): + yield StreamTaskMessageStart( + type="start", + index=0, + content=TextContent(type="text", author="agent", content=""), + ) + raise RuntimeError("boom") + + with pytest.raises(RuntimeError, match="boom"): + await auto_send(_exploding_gen(), task_id="task1", tracer=None, streaming=streaming) + + # The text context that was opened mid-stream was closed by the finally block. + assert ("open", "text") in [(s[0], s[1]) for s in streaming.sink] + assert ("close", "text") in [(s[0], s[1]) for s in streaming.sink] + + +# --------------------------------------------------------------------------- +# Test 6: streamed tool_request delivered +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_auto_send_streams_tool_request(): + """A Start(ToolRequestContent) MUST open a streaming context.""" + streaming = _FakeStreaming() + events = [ + StreamTaskMessageStart( + type="start", + index=0, + content=ToolRequestContent( + type="tool_request", + author="agent", + tool_call_id="c_tool", + name="Bash", + arguments={}, + ), + ), + StreamTaskMessageDelta( + type="delta", + index=0, + delta=ToolRequestDelta( + type="tool_request", + tool_call_id="c_tool", + name="Bash", + arguments_delta='{"cmd": "ls"}', + ), + ), + StreamTaskMessageDone(type="done", index=0), + ] + result = await auto_send(_gen(events), task_id="task1", tracer=None, streaming=streaming) + + assert result.final_text == "" + + ctx_events = [s for s in streaming.sink if s[0] == "ctx"] + assert len(ctx_events) == 1 + assert ctx_events[0][1] == "tool_request" + + opens = [s for s in streaming.sink if s[0] == "open"] + closes = [s for s in streaming.sink if s[0] == "close"] + assert len(opens) == 1 + assert len(closes) == 1 + + updates = [s for s in streaming.sink if s[0] == "update"] + assert len(updates) == 1 + + +# --------------------------------------------------------------------------- +# Test 7: interleaved indexes route correctly +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_auto_send_interleaved_indexes_route_correctly(): + """Deltas must be routed to the correct index-keyed context.""" + streaming = _FakeStreaming() + events = [ + StreamTaskMessageStart( + type="start", + index=0, + content=TextContent(type="text", author="agent", content=""), + ), + StreamTaskMessageStart( + type="start", + index=1, + content=TextContent(type="text", author="agent", content=""), + ), + StreamTaskMessageDelta( + type="delta", + index=0, + delta=TextDelta(type="text", text_delta="A"), + ), + StreamTaskMessageDelta( + type="delta", + index=1, + delta=TextDelta(type="text", text_delta="B"), + ), + StreamTaskMessageDone(type="done", index=0), + StreamTaskMessageDone(type="done", index=1), + ] + result = await auto_send(_gen(events), task_id="task1", tracer=None, streaming=streaming) + + ctx_events = [s for s in streaming.sink if s[0] == "ctx"] + assert len(ctx_events) == 2 + + opens = [s for s in streaming.sink if s[0] == "open"] + assert len(opens) == 2 + + updates = [s for s in streaming.sink if s[0] == "update"] + assert len(updates) == 2 + + update_deltas = [s[1].delta for s in streaming.sink if s[0] == "update"] + text_deltas = [d.text_delta for d in update_deltas if isinstance(d, TextDelta)] + assert set(text_deltas) == {"A", "B"} + + +# --------------------------------------------------------------------------- +# Test 8: final_text returns last text segment for multi-step +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_auto_send_final_text_last_segment(): + """final_text must be the LAST text segment, not accumulated across all turns.""" + streaming = _FakeStreaming() + events = [ + StreamTaskMessageStart( + type="start", + index=0, + content=TextContent(type="text", author="agent", content=""), + ), + StreamTaskMessageDelta( + type="delta", + index=0, + delta=TextDelta(type="text", text_delta="First"), + ), + StreamTaskMessageDone(type="done", index=0), + StreamTaskMessageStart( + type="start", + index=1, + content=TextContent(type="text", author="agent", content=""), + ), + StreamTaskMessageDelta( + type="delta", + index=1, + delta=TextDelta(type="text", text_delta="Second"), + ), + StreamTaskMessageDone(type="done", index=1), + ] + result = await auto_send(_gen(events), task_id="task1", tracer=None, streaming=streaming) + assert result.final_text == "Second" + + +# --------------------------------------------------------------------------- +# Test 9: Full(TextContent) contributes to final_text +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_auto_send_full_text_content_sets_final_text(): + """A Full(TextContent) must contribute its text to final_text.""" + streaming = _FakeStreaming() + events = [ + StreamTaskMessageFull( + type="full", + index=0, + content=TextContent(type="text", author="agent", content="hello"), + ), + ] + result = await auto_send(_gen(events), task_id="task1", tracer=None, streaming=streaming) + assert result.final_text == "hello" + + +# --------------------------------------------------------------------------- +# Test 10: created_at is forwarded to streaming context +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_auto_send_created_at_forwarded(): + """created_at must be forwarded to every streaming_task_message_context call.""" + streaming = _FakeStreaming() + dt = datetime(2025, 1, 15, 12, 0, 0) + events = [ + StreamTaskMessageStart( + type="start", + index=0, + content=TextContent(type="text", author="agent", content=""), + ), + StreamTaskMessageDone(type="done", index=0), + StreamTaskMessageFull( + type="full", + index=1, + content=ToolRequestContent( + type="tool_request", + author="agent", + tool_call_id="c_ts", + name="Bash", + arguments={}, + ), + ), + ] + await auto_send(_gen(events), task_id="task1", tracer=None, streaming=streaming, created_at=dt) + + assert all(ts == dt for ts in streaming.recorded_created_at) diff --git a/tests/lib/core/harness/test_emitter.py b/tests/lib/core/harness/test_emitter.py new file mode 100644 index 000000000..3f70660ec --- /dev/null +++ b/tests/lib/core/harness/test_emitter.py @@ -0,0 +1,142 @@ +import pytest + +from agentex.types.task_message import TaskMessage +from agentex.types.text_content import TextContent +from agentex.lib.core.harness.types import TurnUsage +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.types.task_message_delta import TextDelta +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) + +from ._fakes import FakeTracing + + +class _FakeCtx: + """Minimal StreamingTaskMessageContext fake (see test_auto_send.py).""" + + def __init__(self, sink, content_type, initial_content): + self.sink = sink + self.content_type = content_type + self.task_message = TaskMessage(id="msg-1", task_id="task1", content=initial_content) + + async def __aenter__(self): + self.sink.append(("open", self.content_type)) + return self + + async def __aexit__(self, *a): + await self.close() + return False + + async def close(self): + self.sink.append(("close", self.content_type)) + + async def stream_update(self, update): + self.sink.append(("update", update)) + return update + + +class _FakeStreaming: + def __init__(self): + self.sink = [] + + def streaming_task_message_context(self, task_id, initial_content, streaming_mode="coalesced", created_at=None): + ctype = getattr(initial_content, "type", None) + self.sink.append(("ctx", ctype)) + return _FakeCtx(self.sink, ctype, initial_content) + + +class _Turn: + def __init__(self, events_list, usage): + self._events_list = events_list + self._usage = usage + + @property + async def events(self): + for e in self._events_list: + yield e + + def usage(self): + return self._usage + + +@pytest.mark.asyncio +async def test_emitter_yield_mode_passes_through(): + events = [ + StreamTaskMessageStart(type="start", index=0, content=TextContent(type="text", author="agent", content="hi")), + StreamTaskMessageDone(type="done", index=0), + ] + turn = _Turn(events, TurnUsage(model="m")) + emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) + out = [e async for e in emitter.yield_turn(turn)] + assert out == events + + +@pytest.mark.asyncio +async def test_emitter_tracing_default_on_when_trace_id_present(): + # Inject a fake tracing backend so the test env doesn't need temporalio. + # This exercises the default-on path (tracer=None) when trace_id is truthy. + emitter = UnifiedEmitter(task_id="t", trace_id="trace1", parent_span_id="p", tracing=FakeTracing()) + assert emitter.tracer is not None + + +@pytest.mark.asyncio +async def test_emitter_tracing_overridable_off(): + emitter = UnifiedEmitter(task_id="t", trace_id="trace1", parent_span_id="p", tracer=False) + assert emitter.tracer is None + + +@pytest.mark.asyncio +async def test_emitter_auto_send_turn_returns_usage(): + usage = TurnUsage(model="m", input_tokens=5) + events = [ + StreamTaskMessageStart(type="start", index=0, content=TextContent(type="text", author="agent", content="")), + StreamTaskMessageDelta(type="delta", index=0, delta=TextDelta(type="text", text_delta="Hello")), + StreamTaskMessageDone(type="done", index=0), + ] + turn = _Turn(events, usage) + fake = _FakeStreaming() + emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None, streaming=fake) + result = await emitter.auto_send_turn(turn) + assert result.usage == usage + assert result.final_text == "Hello" + + +class _ContractTurn: + """A turn that honors the single-pass contract: usage() is the empty default + UNTIL `events` is exhausted, then the real usage (this is how real harness + turns behave — they populate usage while the stream is consumed).""" + + def __init__(self, events_list, real_usage): + self._events_list = events_list + self._real_usage = real_usage + self._exhausted = False + + @property + async def events(self): + for e in self._events_list: + yield e + self._exhausted = True + + def usage(self): + return self._real_usage if self._exhausted else TurnUsage(model="m") + + +@pytest.mark.asyncio +async def test_emitter_auto_send_turn_reads_usage_after_exhaustion(): + # Regression: auto_send_turn must read turn.usage() AFTER consuming the + # stream, not eagerly when building the auto_send call (which would capture + # the empty default and lose real token usage on the auto_send path). + real_usage = TurnUsage(model="m", input_tokens=11, output_tokens=22, total_tokens=33, num_llm_calls=2) + events = [ + StreamTaskMessageStart(type="start", index=0, content=TextContent(type="text", author="agent", content="")), + StreamTaskMessageDelta(type="delta", index=0, delta=TextDelta(type="text", text_delta="hi")), + StreamTaskMessageDone(type="done", index=0), + ] + turn = _ContractTurn(events, real_usage) + emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None, streaming=_FakeStreaming()) + result = await emitter.auto_send_turn(turn) + assert result.usage == real_usage + assert result.usage.input_tokens == 11 and result.usage.total_tokens == 33 diff --git a/tests/lib/core/harness/test_harness_claude_code_async.py b/tests/lib/core/harness/test_harness_claude_code_async.py new file mode 100644 index 000000000..c622d25c1 --- /dev/null +++ b/tests/lib/core/harness/test_harness_claude_code_async.py @@ -0,0 +1,248 @@ +"""Integration test: async (Redis-streaming) channel with a claude-code turn. + +Exercises the unified harness surface (UnifiedEmitter.auto_send_turn + ClaudeCodeTurn) +with hand-built claude-code ``stream-json`` envelopes and a fake streaming +backend so the test runs fully offline (no claude-code CLI subprocess, no +Redis, no Agentex server). + +Native envelope shapes are copied verbatim from the claude-code turn test and +conformance fixtures (assistant tool_use -> Start(ToolRequestContent)+Done; +user tool_result -> Full(ToolResponseContent); assistant text -> +Start(TextContent)+Delta+Done; result envelope -> usage). + +What is tested +-------------- +- auto_send pushes the correct message contexts: tool_request + tool_response + + text (in that order). +- TurnResult.final_text equals the final assistant text. +- TurnResult.usage reflects the claude-code ``result`` envelope (input/output + tokens, cost, num_llm_calls from num_turns). +- With a SpanTracer + fake tracing, a tool span is derived on the async path. + +What is NOT covered without live infrastructure +----------------------------------------------- +- Actual Redis streaming. +- The ACP on_task_event_send / on_task_create / on_task_cancel lifecycle. +- A real claude-code CLI subprocess / live model behaviour. + +See also: test_harness_claude_code_sync.py and test_harness_claude_code_temporal.py. +""" + +from __future__ import annotations + +from typing import Any, AsyncIterator + +import pytest + +from agentex.types.task_message import TaskMessage +from agentex.lib.core.harness.types import TurnResult +from agentex.lib.core.harness.tracer import SpanTracer +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.lib.adk._modules._claude_code_turn import ClaudeCodeTurn + +from ._fakes import FakeTracing + +# --------------------------------------------------------------------------- +# Native claude-code envelope fixtures +# --------------------------------------------------------------------------- + + +def _tool_then_text_envelopes() -> list[dict[str, Any]]: + return [ + { + "type": "assistant", + "message": { + "content": [ + { + "type": "tool_use", + "id": "call_read", + "name": "Read", + "input": {"path": "/workspace/README.md"}, + } + ] + }, + }, + { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_read", + "content": "# My Project — temperature 72F", + } + ] + }, + }, + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "The project file says 72F."}]}, + }, + { + "type": "result", + "usage": {"input_tokens": 200, "output_tokens": 80}, + "cost_usd": 0.015, + "num_turns": 2, + }, + ] + + +async def _aiter(envelopes: list[dict[str, Any]]) -> AsyncIterator[dict[str, Any]]: + for e in envelopes: + yield e + + +# --------------------------------------------------------------------------- +# Fake streaming backend +# --------------------------------------------------------------------------- + + +class _FakeCtx: + def __init__(self, sink: list[Any], ctype: str, initial_content: Any) -> None: + self.sink = sink + self.ctype = ctype + self.task_message = TaskMessage(id="msg-1", task_id="task1", content=initial_content) + + async def __aenter__(self) -> "_FakeCtx": + self.sink.append(("open", self.ctype, self.task_message.content)) + return self + + async def __aexit__(self, *args: Any) -> bool: + await self.close() + return False + + async def close(self) -> None: + self.sink.append(("close", self.ctype)) + + async def stream_update(self, update: Any) -> Any: + self.sink.append(("delta", self.ctype, update)) + return update + + +class _FakeStreaming: + def __init__(self) -> None: + self.sink: list[Any] = [] + self.messages_opened: list[Any] = [] + + def streaming_task_message_context( + self, + task_id: str, + initial_content: Any, + streaming_mode: str = "coalesced", + created_at: Any = None, + ) -> _FakeCtx: + ctype = getattr(initial_content, "type", None) or "" + self.messages_opened.append(initial_content) + return _FakeCtx(self.sink, ctype, initial_content) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _run_auto_send_turn( + envelopes: list[dict[str, Any]], + trace_id: str | None = None, + parent_span_id: str | None = None, + fake_tracing: FakeTracing | None = None, +) -> tuple[TurnResult, _FakeStreaming]: + fake_streaming = _FakeStreaming() + tracer: SpanTracer | bool | None = None + if trace_id and fake_tracing is not None: + tracer = SpanTracer( + trace_id=trace_id, + parent_span_id=parent_span_id, + task_id="task1", + tracing=fake_tracing, + ) + + turn = ClaudeCodeTurn(_aiter(envelopes)) + emitter = UnifiedEmitter( + task_id="task1", + trace_id=trace_id, + parent_span_id=parent_span_id, + tracer=tracer if tracer is not None else False, + streaming=fake_streaming, + ) + result = await emitter.auto_send_turn(turn) + return result, fake_streaming + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestAsyncAutoSendMessageOrder: + async def test_tool_request_pushed_before_tool_response(self) -> None: + _, fake_streaming = await _run_auto_send_turn(_tool_then_text_envelopes()) + types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] + assert "tool_request" in types + assert "tool_response" in types + assert types.index("tool_request") < types.index("tool_response") + + async def test_text_pushed_last(self) -> None: + _, fake_streaming = await _run_auto_send_turn(_tool_then_text_envelopes()) + types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] + assert types[-1] == "text", f"Expected last type=text, got {types}" + + +class TestAsyncAutoSendContent: + async def test_tool_request_content(self) -> None: + _, fake_streaming = await _run_auto_send_turn(_tool_then_text_envelopes()) + tool_reqs = [m for m in fake_streaming.messages_opened if isinstance(m, ToolRequestContent)] + assert len(tool_reqs) == 1 + assert tool_reqs[0].name == "Read" + + async def test_tool_response_content(self) -> None: + _, fake_streaming = await _run_auto_send_turn(_tool_then_text_envelopes()) + tool_resps = [m for m in fake_streaming.messages_opened if isinstance(m, ToolResponseContent)] + assert len(tool_resps) == 1 + assert "72F" in str(tool_resps[0].content) + + async def test_tool_call_ids_match(self) -> None: + _, fake_streaming = await _run_auto_send_turn(_tool_then_text_envelopes()) + tool_req = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolRequestContent)) + tool_resp = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolResponseContent)) + assert tool_req.tool_call_id == tool_resp.tool_call_id == "call_read" + + +class TestAsyncAutoSendFinalTextAndUsage: + async def test_final_text_matches_last_text(self) -> None: + result, _ = await _run_auto_send_turn(_tool_then_text_envelopes()) + assert result.final_text == "The project file says 72F." + + async def test_usage_from_result_envelope(self) -> None: + """TurnResult.usage reflects the claude-code result envelope.""" + result, _ = await _run_auto_send_turn(_tool_then_text_envelopes()) + assert result.usage is not None + assert result.usage.input_tokens == 200 + assert result.usage.output_tokens == 80 + assert result.usage.total_tokens == 280 + assert result.usage.cost_usd == pytest.approx(0.015) + assert result.usage.num_llm_calls == 2 + + async def test_context_lifecycle_open_then_close(self) -> None: + _, fake_streaming = await _run_auto_send_turn(_tool_then_text_envelopes()) + opens = [e for e in fake_streaming.sink if e[0] == "open"] + closes = [e for e in fake_streaming.sink if e[0] == "close"] + assert len(opens) == len(closes) + assert len(opens) == len(fake_streaming.messages_opened) + + +class TestAsyncAutoSendSpanDerivation: + async def test_tool_span_derived_on_async_path(self) -> None: + fake_tracing = FakeTracing() + await _run_auto_send_turn( + _tool_then_text_envelopes(), + trace_id="trace1", + parent_span_id="parent", + fake_tracing=fake_tracing, + ) + assert len(fake_tracing.started) == 1 + assert fake_tracing.started[0][0] == "Read" + assert len(fake_tracing.ended) == 1 + assert "72F" in str(fake_tracing.ended[0][1]) diff --git a/tests/lib/core/harness/test_harness_claude_code_sync.py b/tests/lib/core/harness/test_harness_claude_code_sync.py new file mode 100644 index 000000000..b53485499 --- /dev/null +++ b/tests/lib/core/harness/test_harness_claude_code_sync.py @@ -0,0 +1,303 @@ +"""Integration test: sync (HTTP-yield) channel with a claude-code turn. + +Exercises the unified harness surface (UnifiedEmitter.yield_turn + ClaudeCodeTurn) +with hand-built claude-code ``stream-json`` envelopes so the test runs fully +offline (no claude-code CLI subprocess, no API keys, no Agentex server). + +Native stream shapes +--------------------- +``ClaudeCodeTurn`` consumes an async iterator of raw claude-code stream-json +envelopes (str | dict). The envelope shapes used here are copied verbatim from +the claude-code turn test (tests/lib/adk/test_claude_code_turn.py) and the +claude-code conformance fixtures +(tests/lib/core/harness/conformance/test_claude_code_conformance.py): + + assistant text block -> Start(TextContent) + Delta + Done + assistant tool_use -> Start(ToolRequestContent) + Done + user tool_result -> Full(ToolResponseContent) + assistant thinking -> Start(ReasoningContent) + Delta + Done + +What is tested +-------------- +- The sync handler forwards StreamTaskMessage* events in canonical order: + tool_request (Start+Done) -> tool_response (Full) -> text. +- The tool_response carries the tool_result content, keyed by tool_use_id. +- With a trace_id + fake tracing, the SpanDeriver opens a tool span on + Done(tool_request) and closes it on the matching Full(tool_response), and + opens/closes a reasoning span for a thinking block. + +What is NOT covered without live infrastructure +----------------------------------------------- +- Actual HTTP streaming over the ACP sync endpoint. +- A real claude-code CLI subprocess / live model behaviour. +- The full FastACP request/response lifecycle. + +See also: test_harness_claude_code_async.py and test_harness_claude_code_temporal.py. +""" + +from __future__ import annotations + +from typing import Any, AsyncIterator, override + +from agentex.lib.core.harness.types import OpenSpan, CloseSpan +from agentex.lib.core.harness.tracer import SpanTracer +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageStart, +) +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.lib.adk._modules._claude_code_turn import ClaudeCodeTurn + +from ._fakes import FakeTracing + +# --------------------------------------------------------------------------- +# Native claude-code envelope fixtures (copied from the turn + conformance tests) +# --------------------------------------------------------------------------- + + +def _tool_then_text_envelopes() -> list[dict[str, Any]]: + """tool_use -> tool_result -> final text, then a result envelope with usage.""" + return [ + { + "type": "assistant", + "message": { + "content": [ + { + "type": "tool_use", + "id": "call_read", + "name": "Read", + "input": {"path": "/workspace/README.md"}, + } + ] + }, + }, + { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_read", + "content": "# My Project — temperature 72F", + } + ] + }, + }, + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "The project file says 72F."}]}, + }, + { + "type": "result", + "usage": {"input_tokens": 100, "output_tokens": 50}, + "cost_usd": 0.01, + "num_turns": 2, + }, + ] + + +def _thinking_envelopes() -> list[dict[str, Any]]: + return [ + { + "type": "assistant", + "message": { + "content": [ + {"type": "thinking", "thinking": "Let me think.\nStep 1: check the facts."}, + {"type": "text", "text": "Here is my answer."}, + ] + }, + }, + {"type": "result", "usage": {"input_tokens": 10, "output_tokens": 5}}, + ] + + +async def _aiter(envelopes: list[dict[str, Any]]) -> AsyncIterator[dict[str, Any]]: + for e in envelopes: + yield e + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _run_yield_turn( + envelopes: list[dict[str, Any]], + trace_id: str | None = None, + parent_span_id: str | None = None, + fake_tracing: FakeTracing | None = None, +) -> list[Any]: + tracer: SpanTracer | bool | None = None + if trace_id and fake_tracing is not None: + tracer = SpanTracer( + trace_id=trace_id, + parent_span_id=parent_span_id, + task_id="task1", + tracing=fake_tracing, + ) + + turn = ClaudeCodeTurn(_aiter(envelopes)) + emitter = UnifiedEmitter( + task_id="task1", + trace_id=trace_id, + parent_span_id=parent_span_id, + tracer=tracer if tracer is not None else False, + ) + return [ev async for ev in emitter.yield_turn(turn)] + + +# --------------------------------------------------------------------------- +# Tests: event order and content +# --------------------------------------------------------------------------- + + +class TestSyncYieldEventOrder: + async def test_tool_request_precedes_tool_response(self) -> None: + events = await _run_yield_turn(_tool_then_text_envelopes()) + content_types = [ + getattr(getattr(ev, "content", None), "type", None) + for ev in events + if isinstance(ev, (StreamTaskMessageStart, StreamTaskMessageFull)) + ] + assert "tool_request" in content_types + assert "tool_response" in content_types + assert content_types.index("tool_request") < content_types.index("tool_response") + + async def test_text_appears_after_tool_response(self) -> None: + events = await _run_yield_turn(_tool_then_text_envelopes()) + tool_resp_pos = next( + i + for i, ev in enumerate(events) + if isinstance(ev, StreamTaskMessageFull) + and getattr(getattr(ev, "content", None), "type", None) == "tool_response" + ) + text_start_pos = next( + i + for i, ev in enumerate(events) + if isinstance(ev, StreamTaskMessageStart) and getattr(getattr(ev, "content", None), "type", None) == "text" + ) + assert tool_resp_pos < text_start_pos + + async def test_tool_response_carries_result_keyed_by_tool_use_id(self) -> None: + events = await _run_yield_turn(_tool_then_text_envelopes()) + full_responses = [ + ev.content + for ev in events + if isinstance(ev, StreamTaskMessageFull) and isinstance(getattr(ev, "content", None), ToolResponseContent) + ] + assert len(full_responses) == 1 + tool_response = full_responses[0] + assert isinstance(tool_response, ToolResponseContent) + assert tool_response.tool_call_id == "call_read" + assert "72F" in str(tool_response.content) + + async def test_tool_request_is_read(self) -> None: + events = await _run_yield_turn(_tool_then_text_envelopes()) + tool_reqs = [ + ev.content + for ev in events + if isinstance(getattr(ev, "content", None), ToolRequestContent) + ] + assert any(isinstance(c, ToolRequestContent) and c.name == "Read" for c in tool_reqs) + + async def test_every_start_has_matching_done(self) -> None: + events = await _run_yield_turn(_tool_then_text_envelopes()) + starts = {ev.index for ev in events if isinstance(ev, StreamTaskMessageStart)} + dones = {ev.index for ev in events if isinstance(ev, StreamTaskMessageDone)} + assert starts == dones, f"Unmatched Start/Done indices: starts={starts} dones={dones}" + + +# --------------------------------------------------------------------------- +# Tests: span derivation on the yield path +# --------------------------------------------------------------------------- + + +class TestSyncYieldSpanDerivation: + async def test_tool_span_opened_and_closed(self) -> None: + """Done(tool_request) opens a tool span; Full(tool_response) closes it.""" + fake_tracing = FakeTracing() + await _run_yield_turn( + _tool_then_text_envelopes(), + trace_id="trace1", + parent_span_id="parent-span", + fake_tracing=fake_tracing, + ) + assert len(fake_tracing.started) == 1 + assert len(fake_tracing.ended) == 1 + name, parent_id, _ = fake_tracing.started[0] + assert name == "Read" + assert parent_id == "parent-span" + + async def test_tool_span_output_is_tool_result(self) -> None: + fake_tracing = FakeTracing() + await _run_yield_turn( + _tool_then_text_envelopes(), + trace_id="trace1", + parent_span_id="parent-span", + fake_tracing=fake_tracing, + ) + name, output = fake_tracing.ended[0] + assert name == "Read" + assert "72F" in str(output) + + async def test_reasoning_span_for_thinking_block(self) -> None: + """A thinking block opens and closes a reasoning span.""" + fake_tracing = FakeTracing() + await _run_yield_turn( + _thinking_envelopes(), + trace_id="trace1", + parent_span_id="parent-span", + fake_tracing=fake_tracing, + ) + assert fake_tracing.started_names == ["reasoning"] + assert len(fake_tracing.ended) == 1 + + async def test_no_trace_id_means_no_spans(self) -> None: + fake_tracing = FakeTracing() + turn = ClaudeCodeTurn(_aiter(_tool_then_text_envelopes())) + emitter = UnifiedEmitter(task_id="task1", trace_id=None, parent_span_id=None, tracing=fake_tracing) + [_ async for _ in emitter.yield_turn(turn)] + assert fake_tracing.started == [] + assert fake_tracing.ended == [] + + async def test_tracer_false_suppresses_spans(self) -> None: + fake_tracing = FakeTracing() + turn = ClaudeCodeTurn(_aiter(_tool_then_text_envelopes())) + emitter = UnifiedEmitter( + task_id="task1", + trace_id="trace1", + parent_span_id="parent-span", + tracer=False, + tracing=fake_tracing, + ) + [_ async for _ in emitter.yield_turn(turn)] + assert fake_tracing.started == [] + assert fake_tracing.ended == [] + + async def test_span_signal_types(self) -> None: + received_signals: list[Any] = [] + + class _RecordingTracer(SpanTracer): + @override + async def handle(self, signal: Any) -> None: + received_signals.append(signal) + await super().handle(signal) + + fake_tracing = FakeTracing() + tracer = _RecordingTracer( + trace_id="trace1", + parent_span_id="parent", + task_id="task1", + tracing=fake_tracing, + ) + turn = ClaudeCodeTurn(_aiter(_tool_then_text_envelopes())) + emitter = UnifiedEmitter(task_id="task1", trace_id="trace1", parent_span_id="parent", tracer=tracer) + [_ async for _ in emitter.yield_turn(turn)] + + tool_signals = [s for s in received_signals if getattr(s, "name", None) == "Read"] + assert len(tool_signals) >= 1 + assert isinstance(received_signals[0], OpenSpan) + assert any(isinstance(s, CloseSpan) for s in received_signals) diff --git a/tests/lib/core/harness/test_harness_claude_code_temporal.py b/tests/lib/core/harness/test_harness_claude_code_temporal.py new file mode 100644 index 000000000..b643f0d20 --- /dev/null +++ b/tests/lib/core/harness/test_harness_claude_code_temporal.py @@ -0,0 +1,183 @@ +"""Integration test: Temporal channel with a claude-code turn, offline. + +The claude-code tap is a pure library adapter (no Temporal-specific helper such +as langgraph's ``stream_langgraph_events``). In a Temporal deployment the +claude-code CLI runs inside a Temporal activity and the resulting canonical +stream is delivered via the SAME ``UnifiedEmitter.auto_send_turn`` path used by +the non-temporal async channel. The only temporal-specific concern at the +harness boundary is that the activity stamps messages with a deterministic +``created_at`` (e.g. ``workflow.now()``) for replay determinism. + +This suite therefore exercises the auto_send path inside an activity-style call +plus the temporal-only contract: ``created_at`` is threaded through to every +streaming context. The native claude-code envelope shapes are copied verbatim +from the claude-code turn test / conformance fixtures. + +What is tested +-------------- +- The canonical message sequence (tool_request -> tool_response -> text) is + delivered via auto_send_turn, exactly as inside a Temporal activity. +- ``created_at`` passed to ``auto_send_turn`` is forwarded to every + ``streaming_task_message_context`` call (deterministic timestamping). +- Final text + usage from the result envelope are returned. + +What is NOT covered without live infrastructure +----------------------------------------------- +- Temporal scheduling / durability / replay behaviour. +- Redis streaming (requires a running Redis instance). +- A real claude-code CLI subprocess / live model behaviour. + +See also: test_harness_claude_code_sync.py and test_harness_claude_code_async.py. +""" + +from __future__ import annotations + +from typing import Any, AsyncIterator +from datetime import datetime, timezone + +from agentex.types.task_message import TaskMessage +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.lib.adk._modules._claude_code_turn import ClaudeCodeTurn + + +def _tool_then_text_envelopes() -> list[dict[str, Any]]: + return [ + { + "type": "assistant", + "message": { + "content": [ + { + "type": "tool_use", + "id": "call_read", + "name": "Read", + "input": {"path": "/workspace/README.md"}, + } + ] + }, + }, + { + "type": "user", + "message": { + "content": [ + {"type": "tool_result", "tool_use_id": "call_read", "content": "# My Project — 72F"} + ] + }, + }, + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "The project file says 72F."}]}, + }, + {"type": "result", "usage": {"input_tokens": 50, "output_tokens": 20}, "num_turns": 2}, + ] + + +async def _aiter(envelopes: list[dict[str, Any]]) -> AsyncIterator[dict[str, Any]]: + for e in envelopes: + yield e + + +# --------------------------------------------------------------------------- +# Fake streaming backend that records created_at +# --------------------------------------------------------------------------- + + +class _FakeCtx: + def __init__(self, ctype: str, initial_content: Any) -> None: + self.ctype = ctype + self.task_message = TaskMessage(id="msg-1", task_id="task1", content=initial_content) + + async def __aenter__(self) -> "_FakeCtx": + return self + + async def __aexit__(self, *args: Any) -> bool: + await self.close() + return False + + async def close(self) -> None: + pass + + async def stream_update(self, update: Any) -> Any: + return update + + +class _FakeStreaming: + def __init__(self) -> None: + self.messages_opened: list[Any] = [] + self.created_ats: list[Any] = [] + + def streaming_task_message_context( + self, + task_id: str, + initial_content: Any, + streaming_mode: str = "coalesced", + created_at: Any = None, + ) -> _FakeCtx: + ctype = getattr(initial_content, "type", None) or "" + self.messages_opened.append(initial_content) + self.created_ats.append(created_at) + return _FakeCtx(ctype, initial_content) + + +async def _run_activity( + envelopes: list[dict[str, Any]], created_at: datetime | None +) -> tuple[Any, _FakeStreaming]: + fake_streaming = _FakeStreaming() + turn = ClaudeCodeTurn(_aiter(envelopes)) + emitter = UnifiedEmitter( + task_id="task1", + trace_id=None, + parent_span_id=None, + tracer=False, + streaming=fake_streaming, + ) + result = await emitter.auto_send_turn(turn, created_at=created_at) + return result, fake_streaming + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestTemporalActivityDelivery: + async def test_canonical_sequence_delivered(self) -> None: + _, fake_streaming = await _run_activity(_tool_then_text_envelopes(), created_at=None) + types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] + assert "tool_request" in types + assert "tool_response" in types + assert types.index("tool_request") < types.index("tool_response") + assert types[-1] == "text" + + async def test_tool_round_trip_keyed_correctly(self) -> None: + _, fake_streaming = await _run_activity(_tool_then_text_envelopes(), created_at=None) + tool_req = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolRequestContent)) + tool_resp = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolResponseContent)) + assert tool_req.tool_call_id == tool_resp.tool_call_id == "call_read" + + async def test_final_text_and_usage(self) -> None: + result, _ = await _run_activity(_tool_then_text_envelopes(), created_at=None) + assert result.final_text == "The project file says 72F." + assert result.usage.input_tokens == 50 + assert result.usage.num_llm_calls == 2 + + +class TestTemporalCreatedAtThreading: + async def test_created_at_threaded_to_all_contexts(self) -> None: + fixed = datetime(2026, 6, 22, 12, 0, 0, tzinfo=timezone.utc) + _, fake_streaming = await _run_activity(_tool_then_text_envelopes(), created_at=fixed) + assert len(fake_streaming.created_ats) == len(fake_streaming.messages_opened) + assert all(ts == fixed for ts in fake_streaming.created_ats), ( + f"Expected every context stamped with {fixed}, got {fake_streaming.created_ats}" + ) + + async def test_default_created_at_is_none(self) -> None: + _, fake_streaming = await _run_activity(_tool_then_text_envelopes(), created_at=None) + assert all(ts is None for ts in fake_streaming.created_ats) + + async def test_created_at_deterministic_across_runs(self) -> None: + fixed = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + _, first = await _run_activity(_tool_then_text_envelopes(), created_at=fixed) + _, second = await _run_activity(_tool_then_text_envelopes(), created_at=fixed) + assert first.created_ats == second.created_ats diff --git a/tests/lib/core/harness/test_harness_codex_async.py b/tests/lib/core/harness/test_harness_codex_async.py new file mode 100644 index 000000000..c31ebfa49 --- /dev/null +++ b/tests/lib/core/harness/test_harness_codex_async.py @@ -0,0 +1,228 @@ +"""Integration test: async (Redis-streaming) channel with a codex turn. + +Exercises the unified harness surface (UnifiedEmitter.auto_send_turn + CodexTurn) +with hand-built codex ``exec --json`` event dicts and a fake streaming backend +so the test runs fully offline (no codex CLI subprocess, no Redis, no Agentex +server). + +Native event shapes are copied verbatim from the codex turn test / conformance +fixtures (command_execution -> tool round-trip; agent_message -> text; +turn.completed -> usage). + +What is tested +-------------- +- auto_send pushes the correct message contexts: tool_request + tool_response + + text (in that order). +- TurnResult.final_text equals the final agent_message text. +- TurnResult.usage reflects the codex ``turn.completed`` usage (input/output/ + total tokens) plus the locally-counted num_tool_calls. +- With a SpanTracer + fake tracing, a tool span is derived on the async path. + +What is NOT covered without live infrastructure +----------------------------------------------- +- Actual Redis streaming. +- The ACP on_task_event_send / on_task_create / on_task_cancel lifecycle. +- A real codex CLI subprocess / live model behaviour. + +See also: test_harness_codex_sync.py and test_harness_codex_temporal.py. +""" + +from __future__ import annotations + +from typing import Any, AsyncIterator + +from agentex.types.task_message import TaskMessage +from agentex.lib.core.harness.types import TurnResult +from agentex.lib.core.harness.tracer import SpanTracer +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.lib.adk._modules._codex_turn import CodexTurn + +from ._fakes import FakeTracing + +# --------------------------------------------------------------------------- +# Native codex event fixtures +# --------------------------------------------------------------------------- + + +def _tool_then_text_events() -> list[dict[str, Any]]: + return [ + {"type": "thread.started", "thread_id": "thread-abc"}, + { + "type": "item.started", + "item": {"id": "tool1", "type": "command_execution", "command": "cat weather.txt"}, + }, + { + "type": "item.completed", + "item": { + "id": "tool1", + "type": "command_execution", + "command": "cat weather.txt", + "aggregated_output": "sunny and 72F", + "exit_code": 0, + }, + }, + {"type": "item.started", "item": {"id": "msg1", "type": "agent_message", "text": ""}}, + { + "type": "item.completed", + "item": {"id": "msg1", "type": "agent_message", "text": "The weather is sunny and 72F."}, + }, + { + "type": "turn.completed", + "usage": {"input_tokens": 20, "output_tokens": 8, "total_tokens": 28}, + }, + ] + + +async def _aiter(events: list[dict[str, Any]]) -> AsyncIterator[dict[str, Any]]: + for e in events: + yield e + + +# --------------------------------------------------------------------------- +# Fake streaming backend +# --------------------------------------------------------------------------- + + +class _FakeCtx: + def __init__(self, sink: list[Any], ctype: str, initial_content: Any) -> None: + self.sink = sink + self.ctype = ctype + self.task_message = TaskMessage(id="msg-1", task_id="task1", content=initial_content) + + async def __aenter__(self) -> "_FakeCtx": + self.sink.append(("open", self.ctype, self.task_message.content)) + return self + + async def __aexit__(self, *args: Any) -> bool: + await self.close() + return False + + async def close(self) -> None: + self.sink.append(("close", self.ctype)) + + async def stream_update(self, update: Any) -> Any: + self.sink.append(("delta", self.ctype, update)) + return update + + +class _FakeStreaming: + def __init__(self) -> None: + self.sink: list[Any] = [] + self.messages_opened: list[Any] = [] + + def streaming_task_message_context( + self, + task_id: str, + initial_content: Any, + streaming_mode: str = "coalesced", + created_at: Any = None, + ) -> _FakeCtx: + ctype = getattr(initial_content, "type", None) or "" + self.messages_opened.append(initial_content) + return _FakeCtx(self.sink, ctype, initial_content) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _run_auto_send_turn( + events: list[dict[str, Any]], + trace_id: str | None = None, + parent_span_id: str | None = None, + fake_tracing: FakeTracing | None = None, +) -> tuple[TurnResult, _FakeStreaming]: + fake_streaming = _FakeStreaming() + tracer: SpanTracer | bool | None = None + if trace_id and fake_tracing is not None: + tracer = SpanTracer( + trace_id=trace_id, + parent_span_id=parent_span_id, + task_id="task1", + tracing=fake_tracing, + ) + + turn = CodexTurn(_aiter(events), model="o4-mini") + emitter = UnifiedEmitter( + task_id="task1", + trace_id=trace_id, + parent_span_id=parent_span_id, + tracer=tracer if tracer is not None else False, + streaming=fake_streaming, + ) + result = await emitter.auto_send_turn(turn) + return result, fake_streaming + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestAsyncAutoSendMessageOrder: + async def test_tool_request_pushed_before_tool_response(self) -> None: + _, fake_streaming = await _run_auto_send_turn(_tool_then_text_events()) + types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] + assert "tool_request" in types + assert "tool_response" in types + assert types.index("tool_request") < types.index("tool_response") + + async def test_text_pushed_last(self) -> None: + _, fake_streaming = await _run_auto_send_turn(_tool_then_text_events()) + types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] + assert types[-1] == "text", f"Expected last type=text, got {types}" + + +class TestAsyncAutoSendContent: + async def test_tool_response_content(self) -> None: + _, fake_streaming = await _run_auto_send_turn(_tool_then_text_events()) + tool_resps = [m for m in fake_streaming.messages_opened if isinstance(m, ToolResponseContent)] + assert len(tool_resps) == 1 + assert "72F" in str(tool_resps[0].content) + + async def test_tool_call_ids_match(self) -> None: + _, fake_streaming = await _run_auto_send_turn(_tool_then_text_events()) + tool_req = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolRequestContent)) + tool_resp = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolResponseContent)) + assert tool_req.tool_call_id == tool_resp.tool_call_id + + +class TestAsyncAutoSendFinalTextAndUsage: + async def test_final_text_matches_last_text(self) -> None: + result, _ = await _run_auto_send_turn(_tool_then_text_events()) + assert result.final_text == "The weather is sunny and 72F." + + async def test_usage_from_turn_completed(self) -> None: + """TurnResult.usage reflects the codex turn.completed usage + tool count.""" + result, _ = await _run_auto_send_turn(_tool_then_text_events()) + assert result.usage is not None + assert result.usage.input_tokens == 20 + assert result.usage.output_tokens == 8 + assert result.usage.total_tokens == 28 + assert result.usage.model == "o4-mini" + assert result.usage.num_tool_calls == 1 + assert result.usage.num_llm_calls == 1 + + async def test_context_lifecycle_open_then_close(self) -> None: + _, fake_streaming = await _run_auto_send_turn(_tool_then_text_events()) + opens = [e for e in fake_streaming.sink if e[0] == "open"] + closes = [e for e in fake_streaming.sink if e[0] == "close"] + assert len(opens) == len(closes) + assert len(opens) == len(fake_streaming.messages_opened) + + +class TestAsyncAutoSendSpanDerivation: + async def test_tool_span_derived_on_async_path(self) -> None: + fake_tracing = FakeTracing() + await _run_auto_send_turn( + _tool_then_text_events(), + trace_id="trace1", + parent_span_id="parent", + fake_tracing=fake_tracing, + ) + assert len(fake_tracing.started) == 1 + assert len(fake_tracing.ended) == 1 + assert "72F" in str(fake_tracing.ended[0][1]) diff --git a/tests/lib/core/harness/test_harness_codex_sync.py b/tests/lib/core/harness/test_harness_codex_sync.py new file mode 100644 index 000000000..6129716ee --- /dev/null +++ b/tests/lib/core/harness/test_harness_codex_sync.py @@ -0,0 +1,276 @@ +"""Integration test: sync (HTTP-yield) channel with a codex turn. + +Exercises the unified harness surface (UnifiedEmitter.yield_turn + CodexTurn) +with hand-built codex ``exec --json`` event dicts so the test runs fully offline +(no codex CLI subprocess, no API keys, no Agentex server). + +Native stream shapes +--------------------- +``CodexTurn`` consumes an async iterator of raw codex events (str | dict). The +event shapes used here are copied verbatim from the codex turn test +(tests/lib/adk/test_codex_turn.py) and the codex conformance fixtures +(tests/lib/core/harness/conformance/test_codex_conformance.py): + + command_execution item -> Start(ToolRequestContent) + Done + Full(ToolResponseContent) + agent_message item -> Start(TextContent) + ... + Full/Done + reasoning item -> Start(ReasoningContent) + Full(ReasoningContent) + turn.completed -> usage + +Reasoning note +-------------- +The codex converter emits reasoning as Start(ReasoningContent) + deltas + Done. +The SpanDeriver opens a reasoning span on Start and closes it normally when the +Done event is observed (is_complete=True). + +What is tested +-------------- +- The sync handler forwards StreamTaskMessage* events in canonical order: + tool_request (Start+Done) -> tool_response (Full) -> text. +- The tool_response carries the command output, keyed by item id. +- With a trace_id + fake tracing, a tool span is opened on Done(tool_request) + and closed on the matching Full(tool_response), and a reasoning span is + opened and closed normally for a reasoning item. + +What is NOT covered without live infrastructure +----------------------------------------------- +- Actual HTTP streaming over the ACP sync endpoint. +- A real codex CLI subprocess / live model behaviour. +- The full FastACP request/response lifecycle. + +See also: test_harness_codex_async.py and test_harness_codex_temporal.py. +""" + +from __future__ import annotations + +from typing import Any, AsyncIterator, override + +from agentex.lib.core.harness.types import OpenSpan, CloseSpan +from agentex.lib.core.harness.tracer import SpanTracer +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.types.task_message_update import ( + StreamTaskMessageFull, + StreamTaskMessageStart, +) +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.lib.adk._modules._codex_turn import CodexTurn + +from ._fakes import FakeTracing + +# --------------------------------------------------------------------------- +# Native codex event fixtures (copied from the turn + conformance tests) +# --------------------------------------------------------------------------- + + +def _tool_then_text_events() -> list[dict[str, Any]]: + """A command_execution tool round-trip followed by a final text reply.""" + return [ + {"type": "thread.started", "thread_id": "thread-abc"}, + {"type": "turn.started"}, + { + "type": "item.started", + "item": {"id": "tool1", "type": "command_execution", "command": "cat weather.txt"}, + }, + { + "type": "item.completed", + "item": { + "id": "tool1", + "type": "command_execution", + "command": "cat weather.txt", + "aggregated_output": "sunny and 72F", + "exit_code": 0, + }, + }, + {"type": "item.started", "item": {"id": "msg1", "type": "agent_message", "text": ""}}, + { + "type": "item.completed", + "item": {"id": "msg1", "type": "agent_message", "text": "The weather is sunny and 72F."}, + }, + { + "type": "turn.completed", + "usage": {"input_tokens": 20, "output_tokens": 8, "total_tokens": 28}, + }, + ] + + +def _reasoning_events() -> list[dict[str, Any]]: + return [ + {"type": "thread.started", "thread_id": "thread-reason"}, + {"type": "item.started", "item": {"id": "r1", "type": "reasoning", "text": ""}}, + { + "type": "item.completed", + "item": {"id": "r1", "type": "reasoning", "text": "Step 1: analyze\nStep 2: solve"}, + }, + {"type": "item.started", "item": {"id": "msg2", "type": "agent_message", "text": ""}}, + {"type": "item.completed", "item": {"id": "msg2", "type": "agent_message", "text": "42"}}, + {"type": "turn.completed", "usage": {"input_tokens": 30, "output_tokens": 20, "total_tokens": 50}}, + ] + + +async def _aiter(events: list[dict[str, Any]]) -> AsyncIterator[dict[str, Any]]: + for e in events: + yield e + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _run_yield_turn( + events: list[dict[str, Any]], + trace_id: str | None = None, + parent_span_id: str | None = None, + fake_tracing: FakeTracing | None = None, +) -> list[Any]: + tracer: SpanTracer | bool | None = None + if trace_id and fake_tracing is not None: + tracer = SpanTracer( + trace_id=trace_id, + parent_span_id=parent_span_id, + task_id="task1", + tracing=fake_tracing, + ) + + turn = CodexTurn(_aiter(events), model="o4-mini") + emitter = UnifiedEmitter( + task_id="task1", + trace_id=trace_id, + parent_span_id=parent_span_id, + tracer=tracer if tracer is not None else False, + ) + return [ev async for ev in emitter.yield_turn(turn)] + + +# --------------------------------------------------------------------------- +# Tests: event order and content +# --------------------------------------------------------------------------- + + +class TestSyncYieldEventOrder: + async def test_tool_request_precedes_tool_response(self) -> None: + events = await _run_yield_turn(_tool_then_text_events()) + content_types = [ + getattr(getattr(ev, "content", None), "type", None) + for ev in events + if isinstance(ev, (StreamTaskMessageStart, StreamTaskMessageFull)) + ] + assert "tool_request" in content_types + assert "tool_response" in content_types + assert content_types.index("tool_request") < content_types.index("tool_response") + + async def test_text_appears_after_tool_response(self) -> None: + events = await _run_yield_turn(_tool_then_text_events()) + tool_resp_pos = next( + i + for i, ev in enumerate(events) + if isinstance(ev, StreamTaskMessageFull) + and getattr(getattr(ev, "content", None), "type", None) == "tool_response" + ) + text_start_pos = next( + i + for i, ev in enumerate(events) + if isinstance(ev, StreamTaskMessageStart) and getattr(getattr(ev, "content", None), "type", None) == "text" + ) + assert tool_resp_pos < text_start_pos + + async def test_tool_response_carries_command_output(self) -> None: + events = await _run_yield_turn(_tool_then_text_events()) + full_responses = [ + ev.content + for ev in events + if isinstance(ev, StreamTaskMessageFull) and isinstance(getattr(ev, "content", None), ToolResponseContent) + ] + assert len(full_responses) == 1 + tool_response = full_responses[0] + assert isinstance(tool_response, ToolResponseContent) + assert "72F" in str(tool_response.content) + + async def test_tool_request_present(self) -> None: + events = await _run_yield_turn(_tool_then_text_events()) + tool_reqs = [ + ev.content for ev in events if isinstance(getattr(ev, "content", None), ToolRequestContent) + ] + assert len(tool_reqs) == 1 + + +# --------------------------------------------------------------------------- +# Tests: span derivation on the yield path +# --------------------------------------------------------------------------- + + +class TestSyncYieldSpanDerivation: + async def test_tool_span_opened_and_closed(self) -> None: + """Done(tool_request) opens a tool span; Full(tool_response) closes it.""" + fake_tracing = FakeTracing() + await _run_yield_turn( + _tool_then_text_events(), + trace_id="trace1", + parent_span_id="parent-span", + fake_tracing=fake_tracing, + ) + assert len(fake_tracing.started) == 1 + assert len(fake_tracing.ended) == 1 + _name, parent_id, _input = fake_tracing.started[0] + assert parent_id == "parent-span" + + async def test_tool_span_output_is_command_output(self) -> None: + fake_tracing = FakeTracing() + await _run_yield_turn( + _tool_then_text_events(), + trace_id="trace1", + parent_span_id="parent-span", + fake_tracing=fake_tracing, + ) + _name, output = fake_tracing.ended[0] + assert "72F" in str(output) + + async def test_reasoning_span_opened_then_done_closed(self) -> None: + """A codex reasoning item emits Start+Delta+Done: the reasoning span + opens and is closed normally when the Done event is observed.""" + received_signals: list[Any] = [] + + class _RecordingTracer(SpanTracer): + @override + async def handle(self, signal: Any) -> None: + received_signals.append(signal) + await super().handle(signal) + + fake_tracing = FakeTracing() + tracer = _RecordingTracer( + trace_id="trace1", + parent_span_id="parent-span", + task_id="task1", + tracing=fake_tracing, + ) + turn = CodexTurn(_aiter(_reasoning_events()), model="o4-mini") + emitter = UnifiedEmitter(task_id="task1", trace_id="trace1", parent_span_id="parent-span", tracer=tracer) + [_ async for _ in emitter.yield_turn(turn)] + + opens = [s for s in received_signals if isinstance(s, OpenSpan) and s.kind == "reasoning"] + closes = [s for s in received_signals if isinstance(s, CloseSpan) and str(s.key).startswith("reasoning:")] + assert len(opens) == 1, "Reasoning Start must open exactly one reasoning span" + assert len(closes) == 1, "Reasoning span must close exactly once" + assert closes[0].is_complete is True, "Done event closes the reasoning span as complete" + + async def test_no_trace_id_means_no_spans(self) -> None: + fake_tracing = FakeTracing() + turn = CodexTurn(_aiter(_tool_then_text_events()), model="o4-mini") + emitter = UnifiedEmitter(task_id="task1", trace_id=None, parent_span_id=None, tracing=fake_tracing) + [_ async for _ in emitter.yield_turn(turn)] + assert fake_tracing.started == [] + assert fake_tracing.ended == [] + + async def test_tracer_false_suppresses_spans(self) -> None: + fake_tracing = FakeTracing() + turn = CodexTurn(_aiter(_tool_then_text_events()), model="o4-mini") + emitter = UnifiedEmitter( + task_id="task1", + trace_id="trace1", + parent_span_id="parent-span", + tracer=False, + tracing=fake_tracing, + ) + [_ async for _ in emitter.yield_turn(turn)] + assert fake_tracing.started == [] + assert fake_tracing.ended == [] diff --git a/tests/lib/core/harness/test_harness_codex_temporal.py b/tests/lib/core/harness/test_harness_codex_temporal.py new file mode 100644 index 000000000..0af0b862b --- /dev/null +++ b/tests/lib/core/harness/test_harness_codex_temporal.py @@ -0,0 +1,180 @@ +"""Integration test: Temporal channel with a codex turn, offline. + +The codex tap is a pure library adapter (subprocess/sandbox provisioning lives +in the golden agent; there is no codex-specific temporal helper like langgraph's +``stream_langgraph_events``). In a Temporal deployment the codex CLI runs inside +a Temporal activity and the resulting canonical stream is delivered via the SAME +``UnifiedEmitter.auto_send_turn`` path used by the non-temporal async channel. +The only temporal-specific concern at the harness boundary is that the activity +stamps messages with a deterministic ``created_at`` (e.g. ``workflow.now()``) +for replay determinism. + +This suite exercises the auto_send path inside an activity-style call plus the +temporal-only contract: ``created_at`` is threaded through to every streaming +context. The native codex event shapes are copied verbatim from the codex turn +test / conformance fixtures. + +What is tested +-------------- +- The canonical message sequence (tool_request -> tool_response -> text) is + delivered via auto_send_turn, exactly as inside a Temporal activity. +- ``created_at`` passed to ``auto_send_turn`` is forwarded to every + ``streaming_task_message_context`` call (deterministic timestamping). +- Final text + usage from turn.completed are returned. + +What is NOT covered without live infrastructure +----------------------------------------------- +- Temporal scheduling / durability / replay behaviour. +- Redis streaming (requires a running Redis instance). +- A real codex CLI subprocess / live model behaviour. + +See also: test_harness_codex_sync.py and test_harness_codex_async.py. +""" + +from __future__ import annotations + +from typing import Any, AsyncIterator +from datetime import datetime, timezone + +from agentex.types.task_message import TaskMessage +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.lib.adk._modules._codex_turn import CodexTurn + + +def _tool_then_text_events() -> list[dict[str, Any]]: + return [ + {"type": "thread.started", "thread_id": "thread-abc"}, + { + "type": "item.started", + "item": {"id": "tool1", "type": "command_execution", "command": "cat weather.txt"}, + }, + { + "type": "item.completed", + "item": { + "id": "tool1", + "type": "command_execution", + "command": "cat weather.txt", + "aggregated_output": "sunny and 72F", + "exit_code": 0, + }, + }, + {"type": "item.started", "item": {"id": "msg1", "type": "agent_message", "text": ""}}, + { + "type": "item.completed", + "item": {"id": "msg1", "type": "agent_message", "text": "The weather is sunny and 72F."}, + }, + { + "type": "turn.completed", + "usage": {"input_tokens": 20, "output_tokens": 8, "total_tokens": 28}, + }, + ] + + +async def _aiter(events: list[dict[str, Any]]) -> AsyncIterator[dict[str, Any]]: + for e in events: + yield e + + +# --------------------------------------------------------------------------- +# Fake streaming backend that records created_at +# --------------------------------------------------------------------------- + + +class _FakeCtx: + def __init__(self, ctype: str, initial_content: Any) -> None: + self.ctype = ctype + self.task_message = TaskMessage(id="msg-1", task_id="task1", content=initial_content) + + async def __aenter__(self) -> "_FakeCtx": + return self + + async def __aexit__(self, *args: Any) -> bool: + await self.close() + return False + + async def close(self) -> None: + pass + + async def stream_update(self, update: Any) -> Any: + return update + + +class _FakeStreaming: + def __init__(self) -> None: + self.messages_opened: list[Any] = [] + self.created_ats: list[Any] = [] + + def streaming_task_message_context( + self, + task_id: str, + initial_content: Any, + streaming_mode: str = "coalesced", + created_at: Any = None, + ) -> _FakeCtx: + ctype = getattr(initial_content, "type", None) or "" + self.messages_opened.append(initial_content) + self.created_ats.append(created_at) + return _FakeCtx(ctype, initial_content) + + +async def _run_activity(events: list[dict[str, Any]], created_at: datetime | None) -> tuple[Any, _FakeStreaming]: + fake_streaming = _FakeStreaming() + turn = CodexTurn(_aiter(events), model="o4-mini") + emitter = UnifiedEmitter( + task_id="task1", + trace_id=None, + parent_span_id=None, + tracer=False, + streaming=fake_streaming, + ) + result = await emitter.auto_send_turn(turn, created_at=created_at) + return result, fake_streaming + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestTemporalActivityDelivery: + async def test_canonical_sequence_delivered(self) -> None: + _, fake_streaming = await _run_activity(_tool_then_text_events(), created_at=None) + types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] + assert "tool_request" in types + assert "tool_response" in types + assert types.index("tool_request") < types.index("tool_response") + assert types[-1] == "text" + + async def test_tool_round_trip_keyed_correctly(self) -> None: + _, fake_streaming = await _run_activity(_tool_then_text_events(), created_at=None) + tool_req = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolRequestContent)) + tool_resp = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolResponseContent)) + assert tool_req.tool_call_id == tool_resp.tool_call_id + + async def test_final_text_and_usage(self) -> None: + result, _ = await _run_activity(_tool_then_text_events(), created_at=None) + assert result.final_text == "The weather is sunny and 72F." + assert result.usage.total_tokens == 28 + assert result.usage.num_tool_calls == 1 + + +class TestTemporalCreatedAtThreading: + async def test_created_at_threaded_to_all_contexts(self) -> None: + fixed = datetime(2026, 6, 22, 12, 0, 0, tzinfo=timezone.utc) + _, fake_streaming = await _run_activity(_tool_then_text_events(), created_at=fixed) + assert len(fake_streaming.created_ats) == len(fake_streaming.messages_opened) + assert all(ts == fixed for ts in fake_streaming.created_ats), ( + f"Expected every context stamped with {fixed}, got {fake_streaming.created_ats}" + ) + + async def test_default_created_at_is_none(self) -> None: + _, fake_streaming = await _run_activity(_tool_then_text_events(), created_at=None) + assert all(ts is None for ts in fake_streaming.created_ats) + + async def test_created_at_deterministic_across_runs(self) -> None: + fixed = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + _, first = await _run_activity(_tool_then_text_events(), created_at=fixed) + _, second = await _run_activity(_tool_then_text_events(), created_at=fixed) + assert first.created_ats == second.created_ats diff --git a/tests/lib/core/harness/test_harness_langgraph_async.py b/tests/lib/core/harness/test_harness_langgraph_async.py new file mode 100644 index 000000000..09e92102b --- /dev/null +++ b/tests/lib/core/harness/test_harness_langgraph_async.py @@ -0,0 +1,276 @@ +"""Integration test: async (Redis-streaming) channel with a LangGraph agent. + +Exercises the unified harness surface (UnifiedEmitter.auto_send_turn + LangGraphTurn) +with a minimal fake LangGraph stream so the test runs fully offline (no API +keys, no Redis, no Agentex server). + +Agent description +----------------- +A simulated single-tool agent run using hand-crafted LangGraph event tuples: +one tool request + response, followed by a final text reply. + +What is tested +-------------- +- The async handler pushes the correct sequence of messages to the fake streaming + backend: Full(ToolRequest) + Full(ToolResponse) + text Start/Delta/Done. +- final_text accumulates all text (not just last segment — unified behavior). +- Tool messages go through streaming_task_message_context (not messages.create). +- With a SpanTracer, Full tool events produce tool spans (request opens, response + closes), aligning LangGraph tracing with the Start+Done harnesses. + +What is NOT covered without live infrastructure +----------------------------------------------- +- Actual Redis streaming (requires a running Redis instance). +- The ACP on_task_event_send / on_task_create / on_task_cancel lifecycle. +- Real LLM calls or real LangGraph graph execution. +- The full FastACP async request lifecycle. + +See also: test_harness_langgraph_sync.py and test_harness_langgraph_temporal.py +for the other two channels. +""" + +from __future__ import annotations + +import sys +from typing import Any +from dataclasses import field, dataclass + +import pytest + +from agentex.types.task_message import TaskMessage +from agentex.types.text_content import TextContent +from agentex.lib.core.harness.types import TurnResult +from agentex.lib.core.harness.tracer import SpanTracer +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.lib.adk._modules._langgraph_turn import LangGraphTurn + +from ._fakes import FakeTracing + +# --------------------------------------------------------------------------- +# Remove conftest stubs so real langchain_core types are used +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _real_langchain_core(): + stub_keys = [k for k in sys.modules if k.startswith("langchain_core") or k.startswith("langgraph")] + saved = {k: sys.modules.pop(k) for k in stub_keys} + import importlib + + importlib.import_module("langchain_core.messages") + yield + sys.modules.update(saved) + + +# --------------------------------------------------------------------------- +# Fake streaming backend (replaces adk.streaming; no Redis required) +# --------------------------------------------------------------------------- + + +@dataclass +class _FakeCtx: + ctype: str + initial_content: Any + task_message: TaskMessage + closed: bool = False + deltas: list[Any] = field(default_factory=list) + + async def __aenter__(self) -> "_FakeCtx": + return self + + async def __aexit__(self, *args: Any) -> bool: + await self.close() + return False + + async def close(self) -> None: + self.closed = True + + async def stream_update(self, update: Any) -> Any: + self.deltas.append(update) + return update + + +class _FakeStreaming: + def __init__(self) -> None: + self.contexts: list[_FakeCtx] = [] + + def streaming_task_message_context(self, task_id: str, initial_content: Any, **kw: Any) -> _FakeCtx: + ctype = getattr(initial_content, "type", None) or "" + tm = TaskMessage(id=f"m{len(self.contexts) + 1}", task_id=task_id, content=initial_content) + ctx = _FakeCtx(ctype=ctype, initial_content=initial_content, task_message=tm) + self.contexts.append(ctx) + return ctx + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_stream(events: list[tuple[str, Any]]): + async def _gen(): + for e in events: + yield e + + return _gen() + + +async def _run_auto_send_turn( + stream_events: list[tuple[str, Any]], + trace_id: str | None = None, +) -> tuple[TurnResult, _FakeStreaming, FakeTracing | None]: + fake_streaming = _FakeStreaming() + fake_tracing = FakeTracing() if trace_id else None + + tracer: SpanTracer | bool = False + if trace_id and fake_tracing is not None: + tracer = SpanTracer(trace_id=trace_id, parent_span_id=None, task_id="task1", tracing=fake_tracing) + + turn = LangGraphTurn(_make_stream(stream_events), model=None) + emitter = UnifiedEmitter( + task_id="task1", + trace_id=trace_id, + parent_span_id=None, + tracer=tracer, + streaming=fake_streaming, + ) + result = await emitter.auto_send_turn(turn) + return result, fake_streaming, fake_tracing + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestAsyncAutoSendChannel: + async def test_text_only_streams_text_and_returns_final(self): + from langchain_core.messages import AIMessage, AIMessageChunk + + chunk = AIMessageChunk(content="Hello from LangGraph!") + ai_msg = AIMessage(content="Hello from LangGraph!") + events = [ + ("messages", (chunk, {})), + ("updates", {"agent": {"messages": [ai_msg]}}), + ] + result, fake_streaming, _ = await _run_auto_send_turn(events) + + assert result.final_text == "Hello from LangGraph!" + text_ctxs = [c for c in fake_streaming.contexts if c.ctype == "text"] + assert len(text_ctxs) == 1 + assert text_ctxs[0].closed is True + + async def test_tool_call_posted_via_streaming_context(self): + from langchain_core.messages import AIMessage + + tc = {"id": "call_1", "name": "get_weather", "args": {"city": "Paris"}} + ai_msg = AIMessage(content="", tool_calls=[tc]) + events = [("updates", {"agent": {"messages": [ai_msg]}})] + + result, fake_streaming, _ = await _run_auto_send_turn(events) + + # Tool request via streaming_task_message_context (Full event) + tool_req_ctxs = [c for c in fake_streaming.contexts if isinstance(c.initial_content, ToolRequestContent)] + assert len(tool_req_ctxs) == 1 + assert tool_req_ctxs[0].initial_content.tool_call_id == "call_1" + assert tool_req_ctxs[0].closed is True + assert tool_req_ctxs[0].deltas == [], "Full messages have no deltas" + + async def test_tool_response_posted_via_streaming_context(self): + from langchain_core.messages import ToolMessage + + tool_msg = ToolMessage(content="Sunny, 72F", tool_call_id="call_1", name="get_weather") + events = [("updates", {"tools": {"messages": [tool_msg]}})] + + _, fake_streaming, _ = await _run_auto_send_turn(events) + + tool_resp_ctxs = [c for c in fake_streaming.contexts if isinstance(c.initial_content, ToolResponseContent)] + assert len(tool_resp_ctxs) == 1 + assert tool_resp_ctxs[0].initial_content.content == "Sunny, 72F" + assert tool_resp_ctxs[0].closed is True + + async def test_multi_step_final_text_is_last_segment(self): + """Unified surface: final_text uses last-segment semantics. + + auto_send resets final_text_parts when a new Start(TextContent) is seen, + so multi-step turns (text -> tool -> text) return only the LAST text segment. + This matches the behaviour documented in auto_send.py and mirrors + stream_pydantic_ai_events. + """ + from langchain_core.messages import AIMessage, ToolMessage, AIMessageChunk + + chunk1 = AIMessageChunk(content="Searching...") + ai_msg1 = AIMessage(content="Searching...", tool_calls=[{"id": "c1", "name": "s", "args": {}}]) + tool_msg = ToolMessage(content="results", tool_call_id="c1", name="s") + chunk2 = AIMessageChunk(content="Found it!") + ai_msg2 = AIMessage(content="Found it!") + + events = [ + ("messages", (chunk1, {})), + ("updates", {"agent": {"messages": [ai_msg1]}}), + ("updates", {"tools": {"messages": [tool_msg]}}), + ("messages", (chunk2, {})), + ("updates", {"agent": {"messages": [ai_msg2]}}), + ] + result, fake_streaming, _ = await _run_auto_send_turn(events) + + # Last segment only — first text segment is NOT in final_text + assert result.final_text == "Found it!" + + # Two text streaming contexts still opened (both streamed to Redis) + text_ctxs = [c for c in fake_streaming.contexts if isinstance(c.initial_content, TextContent)] + assert len(text_ctxs) == 2 + + async def test_empty_stream_returns_empty_final_text(self): + result, fake_streaming, _ = await _run_auto_send_turn([]) + assert result.final_text == "" + assert fake_streaming.contexts == [] + + async def test_turn_usage_populated_after_events_consumed(self): + """LangGraphTurn.usage() is populated via the on_final_ai_message callback + during event iteration. TurnResult.usage is a snapshot from before events run + (emitter.auto_send_turn evaluates turn.usage() eagerly); the authoritative + post-iteration usage is on turn.usage() directly.""" + from langchain_core.messages import AIMessage + + fake_streaming = _FakeStreaming() + usage_meta = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15} + ai_msg = AIMessage(content="hi", usage_metadata=usage_meta) + events = [("updates", {"agent": {"messages": [ai_msg]}})] + + turn = LangGraphTurn(_make_stream(events), model="gpt-4") + emitter = UnifiedEmitter( + task_id="task1", trace_id=None, parent_span_id=None, tracer=False, streaming=fake_streaming + ) + await emitter.auto_send_turn(turn) + + # After auto_send_turn, turn.usage() has the captured values + usage = turn.usage() + assert usage.input_tokens == 10 + assert usage.output_tokens == 5 + assert usage.total_tokens == 15 + + async def test_tracer_produces_tool_spans_for_full_events(self): + """SpanDeriver handles Full tool events (request opens, response closes). + + Full(ToolRequestContent) opens a tool span; Full(ToolResponseContent) closes it. + This aligns LangGraph tracing with Start+Done harnesses (pydantic-ai, openai-agents). + """ + from langchain_core.messages import AIMessage, ToolMessage + + tc = {"id": "c1", "name": "t", "args": {}} + ai_msg = AIMessage(content="", tool_calls=[tc]) + tool_msg = ToolMessage(content="ok", tool_call_id="c1", name="t") + + events = [ + ("updates", {"agent": {"messages": [ai_msg]}}), + ("updates", {"tools": {"messages": [tool_msg]}}), + ] + _, _, fake_tracing = await _run_auto_send_turn(events, trace_id="trace-1") + + assert fake_tracing is not None + assert len(fake_tracing.started) == 1, "Full(ToolRequestContent) opens one tool span" + assert fake_tracing.started[0][0] == "t", "span name matches the tool name" + assert len(fake_tracing.ended) == 1, "Full(ToolResponseContent) closes the span" diff --git a/tests/lib/core/harness/test_harness_langgraph_sync.py b/tests/lib/core/harness/test_harness_langgraph_sync.py new file mode 100644 index 000000000..67d213b6a --- /dev/null +++ b/tests/lib/core/harness/test_harness_langgraph_sync.py @@ -0,0 +1,205 @@ +"""Integration test: sync (HTTP-yield) channel with a LangGraph agent. + +Exercises the unified harness surface (UnifiedEmitter.yield_turn + LangGraphTurn) +with a minimal fake LangGraph stream so the test runs fully offline (no API +keys, no Redis, no Agentex server). + +Agent description +----------------- +A simulated single-tool agent run using hand-crafted LangGraph event tuples: +one tool request + response, followed by a final text reply. + +What is tested +-------------- +- The sync handler correctly yields StreamTaskMessage* events in order: + Full(ToolRequest) then Full(ToolResponse) then text Start+Delta+Done. +- With trace_id + fake tracing, the SpanDeriver fires for text events. +- LangGraph emits tool calls as Full events (not Start+Done); the SpanDeriver + opens a tool span on Full(ToolRequestContent) and closes it on the matching + Full(ToolResponseContent) (see test_tracer_produces_tool_spans_for_full_events). +- Final text is accumulated via yield mode. + +What is NOT covered without live infrastructure +----------------------------------------------- +- Actual HTTP streaming over the ACP sync endpoint. +- Real LLM calls or real LangGraph graph execution. +- The full FastACP request/response lifecycle. + +See also: test_harness_langgraph_async.py and test_harness_langgraph_temporal.py +for the other two channels. +""" + +from __future__ import annotations + +import sys +from typing import Any + +import pytest + +from agentex.lib.core.harness.tracer import SpanTracer +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.types.task_message_update import ( + StreamTaskMessageFull, + StreamTaskMessageStart, +) +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.lib.adk._modules._langgraph_turn import LangGraphTurn + +from ._fakes import FakeTracing + +# --------------------------------------------------------------------------- +# Remove conftest stubs so real langchain_core types are used +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _real_langchain_core(): + stub_keys = [k for k in sys.modules if k.startswith("langchain_core") or k.startswith("langgraph")] + saved = {k: sys.modules.pop(k) for k in stub_keys} + import importlib + + importlib.import_module("langchain_core.messages") + yield + sys.modules.update(saved) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_stream(events: list[tuple[str, Any]]): + async def _gen(): + for e in events: + yield e + + return _gen() + + +async def _run_yield_turn( + stream_events: list[tuple[str, Any]], trace_id: str | None = None +) -> tuple[list[Any], FakeTracing | None]: + fake_tracing = FakeTracing() if trace_id else None + tracer: SpanTracer | bool | None = None + if trace_id and fake_tracing is not None: + tracer = SpanTracer(trace_id=trace_id, parent_span_id=None, task_id="task1", tracing=fake_tracing) + + emitter = UnifiedEmitter( + task_id="task1", + trace_id=trace_id, + parent_span_id=None, + tracer=tracer if tracer is not None else False, + ) + turn = LangGraphTurn(_make_stream(stream_events), model=None) + out = [e async for e in emitter.yield_turn(turn)] + return out, fake_tracing + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestSyncYieldChannel: + async def test_text_only_stream_yields_start_delta_done(self): + from langchain_core.messages import AIMessage, AIMessageChunk + + chunk = AIMessageChunk(content="Hello from LangGraph!") + ai_msg = AIMessage(content="Hello from LangGraph!") + events = [ + ("messages", (chunk, {})), + ("updates", {"agent": {"messages": [ai_msg]}}), + ] + out, _ = await _run_yield_turn(events) + + types = [type(e).__name__ for e in out] + assert "StreamTaskMessageStart" in types + assert "StreamTaskMessageDelta" in types + assert "StreamTaskMessageDone" in types + + async def test_tool_call_yields_full_events(self): + from langchain_core.messages import AIMessage, ToolMessage + + tc = {"id": "call_1", "name": "get_weather", "args": {"city": "Paris"}} + ai_msg = AIMessage(content="", tool_calls=[tc]) + tool_msg = ToolMessage(content="Sunny, 72F", tool_call_id="call_1", name="get_weather") + events = [ + ("updates", {"agent": {"messages": [ai_msg]}}), + ("updates", {"tools": {"messages": [tool_msg]}}), + ] + out, _ = await _run_yield_turn(events) + + full_events = [e for e in out if isinstance(e, StreamTaskMessageFull)] + assert len(full_events) == 2 + + contents = [e.content for e in full_events] + assert any(isinstance(c, ToolRequestContent) for c in contents) + assert any(isinstance(c, ToolResponseContent) for c in contents) + + async def test_multi_step_yields_events_in_order(self): + from langchain_core.messages import AIMessage, ToolMessage, AIMessageChunk + + chunk1 = AIMessageChunk(content="Searching...") + ai_msg1 = AIMessage(content="Searching...", tool_calls=[{"id": "c1", "name": "search", "args": {"q": "test"}}]) + tool_msg = ToolMessage(content="results", tool_call_id="c1", name="search") + chunk2 = AIMessageChunk(content="Found it!") + ai_msg2 = AIMessage(content="Found it!") + + events = [ + ("messages", (chunk1, {})), + ("updates", {"agent": {"messages": [ai_msg1]}}), + ("updates", {"tools": {"messages": [tool_msg]}}), + ("messages", (chunk2, {})), + ("updates", {"agent": {"messages": [ai_msg2]}}), + ] + out, _ = await _run_yield_turn(events) + + # Should have multiple start events (one per text segment) + starts = [e for e in out if isinstance(e, StreamTaskMessageStart)] + assert len(starts) >= 2 + # And two Full events (tool req + tool resp) + fulls = [e for e in out if isinstance(e, StreamTaskMessageFull)] + assert len(fulls) == 2 + + async def test_empty_stream_yields_nothing(self): + out, _ = await _run_yield_turn([]) + assert out == [] + + async def test_tracer_produces_tool_spans_for_full_events(self): + """SpanDeriver handles Full tool events (request opens, response closes). + + Full(ToolRequestContent) opens a tool span; Full(ToolResponseContent) closes it. + This aligns LangGraph tracing with Start+Done harnesses (pydantic-ai, openai-agents). + """ + from langchain_core.messages import AIMessage, ToolMessage + + tc = {"id": "c1", "name": "t", "args": {}} + ai_msg = AIMessage(content="", tool_calls=[tc]) + tool_msg = ToolMessage(content="ok", tool_call_id="c1", name="t") + + events = [ + ("updates", {"agent": {"messages": [ai_msg]}}), + ("updates", {"tools": {"messages": [tool_msg]}}), + ] + _, fake_tracing = await _run_yield_turn(events, trace_id="trace-1") + + assert fake_tracing is not None + assert len(fake_tracing.started) == 1, "Full(ToolRequestContent) opens one tool span" + assert fake_tracing.started[0][0] == "t", "span name matches the tool name" + assert len(fake_tracing.ended) == 1, "Full(ToolResponseContent) closes the span" + + async def test_usage_captured_after_yield(self): + from langchain_core.messages import AIMessage + + usage_meta = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15} + ai_msg = AIMessage(content="Hi!", usage_metadata=usage_meta) + events = [("updates", {"agent": {"messages": [ai_msg]}})] + + turn = LangGraphTurn(_make_stream(events), model="gpt-4") + emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) + _ = [e async for e in emitter.yield_turn(turn)] + + usage = turn.usage() + assert usage.input_tokens == 10 + assert usage.output_tokens == 5 diff --git a/tests/lib/core/harness/test_harness_langgraph_temporal.py b/tests/lib/core/harness/test_harness_langgraph_temporal.py new file mode 100644 index 000000000..219e92229 --- /dev/null +++ b/tests/lib/core/harness/test_harness_langgraph_temporal.py @@ -0,0 +1,232 @@ +"""Integration test: Temporal channel with a LangGraph agent. + +The Temporal LangGraph agent pattern uses ``emit_langgraph_messages`` (now in +``_langgraph_sync.py``) inside a Temporal activity. That helper is not +yet unified onto the harness surface (it has its own Redis-streaming code). + +This test file verifies the LangGraph Temporal agent's streaming behavior using +the same fake streaming infrastructure as test_harness_langgraph_async.py. The +key difference from the non-temporal async path is that in Temporal, each agent +turn runs inside a Temporal activity that has already been handed the task_id +and a pre-wired streaming client — so the ``UnifiedEmitter.auto_send_turn`` +path is identical. The graph activities and workflow scaffolding are not tested +here; that requires a running Temporal cluster. + +What is tested +-------------- +- stream_langgraph_events (the public async API used by temporal agent acp.py via + the workflow activity) produces the same result via the unified surface. +- Usage from AIMessage.usage_metadata is captured in TurnResult.usage. +- The auto_send_turn path for a temporal-style call (same as async). + +What is NOT covered without live infrastructure +----------------------------------------------- +- Actual Temporal workflow execution (requires a running Temporal cluster). +- The Temporal activity retry/compensation logic. +- LangGraph checkpoint storage via TemporalCheckpointer. +- emit_langgraph_messages (the Temporal-specific streaming helper). +- Real LLM calls or real LangGraph graph execution. + +See also: test_harness_langgraph_sync.py and test_harness_langgraph_async.py. +""" + +from __future__ import annotations + +import sys +from typing import Any +from dataclasses import field, dataclass + +import pytest + +from agentex.types.task_message import TaskMessage +from agentex.types.text_content import TextContent +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.lib.adk._modules._langgraph_turn import LangGraphTurn, stream_langgraph_events + +# --------------------------------------------------------------------------- +# Remove conftest stubs so real langchain_core types are used +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _real_langchain_core(): + stub_keys = [k for k in sys.modules if k.startswith("langchain_core") or k.startswith("langgraph")] + saved = {k: sys.modules.pop(k) for k in stub_keys} + import importlib + + importlib.import_module("langchain_core.messages") + yield + sys.modules.update(saved) + + +# --------------------------------------------------------------------------- +# Fake streaming backend +# --------------------------------------------------------------------------- + + +@dataclass +class _FakeCtx: + ctype: str + initial_content: Any + task_message: TaskMessage + closed: bool = False + deltas: list[Any] = field(default_factory=list) + + async def __aenter__(self) -> "_FakeCtx": + return self + + async def __aexit__(self, *args: Any) -> bool: + await self.close() + return False + + async def close(self) -> None: + self.closed = True + + async def stream_update(self, update: Any) -> Any: + self.deltas.append(update) + return update + + +class _FakeStreaming: + def __init__(self) -> None: + self.contexts: list[_FakeCtx] = [] + + def streaming_task_message_context(self, task_id: str, initial_content: Any, **kw: Any) -> _FakeCtx: + ctype = getattr(initial_content, "type", None) or "" + tm = TaskMessage(id=f"m{len(self.contexts) + 1}", task_id=task_id, content=initial_content) + ctx = _FakeCtx(ctype=ctype, initial_content=initial_content, task_message=tm) + self.contexts.append(ctx) + return ctx + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_stream(events: list[tuple[str, Any]]): + async def _gen(): + for e in events: + yield e + + return _gen() + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestTemporalAutoSendChannel: + async def test_stream_langgraph_events_plain_text(self, monkeypatch): + """stream_langgraph_events (used by temporal agents via the acp.py activity) returns + the accumulated final text.""" + from langchain_core.messages import AIMessage, AIMessageChunk + + from agentex.lib import adk as adk_module + + fake_streaming = _FakeStreaming() + monkeypatch.setattr(adk_module, "streaming", fake_streaming) + + chunk = AIMessageChunk(content="Hello Temporal!") + ai_msg = AIMessage(content="Hello Temporal!") + events = [ + ("messages", (chunk, {})), + ("updates", {"agent": {"messages": [ai_msg]}}), + ] + + final = await stream_langgraph_events(_make_stream(events), "task-1") + assert final == "Hello Temporal!" + + async def test_stream_langgraph_events_tool_call(self, monkeypatch): + from langchain_core.messages import AIMessage, ToolMessage + + from agentex.lib import adk as adk_module + + fake_streaming = _FakeStreaming() + monkeypatch.setattr(adk_module, "streaming", fake_streaming) + + tc = {"id": "c1", "name": "search", "args": {"q": "test"}} + ai_msg = AIMessage(content="", tool_calls=[tc]) + tool_msg = ToolMessage(content="results", tool_call_id="c1", name="search") + chunk_final = AIMessage(content="Here are the results.") + + events = [ + ("updates", {"agent": {"messages": [ai_msg]}}), + ("updates", {"tools": {"messages": [tool_msg]}}), + ("updates", {"agent": {"messages": [chunk_final]}}), + ] + + final = await stream_langgraph_events(_make_stream(events), "task-1") + + # Check tool request and response posted to fake streaming + tool_req_ctxs = [c for c in fake_streaming.contexts if isinstance(c.initial_content, ToolRequestContent)] + tool_resp_ctxs = [c for c in fake_streaming.contexts if isinstance(c.initial_content, ToolResponseContent)] + assert len(tool_req_ctxs) == 1 + assert len(tool_resp_ctxs) == 1 + assert tool_req_ctxs[0].initial_content.name == "search" + + async def test_langgraph_turn_auto_send_via_unified_emitter(self): + """Direct UnifiedEmitter.auto_send_turn path used by temporal agent workflow + activities. Uses a fake streaming backend (no Redis).""" + from langchain_core.messages import AIMessage, AIMessageChunk + + fake_streaming = _FakeStreaming() + chunk = AIMessageChunk(content="Temporal answer!") + ai_msg = AIMessage(content="Temporal answer!") + events = [ + ("messages", (chunk, {})), + ("updates", {"agent": {"messages": [ai_msg]}}), + ] + + turn = LangGraphTurn(_make_stream(events), model=None) + emitter = UnifiedEmitter( + task_id="task-1", + trace_id=None, + parent_span_id=None, + streaming=fake_streaming, + ) + result = await emitter.auto_send_turn(turn) + + assert result.final_text == "Temporal answer!" + text_ctxs = [c for c in fake_streaming.contexts if isinstance(c.initial_content, TextContent)] + assert len(text_ctxs) == 1 + + async def test_usage_captured_via_turn_after_events_consumed(self): + """Usage from AIMessage.usage_metadata is captured via the on_final_ai_message + callback during event iteration. The authoritative usage is on turn.usage() + after events are consumed (emitter.auto_send_turn evaluates turn.usage() + eagerly before iteration, so TurnResult.usage is a pre-iteration snapshot).""" + from langchain_core.messages import AIMessage + + fake_streaming = _FakeStreaming() + usage_meta = {"input_tokens": 20, "output_tokens": 10, "total_tokens": 30} + ai_msg = AIMessage(content="answer", usage_metadata=usage_meta) + events = [("updates", {"agent": {"messages": [ai_msg]}})] + + turn = LangGraphTurn(_make_stream(events), model="gpt-4o") + emitter = UnifiedEmitter( + task_id="task-1", + trace_id=None, + parent_span_id=None, + streaming=fake_streaming, + ) + await emitter.auto_send_turn(turn) + + # After auto_send_turn, turn.usage() has the captured values + usage = turn.usage() + assert usage.input_tokens == 20 + assert usage.output_tokens == 10 + assert usage.total_tokens == 30 + + async def test_empty_stream_returns_empty_string(self, monkeypatch): + from agentex.lib import adk as adk_module + + fake_streaming = _FakeStreaming() + monkeypatch.setattr(adk_module, "streaming", fake_streaming) + + final = await stream_langgraph_events(_make_stream([]), "task-1") + assert final == "" + assert fake_streaming.contexts == [] diff --git a/tests/lib/core/harness/test_harness_openai_async.py b/tests/lib/core/harness/test_harness_openai_async.py new file mode 100644 index 000000000..1329b94b9 --- /dev/null +++ b/tests/lib/core/harness/test_harness_openai_async.py @@ -0,0 +1,305 @@ +"""Integration test: async (Redis-streaming) channel with an OpenAI-agents turn. + +Exercises the unified harness surface (UnifiedEmitter.auto_send_turn + OpenAITurn) +with hand-built canonical StreamTaskMessage* streams and a fake streaming +backend so the test runs fully offline (no API keys, no Redis, no Agentex +server). + +The canonical event shapes are copied from the OpenAI converter contract +(see tests/lib/core/harness/conformance/test_openai_conformance.py): tool calls +are Full(ToolRequestContent) + Full(ToolResponseContent); text is +Start+Delta+Done. + +What is tested +-------------- +- auto_send pushes the correct message contexts to the fake streaming backend: + tool_request + tool_response + text (in that order). +- TurnResult.final_text equals the accumulated text deltas. +- TurnResult carries a TurnUsage; via the OpenAITurn result/converter path the + aggregated token usage (input/output/total + num_llm_calls) is surfaced in + TurnResult.usage. +- With a SpanTracer + fake tracing, a tool span is derived on the async path. + +What is NOT covered without live infrastructure +----------------------------------------------- +- Actual Redis streaming. +- The ACP on_task_event_send / on_task_create / on_task_cancel lifecycle. +- A real Runner.run_streamed execution / live OpenAI model behaviour. + +See also: test_harness_openai_sync.py and test_harness_openai_temporal.py. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from agents.usage import Usage + +from agentex.types.text_delta import TextDelta +from agentex.types.task_message import TaskMessage +from agentex.types.text_content import TextContent +from agentex.lib.core.harness.types import TurnResult, StreamTaskMessage +from agentex.lib.core.harness.tracer import SpanTracer +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.lib.adk._modules._openai_turn import OpenAITurn + +from ._fakes import FakeTracing + +# --------------------------------------------------------------------------- +# Canonical event fixtures (copied from the OpenAI converter contract) +# --------------------------------------------------------------------------- + + +def _tool_then_text_events() -> list[StreamTaskMessage]: + return [ + StreamTaskMessageFull( + type="full", + index=0, + content=ToolRequestContent( + type="tool_request", + author="agent", + tool_call_id="call_1", + name="get_weather", + arguments={"city": "Paris"}, + ), + ), + StreamTaskMessageFull( + type="full", + index=1, + content=ToolResponseContent( + type="tool_response", + author="agent", + tool_call_id="call_1", + name="get_weather", + content="The weather in Paris is sunny and 72F", + ), + ), + StreamTaskMessageStart( + type="start", + index=2, + content=TextContent(type="text", author="agent", content=""), + ), + StreamTaskMessageDelta(type="delta", index=2, delta=TextDelta(type="text", text_delta="Sunny ")), + StreamTaskMessageDelta(type="delta", index=2, delta=TextDelta(type="text", text_delta="and 72F.")), + StreamTaskMessageDone(type="done", index=2), + ] + + +async def _canonical_stream(events: list[StreamTaskMessage]): + for e in events: + yield e + + +# --------------------------------------------------------------------------- +# Fake streaming backend (replaces adk.streaming; no Redis required) +# --------------------------------------------------------------------------- + + +class _FakeCtx: + def __init__(self, sink: list[Any], ctype: str, initial_content: Any) -> None: + self.sink = sink + self.ctype = ctype + self.task_message = TaskMessage(id="msg-1", task_id="task1", content=initial_content) + + async def __aenter__(self) -> "_FakeCtx": + self.sink.append(("open", self.ctype, self.task_message.content)) + return self + + async def __aexit__(self, *args: Any) -> bool: + await self.close() + return False + + async def close(self) -> None: + self.sink.append(("close", self.ctype)) + + async def stream_update(self, update: Any) -> Any: + self.sink.append(("delta", self.ctype, update)) + return update + + +class _FakeStreaming: + def __init__(self) -> None: + self.sink: list[Any] = [] + self.messages_opened: list[Any] = [] + + def streaming_task_message_context( + self, + task_id: str, + initial_content: Any, + streaming_mode: str = "coalesced", + created_at: Any = None, + ) -> _FakeCtx: + ctype = getattr(initial_content, "type", None) or "" + self.messages_opened.append(initial_content) + return _FakeCtx(self.sink, ctype, initial_content) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _run_auto_send_turn( + events: list[StreamTaskMessage], + trace_id: str | None = None, + parent_span_id: str | None = None, + fake_tracing: FakeTracing | None = None, +) -> tuple[TurnResult, _FakeStreaming]: + fake_streaming = _FakeStreaming() + tracer: SpanTracer | bool | None = None + if trace_id and fake_tracing is not None: + tracer = SpanTracer( + trace_id=trace_id, + parent_span_id=parent_span_id, + task_id="task1", + tracing=fake_tracing, + ) + + turn = OpenAITurn(stream=_canonical_stream(events), model="gpt-4o") + emitter = UnifiedEmitter( + task_id="task1", + trace_id=trace_id, + parent_span_id=parent_span_id, + tracer=tracer if tracer is not None else False, + streaming=fake_streaming, + ) + result = await emitter.auto_send_turn(turn) + return result, fake_streaming + + +# --------------------------------------------------------------------------- +# Tests: message order and content +# --------------------------------------------------------------------------- + + +class TestAsyncAutoSendMessageOrder: + async def test_tool_request_pushed_before_tool_response(self) -> None: + _, fake_streaming = await _run_auto_send_turn(_tool_then_text_events()) + message_types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] + assert "tool_request" in message_types + assert message_types.index("tool_request") < message_types.index("tool_response") + + async def test_text_pushed_last(self) -> None: + _, fake_streaming = await _run_auto_send_turn(_tool_then_text_events()) + message_types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] + assert message_types[-1] == "text", f"Expected last message type=text, got {message_types}" + + async def test_exactly_three_messages(self) -> None: + _, fake_streaming = await _run_auto_send_turn(_tool_then_text_events()) + assert len(fake_streaming.messages_opened) == 3, ( + f"Expected 3 messages, got {[getattr(m, 'type', None) for m in fake_streaming.messages_opened]}" + ) + + +class TestAsyncAutoSendContentVerification: + async def test_tool_request_content(self) -> None: + _, fake_streaming = await _run_auto_send_turn(_tool_then_text_events()) + tool_reqs = [m for m in fake_streaming.messages_opened if isinstance(m, ToolRequestContent)] + assert len(tool_reqs) == 1 + assert tool_reqs[0].name == "get_weather" + + async def test_tool_response_content(self) -> None: + _, fake_streaming = await _run_auto_send_turn(_tool_then_text_events()) + tool_resps = [m for m in fake_streaming.messages_opened if isinstance(m, ToolResponseContent)] + assert len(tool_resps) == 1 + assert "72F" in str(tool_resps[0].content) + assert tool_resps[0].name == "get_weather" + + async def test_tool_call_ids_match(self) -> None: + _, fake_streaming = await _run_auto_send_turn(_tool_then_text_events()) + tool_req = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolRequestContent)) + tool_resp = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolResponseContent)) + assert tool_req.tool_call_id == tool_resp.tool_call_id + + +class TestAsyncAutoSendFinalTextAndUsage: + async def test_final_text_matches_deltas(self) -> None: + result, _ = await _run_auto_send_turn(_tool_then_text_events()) + assert result.final_text == "Sunny and 72F." + + async def test_turn_result_has_usage(self) -> None: + """An injected canonical stream has no run to read usage from, so usage + carries only the model name (input_tokens stays None).""" + result, _ = await _run_auto_send_turn(_tool_then_text_events()) + assert result.usage is not None + assert result.usage.model == "gpt-4o" + + async def test_context_lifecycle_open_then_close(self) -> None: + _, fake_streaming = await _run_auto_send_turn(_tool_then_text_events()) + opens = [e for e in fake_streaming.sink if e[0] == "open"] + closes = [e for e in fake_streaming.sink if e[0] == "close"] + assert len(opens) == len(closes) == 3 + + async def test_usage_populated_from_result_path(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Via the OpenAITurn result/converter path, aggregated token usage is + surfaced on TurnResult.usage after the stream is consumed. + + Mirrors the OpenAI turn test: a fake RunResultStreaming exposes + raw_responses with a Usage, and the converter is monkeypatched to a + passthrough so the canonical text stream is delivered while usage is read + from raw_responses. + """ + import agentex.lib.adk._modules._openai_turn as turn_mod + + canonical: list[StreamTaskMessage] = [ + StreamTaskMessageStart( + type="start", index=0, content=TextContent(type="text", author="agent", content="") + ), + StreamTaskMessageDelta(type="delta", index=0, delta=TextDelta(type="text", text_delta="hi")), + StreamTaskMessageDone(type="done", index=0), + ] + + class _FakeResult: + def __init__(self) -> None: + self.raw_responses = [ + type("R", (), {"usage": Usage(requests=2, input_tokens=8, output_tokens=4, total_tokens=12)})() + ] + + def stream_events(self): # type: ignore[no-untyped-def] + return _canonical_stream(canonical) + + async def _passthrough(stream): # type: ignore[no-untyped-def] + async for e in stream: + yield e + + monkeypatch.setattr(turn_mod, "convert_openai_to_agentex_events", _passthrough) + + turn = OpenAITurn(result=_FakeResult(), model="gpt-4o") + emitter = UnifiedEmitter( + task_id="task1", + trace_id=None, + parent_span_id=None, + tracer=False, + streaming=_FakeStreaming(), + ) + result = await emitter.auto_send_turn(turn) + + assert result.final_text == "hi" + assert result.usage.model == "gpt-4o" + assert result.usage.num_llm_calls == 2 + assert result.usage.input_tokens == 8 + assert result.usage.output_tokens == 4 + assert result.usage.total_tokens == 12 + + +class TestAsyncAutoSendSpanDerivation: + async def test_tool_span_derived_on_async_path(self) -> None: + fake_tracing = FakeTracing() + await _run_auto_send_turn( + _tool_then_text_events(), + trace_id="trace1", + parent_span_id="parent", + fake_tracing=fake_tracing, + ) + assert len(fake_tracing.started) == 1 + assert fake_tracing.started[0][0] == "get_weather" + assert len(fake_tracing.ended) == 1 diff --git a/tests/lib/core/harness/test_harness_openai_sync.py b/tests/lib/core/harness/test_harness_openai_sync.py new file mode 100644 index 000000000..34a9b72c6 --- /dev/null +++ b/tests/lib/core/harness/test_harness_openai_sync.py @@ -0,0 +1,323 @@ +"""Integration test: sync (HTTP-yield) channel with an OpenAI-agents turn. + +Exercises the unified harness surface (UnifiedEmitter.yield_turn + OpenAITurn) +with hand-built canonical StreamTaskMessage* streams so the test runs fully +offline (no API keys, no live OpenAI Agents run, no Agentex server). + +Why an injected canonical stream +-------------------------------- +OpenAI's native ``RunResultStreaming`` events are heavy SDK objects; the +``OpenAITurn`` accepts a pre-built canonical ``stream=`` of StreamTaskMessage* +events that bypasses ``convert_openai_to_agentex_events``. The shapes used here +are copied verbatim from the OpenAI converter contract exercised by +``tests/lib/core/harness/conformance/test_openai_conformance.py`` (tool calls +are Full(ToolRequestContent) + Full(ToolResponseContent); reasoning is +Start(ReasoningContent) + Delta + Done). This keeps the canonical stream +faithful to what the live converter produces while staying offline. + +What is tested +-------------- +- The sync handler forwards StreamTaskMessage* events verbatim in canonical + order: tool_request (Full) -> tool_response (Full) -> text (Start+Delta+Done). +- Final accumulated text equals the seeded text deltas. +- With a trace_id + fake tracing, a tool span is opened (OpenSpan) on + Full(ToolRequestContent) and closed (CloseSpan) on the matching + Full(ToolResponseContent), and a reasoning span is opened/closed for a + reasoning segment — proving the SpanDeriver is wired on the yield path. + +What is NOT covered without live infrastructure +----------------------------------------------- +- Actual HTTP streaming over the ACP sync endpoint. +- A real ``Runner.run_streamed`` execution / live OpenAI model behaviour. +- ``convert_openai_to_agentex_events`` over real SDK events (covered by the + OpenAI turn + conformance suites). + +See also: test_harness_openai_async.py and test_harness_openai_temporal.py. +""" + +from __future__ import annotations + +from typing import Any, override + +from agentex.types.text_delta import TextDelta +from agentex.types.text_content import TextContent +from agentex.lib.core.harness.types import OpenSpan, CloseSpan, StreamTaskMessage +from agentex.lib.core.harness.tracer import SpanTracer +from agentex.types.reasoning_content import ReasoningContent +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.lib.adk._modules._openai_turn import OpenAITurn +from agentex.types.reasoning_content_delta import ReasoningContentDelta + +from ._fakes import FakeTracing + +# --------------------------------------------------------------------------- +# Canonical event fixtures (copied from the OpenAI converter contract) +# --------------------------------------------------------------------------- + + +def _tool_then_text_events() -> list[StreamTaskMessage]: + """A tool round-trip followed by a final text reply. + + Mirrors the OpenAI converter's tool path: a Full(ToolRequestContent) for the + call and a Full(ToolResponseContent) for the result (matched by tool_call_id), + then a streamed text answer. + """ + return [ + StreamTaskMessageFull( + type="full", + index=0, + content=ToolRequestContent( + type="tool_request", + author="agent", + tool_call_id="call_1", + name="get_weather", + arguments={"city": "Paris"}, + ), + ), + StreamTaskMessageFull( + type="full", + index=1, + content=ToolResponseContent( + type="tool_response", + author="agent", + tool_call_id="call_1", + name="get_weather", + content="The weather in Paris is sunny and 72F", + ), + ), + StreamTaskMessageStart( + type="start", + index=2, + content=TextContent(type="text", author="agent", content=""), + ), + StreamTaskMessageDelta(type="delta", index=2, delta=TextDelta(type="text", text_delta="Sunny ")), + StreamTaskMessageDelta(type="delta", index=2, delta=TextDelta(type="text", text_delta="and 72F.")), + StreamTaskMessageDone(type="done", index=2), + ] + + +def _reasoning_events() -> list[StreamTaskMessage]: + """A reasoning segment: Start(ReasoningContent) + Delta + Done.""" + return [ + StreamTaskMessageStart( + type="start", + index=0, + content=ReasoningContent(type="reasoning", author="agent", summary=["Thinking..."]), + ), + StreamTaskMessageDelta( + type="delta", + index=0, + delta=ReasoningContentDelta(type="reasoning_content", content_index=0, content_delta="step 1"), + ), + StreamTaskMessageDone(type="done", index=0), + ] + + +async def _canonical_stream(events: list[StreamTaskMessage]): + for e in events: + yield e + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _run_yield_turn( + events: list[StreamTaskMessage], + trace_id: str | None = None, + parent_span_id: str | None = None, + fake_tracing: FakeTracing | None = None, +) -> list[Any]: + """Drive the sync (yield) path and collect all yielded events.""" + tracer: SpanTracer | bool | None = None + if trace_id and fake_tracing is not None: + tracer = SpanTracer( + trace_id=trace_id, + parent_span_id=parent_span_id, + task_id="task1", + tracing=fake_tracing, + ) + + turn = OpenAITurn(stream=_canonical_stream(events), model="gpt-4o") + emitter = UnifiedEmitter( + task_id="task1", + trace_id=trace_id, + parent_span_id=parent_span_id, + tracer=tracer if tracer is not None else False, + ) + return [ev async for ev in emitter.yield_turn(turn)] + + +# --------------------------------------------------------------------------- +# Tests: event order and content +# --------------------------------------------------------------------------- + + +class TestSyncYieldEventOrder: + async def test_tool_request_precedes_tool_response(self) -> None: + events = await _run_yield_turn(_tool_then_text_events()) + content_types = [ + getattr(getattr(ev, "content", None), "type", None) + for ev in events + if isinstance(ev, (StreamTaskMessageStart, StreamTaskMessageFull)) + ] + assert "tool_request" in content_types + assert "tool_response" in content_types + assert content_types.index("tool_request") < content_types.index("tool_response") + + async def test_text_appears_after_tool_response(self) -> None: + events = await _run_yield_turn(_tool_then_text_events()) + tool_resp_pos = next( + i + for i, ev in enumerate(events) + if isinstance(ev, StreamTaskMessageFull) + and getattr(getattr(ev, "content", None), "type", None) == "tool_response" + ) + text_start_pos = next( + i + for i, ev in enumerate(events) + if isinstance(ev, StreamTaskMessageStart) and getattr(getattr(ev, "content", None), "type", None) == "text" + ) + assert tool_resp_pos < text_start_pos + + async def test_tool_response_carries_weather_result(self) -> None: + events = await _run_yield_turn(_tool_then_text_events()) + full_responses = [ + ev + for ev in events + if isinstance(ev, StreamTaskMessageFull) and isinstance(getattr(ev, "content", None), ToolResponseContent) + ] + assert len(full_responses) == 1 + tool_response = full_responses[0].content + assert isinstance(tool_response, ToolResponseContent) + assert "72F" in str(tool_response.content) + assert tool_response.name == "get_weather" + + async def test_accumulated_text_matches_deltas(self) -> None: + events = await _run_yield_turn(_tool_then_text_events()) + accumulated = "".join( + ev.delta.text_delta + for ev in events + if isinstance(ev, StreamTaskMessageDelta) and isinstance(ev.delta, TextDelta) and ev.delta.text_delta + ) + assert accumulated == "Sunny and 72F." + + async def test_every_start_has_matching_done(self) -> None: + events = await _run_yield_turn(_tool_then_text_events()) + starts = {ev.index for ev in events if isinstance(ev, StreamTaskMessageStart)} + dones = {ev.index for ev in events if isinstance(ev, StreamTaskMessageDone)} + assert starts == dones, f"Unmatched Start/Done indices: starts={starts} dones={dones}" + + +# --------------------------------------------------------------------------- +# Tests: span derivation on the yield path +# --------------------------------------------------------------------------- + + +class TestSyncYieldSpanDerivation: + async def test_tool_span_opened_and_closed(self) -> None: + """Full(ToolRequestContent) opens a tool span; Full(ToolResponseContent) closes it.""" + fake_tracing = FakeTracing() + await _run_yield_turn( + _tool_then_text_events(), + trace_id="trace1", + parent_span_id="parent-span", + fake_tracing=fake_tracing, + ) + + assert len(fake_tracing.started) == 1, "Expected exactly one tool span opened" + assert len(fake_tracing.ended) == 1, "Expected exactly one tool span closed" + name, parent_id, _ = fake_tracing.started[0] + assert name == "get_weather" + assert parent_id == "parent-span" + + async def test_tool_span_output_is_tool_result(self) -> None: + fake_tracing = FakeTracing() + await _run_yield_turn( + _tool_then_text_events(), + trace_id="trace1", + parent_span_id="parent-span", + fake_tracing=fake_tracing, + ) + name, output = fake_tracing.ended[0] + assert name == "get_weather" + assert "72F" in str(output) + + async def test_reasoning_span_opened_and_closed(self) -> None: + """A reasoning segment opens and closes a reasoning span.""" + fake_tracing = FakeTracing() + await _run_yield_turn( + _reasoning_events(), + trace_id="trace1", + parent_span_id="parent-span", + fake_tracing=fake_tracing, + ) + assert fake_tracing.started_names == ["reasoning"] + assert len(fake_tracing.ended) == 1 + + async def test_no_trace_id_means_no_spans(self) -> None: + fake_tracing = FakeTracing() + turn = OpenAITurn(stream=_canonical_stream(_tool_then_text_events()), model="gpt-4o") + emitter = UnifiedEmitter( + task_id="task1", + trace_id=None, + parent_span_id=None, + tracing=fake_tracing, + ) + [_ async for _ in emitter.yield_turn(turn)] + assert fake_tracing.started == [] + assert fake_tracing.ended == [] + + async def test_tracer_false_suppresses_spans(self) -> None: + fake_tracing = FakeTracing() + turn = OpenAITurn(stream=_canonical_stream(_tool_then_text_events()), model="gpt-4o") + emitter = UnifiedEmitter( + task_id="task1", + trace_id="trace1", + parent_span_id="parent-span", + tracer=False, + tracing=fake_tracing, + ) + [_ async for _ in emitter.yield_turn(turn)] + assert fake_tracing.started == [] + assert fake_tracing.ended == [] + + async def test_span_signal_types(self) -> None: + """The signals received by the tracer are OpenSpan then CloseSpan.""" + received_signals: list[Any] = [] + + class _RecordingTracer(SpanTracer): + @override + async def handle(self, signal: Any) -> None: + received_signals.append(signal) + await super().handle(signal) + + fake_tracing = FakeTracing() + tracer = _RecordingTracer( + trace_id="trace1", + parent_span_id="parent", + task_id="task1", + tracing=fake_tracing, + ) + turn = OpenAITurn(stream=_canonical_stream(_tool_then_text_events()), model="gpt-4o") + emitter = UnifiedEmitter( + task_id="task1", + trace_id="trace1", + parent_span_id="parent", + tracer=tracer, + ) + [_ async for _ in emitter.yield_turn(turn)] + + assert len(received_signals) == 2 + assert isinstance(received_signals[0], OpenSpan) + assert isinstance(received_signals[1], CloseSpan) + assert received_signals[0].name == "get_weather" diff --git a/tests/lib/core/harness/test_harness_openai_temporal.py b/tests/lib/core/harness/test_harness_openai_temporal.py new file mode 100644 index 000000000..61cda37ef --- /dev/null +++ b/tests/lib/core/harness/test_harness_openai_temporal.py @@ -0,0 +1,195 @@ +"""Integration test: Temporal channel with an OpenAI-agents turn, offline. + +In a Temporal OpenAI deployment (see +examples/tutorials/10_async/10_temporal/120_openai_agents), the OpenAI Agents +SDK run executes inside a Temporal activity. Each turn's canonical stream is +delivered to Redis via the SAME ``UnifiedEmitter.auto_send_turn`` path used by +the non-temporal async channel — the only temporal-specific concern at the +harness boundary is that the activity stamps messages with a deterministic +``created_at`` (e.g. ``workflow.now()``) so replay is deterministic. + +There is no dedicated ``stream_openai_events`` temporal helper (unlike +langgraph's ``stream_langgraph_events``); the temporal OpenAI agent builds an +``OpenAITurn`` and calls ``auto_send_turn`` directly inside the activity. This +suite therefore exercises the auto_send path plus the temporal-only contract: +``created_at`` is threaded through to every streaming context. + +What is tested +-------------- +- The canonical message sequence (tool_request -> tool_response -> text) is + delivered via auto_send_turn, exactly as inside a Temporal activity. +- ``created_at`` passed to ``auto_send_turn`` is forwarded to every + ``streaming_task_message_context`` call (deterministic timestamping). +- Final text is returned from the turn. + +What is NOT covered without live infrastructure +----------------------------------------------- +- Temporal scheduling (workflow.signal -> activity dispatch). +- Temporal durability / replay behaviour. +- Redis streaming (requires a running Redis instance). +- A real Runner.run_streamed execution / live OpenAI model behaviour. + +See also: test_harness_openai_sync.py and test_harness_openai_async.py. +""" + +from __future__ import annotations + +from typing import Any +from datetime import datetime, timezone + +from agentex.types.text_delta import TextDelta +from agentex.types.task_message import TaskMessage +from agentex.types.text_content import TextContent +from agentex.lib.core.harness.types import StreamTaskMessage +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.lib.adk._modules._openai_turn import OpenAITurn + + +def _tool_then_text_events() -> list[StreamTaskMessage]: + return [ + StreamTaskMessageFull( + type="full", + index=0, + content=ToolRequestContent( + type="tool_request", + author="agent", + tool_call_id="call_1", + name="get_weather", + arguments={"city": "Paris"}, + ), + ), + StreamTaskMessageFull( + type="full", + index=1, + content=ToolResponseContent( + type="tool_response", + author="agent", + tool_call_id="call_1", + name="get_weather", + content="The weather in Paris is sunny and 72F", + ), + ), + StreamTaskMessageStart( + type="start", + index=2, + content=TextContent(type="text", author="agent", content=""), + ), + StreamTaskMessageDelta(type="delta", index=2, delta=TextDelta(type="text", text_delta="Sunny ")), + StreamTaskMessageDelta(type="delta", index=2, delta=TextDelta(type="text", text_delta="and 72F.")), + StreamTaskMessageDone(type="done", index=2), + ] + + +async def _canonical_stream(events: list[StreamTaskMessage]): + for e in events: + yield e + + +# --------------------------------------------------------------------------- +# Fake streaming backend that records the created_at it receives +# --------------------------------------------------------------------------- + + +class _FakeCtx: + def __init__(self, ctype: str, initial_content: Any) -> None: + self.ctype = ctype + self.task_message = TaskMessage(id="msg-1", task_id="task1", content=initial_content) + + async def __aenter__(self) -> "_FakeCtx": + return self + + async def __aexit__(self, *args: Any) -> bool: + await self.close() + return False + + async def close(self) -> None: + pass + + async def stream_update(self, update: Any) -> Any: + return update + + +class _FakeStreaming: + def __init__(self) -> None: + self.messages_opened: list[Any] = [] + self.created_ats: list[Any] = [] + + def streaming_task_message_context( + self, + task_id: str, + initial_content: Any, + streaming_mode: str = "coalesced", + created_at: Any = None, + ) -> _FakeCtx: + ctype = getattr(initial_content, "type", None) or "" + self.messages_opened.append(initial_content) + self.created_ats.append(created_at) + return _FakeCtx(ctype, initial_content) + + +async def _run_activity(events: list[StreamTaskMessage], created_at: datetime | None) -> tuple[Any, _FakeStreaming]: + """Mirror the temporal activity body: build an OpenAITurn and auto_send it.""" + fake_streaming = _FakeStreaming() + turn = OpenAITurn(stream=_canonical_stream(events), model="gpt-4o") + emitter = UnifiedEmitter( + task_id="task1", + trace_id=None, + parent_span_id=None, + tracer=False, + streaming=fake_streaming, + ) + result = await emitter.auto_send_turn(turn, created_at=created_at) + return result, fake_streaming + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestTemporalActivityMessageOrder: + async def test_canonical_sequence_delivered(self) -> None: + _, fake_streaming = await _run_activity(_tool_then_text_events(), created_at=None) + types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] + assert "tool_request" in types + assert "tool_response" in types + assert types.index("tool_request") < types.index("tool_response") + assert types[-1] == "text" + + async def test_final_text_returned(self) -> None: + result, _ = await _run_activity(_tool_then_text_events(), created_at=None) + assert result.final_text == "Sunny and 72F." + + +class TestTemporalCreatedAtThreading: + """created_at is forwarded to every streaming context (deterministic replay).""" + + async def test_created_at_threaded_to_all_contexts(self) -> None: + fixed = datetime(2026, 6, 22, 12, 0, 0, tzinfo=timezone.utc) + _, fake_streaming = await _run_activity(_tool_then_text_events(), created_at=fixed) + assert len(fake_streaming.created_ats) == 3 + assert all(ts == fixed for ts in fake_streaming.created_ats), ( + f"Expected every context stamped with {fixed}, got {fake_streaming.created_ats}" + ) + + async def test_default_created_at_is_none(self) -> None: + """When the activity does not stamp a timestamp, contexts see None.""" + _, fake_streaming = await _run_activity(_tool_then_text_events(), created_at=None) + assert all(ts is None for ts in fake_streaming.created_ats) + + async def test_created_at_is_deterministic_across_runs(self) -> None: + """Two runs with the same created_at stamp identical timestamps — the + determinism the Temporal channel relies on for replay.""" + fixed = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + _, first = await _run_activity(_tool_then_text_events(), created_at=fixed) + _, second = await _run_activity(_tool_then_text_events(), created_at=fixed) + assert first.created_ats == second.created_ats + assert all(ts == fixed for ts in first.created_ats) diff --git a/tests/lib/core/harness/test_harness_pydantic_ai_async.py b/tests/lib/core/harness/test_harness_pydantic_ai_async.py new file mode 100644 index 000000000..4b6b86415 --- /dev/null +++ b/tests/lib/core/harness/test_harness_pydantic_ai_async.py @@ -0,0 +1,330 @@ +"""Integration test: async (Redis-streaming) channel with a pydantic-ai agent. + +Exercises the unified harness surface (UnifiedEmitter.auto_send_turn + PydanticAITurn) +with a minimal pydantic-ai agent backed by TestModel so the test runs fully +offline (no API keys, no Redis, no Agentex server). + +Agent description +----------------- +Same single-tool agent as the sync test: ``get_weather(city: str) -> str`` +returning "sunny and 72F". TestModel is configured to call the tool once then +produce a fixed text reply. + +The async path uses the bare PydanticAITurn (no coalescing): the foundation +auto_send delivers streamed tool-request Start+ToolRequestDelta+Done messages +natively, so no coalescing wrapper is needed. + +What is tested +-------------- +- The async handler pushes the correct sequence of messages to the fake streaming + backend: tool_request + tool_response + text (in that order). +- final_text equals the TestModel custom output. +- With a SpanTracer, tool spans are derived and forwarded to the fake tracing + backend (streamed tool-request delivery now triggers span derivation on the + async path). + +What is NOT covered without live infrastructure +----------------------------------------------- +- Actual Redis streaming (requires a running Redis instance). +- The ACP on_task_event_send / on_task_create / on_task_cancel lifecycle. +- Multi-turn history persistence via adk.state. +- Real LLM calls or production model behaviour. +- The full FastACP async request lifecycle. + +See also: test_harness_pydantic_ai_sync.py (span derivation with sync path) and +test_harness_pydantic_ai_temporal.py (temporal activity path). +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from pydantic_ai import Agent +from pydantic_ai.models.test import TestModel + +from agentex.types.task_message import TaskMessage +from agentex.lib.core.harness.types import TurnResult +from agentex.lib.core.harness.tracer import SpanTracer +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn + +from ._fakes import FakeTracing + +# --------------------------------------------------------------------------- +# Minimal agent under test +# --------------------------------------------------------------------------- + + +def _make_agent() -> Agent: + """Build a pydantic-ai agent with one weather tool and a TestModel.""" + model = TestModel( + call_tools=["get_weather"], + custom_output_text="The weather in Paris is sunny and 72F.", + ) + agent: Agent = Agent(model) + + @agent.tool_plain + def get_weather(city: str) -> str: + """Get the current weather for a city.""" + return f"The weather in {city} is sunny and 72F" + + return agent + + +# --------------------------------------------------------------------------- +# Fake streaming backend (replaces adk.streaming; no Redis required) +# --------------------------------------------------------------------------- + + +class _FakeCtx: + """Minimal StreamingTaskMessageContext fake.""" + + def __init__(self, sink: list[Any], ctype: str, initial_content: Any) -> None: + self.sink = sink + self.ctype = ctype + self.task_message = TaskMessage(id="msg-1", task_id="task1", content=initial_content) + + async def __aenter__(self) -> "_FakeCtx": + self.sink.append(("open", self.ctype, self.task_message.content)) + return self + + async def __aexit__(self, *args: Any) -> bool: + await self.close() + return False + + async def close(self) -> None: + self.sink.append(("close", self.ctype)) + + async def stream_update(self, update: Any) -> Any: + self.sink.append(("delta", self.ctype, update)) + return update + + +class _FakeStreaming: + """Fake streaming backend; records every context lifecycle event.""" + + def __init__(self) -> None: + self.sink: list[Any] = [] + self.messages_opened: list[Any] = [] + + def streaming_task_message_context( + self, + task_id: str, + initial_content: Any, + streaming_mode: str = "coalesced", + created_at: Any = None, + ) -> _FakeCtx: + ctype = getattr(initial_content, "type", None) or "" + self.messages_opened.append(initial_content) + return _FakeCtx(self.sink, ctype, initial_content) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _run_auto_send_turn( + agent: Agent, + user_msg: str = "What is the weather in Paris?", + trace_id: str | None = None, + parent_span_id: str | None = None, + fake_tracing: FakeTracing | None = None, +) -> tuple[TurnResult, _FakeStreaming]: + """Drive the async (auto_send) path and return the TurnResult + fake streaming state.""" + fake_streaming = _FakeStreaming() + + tracer: SpanTracer | bool | None = None + if trace_id and fake_tracing is not None: + tracer = SpanTracer( + trace_id=trace_id, + parent_span_id=parent_span_id, + task_id="task1", + tracing=fake_tracing, + ) + + async with agent.run_stream_events(user_msg) as stream: + turn = PydanticAITurn( + stream, + model="test", + ) + emitter = UnifiedEmitter( + task_id="task1", + trace_id=trace_id, + parent_span_id=parent_span_id, + tracer=tracer if tracer is not None else False, + streaming=fake_streaming, + ) + result = await emitter.auto_send_turn(turn) + + return result, fake_streaming + + +# --------------------------------------------------------------------------- +# Tests: message order and content +# --------------------------------------------------------------------------- + + +class TestAsyncAutoSendMessageOrder: + """auto_send pushes messages to the streaming backend in canonical order.""" + + async def test_tool_request_pushed_first(self) -> None: + """tool_request is the first message type pushed to the streaming backend.""" + agent = _make_agent() + _, fake_streaming = await _run_auto_send_turn(agent) + + message_types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] + assert "tool_request" in message_types + assert message_types.index("tool_request") < message_types.index("tool_response"), ( + "tool_request must be pushed before tool_response" + ) + + async def test_tool_response_pushed_after_tool_request(self) -> None: + """tool_response appears after tool_request in the pushed messages.""" + agent = _make_agent() + _, fake_streaming = await _run_auto_send_turn(agent) + + message_types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] + assert "tool_response" in message_types + + async def test_text_pushed_last(self) -> None: + """Text content is the last type pushed (after tool round-trip).""" + agent = _make_agent() + _, fake_streaming = await _run_auto_send_turn(agent) + + message_types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] + assert message_types[-1] == "text", f"Expected last message type=text, got {message_types}" + + async def test_exactly_three_messages(self) -> None: + """Exactly three message contexts are opened: tool_request, tool_response, text.""" + agent = _make_agent() + _, fake_streaming = await _run_auto_send_turn(agent) + + assert len(fake_streaming.messages_opened) == 3, ( + f"Expected 3 messages (tool_request + tool_response + text), " + f"got {len(fake_streaming.messages_opened)}: " + f"{[getattr(m, 'type', None) for m in fake_streaming.messages_opened]}" + ) + + +class TestAsyncAutoSendContentVerification: + """The content pushed to the streaming backend is correct.""" + + async def test_tool_request_content(self) -> None: + """The pushed tool_request is a ToolRequestContent for get_weather.""" + agent = _make_agent() + _, fake_streaming = await _run_auto_send_turn(agent) + + tool_reqs = [m for m in fake_streaming.messages_opened if isinstance(m, ToolRequestContent)] + assert len(tool_reqs) == 1, "Expected exactly one ToolRequestContent" + assert tool_reqs[0].name == "get_weather" + + async def test_tool_response_content(self) -> None: + """The pushed tool_response is a ToolResponseContent containing the weather result.""" + agent = _make_agent() + _, fake_streaming = await _run_auto_send_turn(agent) + + tool_resps = [m for m in fake_streaming.messages_opened if isinstance(m, ToolResponseContent)] + assert len(tool_resps) == 1, "Expected exactly one ToolResponseContent" + assert isinstance(tool_resps[0].content, str) + assert "72F" in tool_resps[0].content + assert tool_resps[0].name == "get_weather" + + async def test_tool_call_ids_match(self) -> None: + """tool_request and tool_response have the same tool_call_id.""" + agent = _make_agent() + _, fake_streaming = await _run_auto_send_turn(agent) + + tool_req = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolRequestContent)) + tool_resp = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolResponseContent)) + assert tool_req.tool_call_id == tool_resp.tool_call_id, ( + "tool_request and tool_response must share the same tool_call_id" + ) + + +class TestAsyncAutoSendFinalText: + """auto_send_turn returns the accumulated text from the last text part.""" + + async def test_final_text_matches_model_output(self) -> None: + """TurnResult.final_text equals the TestModel custom_output_text.""" + agent = _make_agent() + result, _ = await _run_auto_send_turn(agent) + assert result.final_text == "The weather in Paris is sunny and 72F." + + async def test_turn_result_has_usage(self) -> None: + """TurnResult carries a TurnUsage object (may have None tokens from TestModel).""" + agent = _make_agent() + result, _ = await _run_auto_send_turn(agent) + assert result.usage is not None + + async def test_context_lifecycle_open_then_close(self) -> None: + """Every message context is opened then closed (no leak).""" + agent = _make_agent() + _, fake_streaming = await _run_auto_send_turn(agent) + + opens = [e for e in fake_streaming.sink if e[0] == "open"] + closes = [e for e in fake_streaming.sink if e[0] == "close"] + assert len(opens) == len(closes) == 3, "Each of the 3 messages must have exactly one open and one close" + + +class TestAsyncAutoSendSpanDerivation: + """Span derivation on the async path now works for streamed tool requests. + + The foundation auto_send delivers Start+ToolRequestDelta+Done natively. + The SpanDeriver opens a tool span on Done(tool_request), so the async path + derives spans just like the sync path. + """ + + async def test_tool_span_derived_on_async_path(self) -> None: + """With the bare PydanticAITurn (no coalescing), a tool span is derived + on the async/auto_send path when auto_send delivers the streamed + Start+ToolRequestDelta+Done sequence.""" + agent = _make_agent() + fake_tracing = FakeTracing() + tracer = SpanTracer( + trace_id="trace1", + parent_span_id="parent", + task_id="task1", + tracing=fake_tracing, + ) + fake_streaming = _FakeStreaming() + + async with agent.run_stream_events("What is the weather in Paris?") as stream: + turn = PydanticAITurn(stream, model="test") + emitter = UnifiedEmitter( + task_id="task1", + trace_id="trace1", + parent_span_id="parent", + tracer=tracer, + streaming=fake_streaming, + ) + await emitter.auto_send_turn(turn) + + assert len(fake_tracing.started) == 1, ( + "Expected one tool span to be started for the get_weather call." + ) + assert fake_tracing.started[0][0] == "get_weather" + assert len(fake_tracing.ended) == 1 + + +@pytest.mark.parametrize( + "user_msg", + [ + "What is the weather in Paris?", + "Tell me the weather in London.", + ], +) +async def test_async_handler_pushes_messages_for_various_inputs(user_msg: str) -> None: + """auto_send pushes at least tool_request + tool_response + text for any input.""" + agent = _make_agent() + result, fake_streaming = await _run_auto_send_turn(agent, user_msg=user_msg) + + message_types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] + assert "tool_request" in message_types + assert "tool_response" in message_types + assert "text" in message_types + assert isinstance(result.final_text, str) + assert len(result.final_text) > 0 diff --git a/tests/lib/core/harness/test_harness_pydantic_ai_sync.py b/tests/lib/core/harness/test_harness_pydantic_ai_sync.py new file mode 100644 index 000000000..04beea81d --- /dev/null +++ b/tests/lib/core/harness/test_harness_pydantic_ai_sync.py @@ -0,0 +1,357 @@ +"""Integration test: sync (HTTP-yield) channel with a pydantic-ai agent. + +Exercises the unified harness surface (UnifiedEmitter.yield_turn + PydanticAITurn) +with a minimal pydantic-ai agent backed by TestModel so the test runs fully +offline (no API keys, no live infrastructure). + +Agent description +----------------- +A single-tool agent with ``get_weather(city: str) -> str`` that always returns +"sunny and 72F". TestModel is configured to call that tool once then produce +a fixed text reply, giving a deterministic event sequence. + +What is tested +-------------- +- The sync handler correctly yields StreamTaskMessage* events in order: + tool_request (Start+Done) then tool_response (Full) then text (Start+Delta+Done). +- Final accumulated text equals the TestModel custom output. +- With a trace_id + fake tracing, a tool span is opened (OpenSpan) and + closed (CloseSpan) — proving the SpanDeriver is wired on the yield path. + +What is NOT covered without live infrastructure +----------------------------------------------- +- Actual HTTP streaming over the ACP sync endpoint (requires a running + Agentex server + deployed agent). +- Real LLM calls or production model behaviour. +- The full FastACP request/response lifecycle. + +See also: tests/lib/core/harness/test_harness_pydantic_ai_async.py and +test_harness_pydantic_ai_temporal.py for the other two channels. +""" + +from __future__ import annotations + +from typing import Any, override + +import pytest +from pydantic_ai import Agent +from pydantic_ai.models.test import TestModel + +from agentex.types.text_delta import TextDelta +from agentex.lib.core.harness.types import OpenSpan, CloseSpan +from agentex.lib.core.harness.tracer import SpanTracer +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageStart, +) +from agentex.types.tool_response_content import ToolResponseContent +from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn + +from ._fakes import FakeTracing + +# --------------------------------------------------------------------------- +# Minimal agent under test +# --------------------------------------------------------------------------- + + +def _make_agent() -> Agent: + """Build a pydantic-ai agent with one weather tool and a TestModel. + + TestModel is instantiated with call_tools=['get_weather'] so it always + invokes the tool once, then emits custom_output_text as the reply. + """ + model = TestModel( + call_tools=["get_weather"], + custom_output_text="The weather in Paris is sunny and 72F.", + ) + agent: Agent = Agent(model) + + @agent.tool_plain + def get_weather(city: str) -> str: + """Get the current weather for a city.""" + return f"The weather in {city} is sunny and 72F" + + return agent + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _run_yield_turn( + agent: Agent, + user_msg: str = "What is the weather in Paris?", + trace_id: str | None = None, + parent_span_id: str | None = None, + fake_tracing: FakeTracing | None = None, +) -> list[Any]: + """Drive the sync (yield) path and collect all yielded events.""" + tracer: SpanTracer | bool | None = None + if trace_id and fake_tracing is not None: + tracer = SpanTracer( + trace_id=trace_id, + parent_span_id=parent_span_id, + task_id="task1", + tracing=fake_tracing, + ) + + events: list[Any] = [] + async with agent.run_stream_events(user_msg) as stream: + turn = PydanticAITurn(stream, model="test") + emitter = UnifiedEmitter( + task_id="task1", + trace_id=trace_id, + parent_span_id=parent_span_id, + tracer=tracer if tracer is not None else False, + ) + events = [ev async for ev in emitter.yield_turn(turn)] + return events + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestSyncYieldEventOrder: + """The yield channel forwards events in canonical order.""" + + async def test_tool_request_precedes_tool_response(self) -> None: + """tool_request events appear before the tool_response Full event.""" + agent = _make_agent() + events = await _run_yield_turn(agent) + + content_types = [ + getattr(getattr(ev, "content", None), "type", None) + for ev in events + if isinstance(ev, (StreamTaskMessageStart, StreamTaskMessageFull)) + ] + assert "tool_request" in content_types + assert "tool_response" in content_types + tool_req_idx = content_types.index("tool_request") + tool_resp_idx = content_types.index("tool_response") + assert tool_req_idx < tool_resp_idx, "tool_request must appear before tool_response in the event stream" + + async def test_text_appears_after_tool_response(self) -> None: + """Text content (Start/Done) comes after the tool_response Full event.""" + agent = _make_agent() + events = await _run_yield_turn(agent) + + full_types = [ + getattr(getattr(ev, "content", None), "type", None) + for ev in events + if isinstance(ev, StreamTaskMessageFull) + ] + start_types = [ + getattr(getattr(ev, "content", None), "type", None) + for ev in events + if isinstance(ev, StreamTaskMessageStart) + ] + + assert "tool_response" in full_types + assert "text" in start_types + + tool_resp_pos = next( + i + for i, ev in enumerate(events) + if isinstance(ev, StreamTaskMessageFull) + and getattr(getattr(ev, "content", None), "type", None) == "tool_response" + ) + text_start_pos = next( + i + for i, ev in enumerate(events) + if isinstance(ev, StreamTaskMessageStart) and getattr(getattr(ev, "content", None), "type", None) == "text" + ) + assert tool_resp_pos < text_start_pos + + async def test_tool_response_carries_weather_result(self) -> None: + """The ToolResponseContent contains the get_weather return value.""" + agent = _make_agent() + events = await _run_yield_turn(agent) + + full_events = [ + ev + for ev in events + if isinstance(ev, StreamTaskMessageFull) and isinstance(getattr(ev, "content", None), ToolResponseContent) + ] + assert len(full_events) >= 1, "Expected at least one tool_response Full event" + tool_response = full_events[0].content + assert isinstance(tool_response, ToolResponseContent) + assert isinstance(tool_response.content, str) + assert "72F" in tool_response.content + assert tool_response.name == "get_weather" + + async def test_accumulated_text_matches_model_output(self) -> None: + """Accumulated text deltas equal the TestModel custom_output_text.""" + from agentex.types.task_message_update import StreamTaskMessageDelta + + agent = _make_agent() + events = await _run_yield_turn(agent) + + accumulated = "".join( + ev.delta.text_delta + for ev in events + if isinstance(ev, StreamTaskMessageDelta) and isinstance(ev.delta, TextDelta) and ev.delta.text_delta + ) + assert accumulated == "The weather in Paris is sunny and 72F." + + async def test_every_start_has_matching_done(self) -> None: + """Every StreamTaskMessageStart has a corresponding StreamTaskMessageDone.""" + agent = _make_agent() + events = await _run_yield_turn(agent) + + starts = {ev.index for ev in events if isinstance(ev, StreamTaskMessageStart)} + dones = {ev.index for ev in events if isinstance(ev, StreamTaskMessageDone)} + assert starts == dones, f"Unmatched Start/Done indices: starts={starts} dones={dones}" + + +class TestSyncYieldSpanDerivation: + """SpanDeriver is wired on the yield path; tool spans are opened/closed.""" + + async def test_tool_span_opened_and_closed(self) -> None: + """One tool span is opened and closed per tool call.""" + agent = _make_agent() + fake_tracing = FakeTracing() + tracer = SpanTracer( + trace_id="trace1", + parent_span_id="parent-span", + task_id="task1", + tracing=fake_tracing, + ) + + async with agent.run_stream_events("What is the weather in Paris?") as stream: + turn = PydanticAITurn(stream, model="test") + emitter = UnifiedEmitter( + task_id="task1", + trace_id="trace1", + parent_span_id="parent-span", + tracer=tracer, + ) + await emitter.yield_turn(turn).__anext__.__self__ if False else None + [_ async for _ in emitter.yield_turn(turn)] + + assert len(fake_tracing.started) == 1, "Expected exactly one tool span opened" + assert len(fake_tracing.ended) == 1, "Expected exactly one tool span closed" + span_name, parent_id, _ = fake_tracing.started[0] + assert span_name == "get_weather" + assert parent_id == "parent-span" + + async def test_tool_span_output_is_tool_result(self) -> None: + """The closed tool span's output equals the tool's return value.""" + agent = _make_agent() + fake_tracing = FakeTracing() + tracer = SpanTracer( + trace_id="trace1", + parent_span_id="parent-span", + task_id="task1", + tracing=fake_tracing, + ) + + async with agent.run_stream_events("What is the weather in Paris?") as stream: + turn = PydanticAITurn(stream, model="test") + emitter = UnifiedEmitter( + task_id="task1", + trace_id="trace1", + parent_span_id="parent-span", + tracer=tracer, + ) + [_ async for _ in emitter.yield_turn(turn)] + + name, output = fake_tracing.ended[0] + assert name == "get_weather" + assert output is not None + assert "72F" in str(output) + + async def test_no_trace_id_means_no_spans(self) -> None: + """With trace_id=None, no spans are derived (emitter disables tracing).""" + agent = _make_agent() + fake_tracing = FakeTracing() + + async with agent.run_stream_events("What is the weather in Paris?") as stream: + turn = PydanticAITurn(stream, model="test") + emitter = UnifiedEmitter( + task_id="task1", + trace_id=None, + parent_span_id=None, + tracing=fake_tracing, + ) + [_ async for _ in emitter.yield_turn(turn)] + + assert fake_tracing.started == [] + assert fake_tracing.ended == [] + + async def test_tracer_false_suppresses_spans(self) -> None: + """tracer=False disables span derivation regardless of trace_id.""" + agent = _make_agent() + fake_tracing = FakeTracing() + + async with agent.run_stream_events("What is the weather in Paris?") as stream: + turn = PydanticAITurn(stream, model="test") + emitter = UnifiedEmitter( + task_id="task1", + trace_id="trace1", + parent_span_id="parent-span", + tracer=False, + tracing=fake_tracing, + ) + [_ async for _ in emitter.yield_turn(turn)] + + assert fake_tracing.started == [] + assert fake_tracing.ended == [] + + async def test_span_signal_types(self) -> None: + """The signals received by the tracer are OpenSpan then CloseSpan.""" + from agentex.lib.core.harness.tracer import SpanTracer as RealTracer + + received_signals: list[Any] = [] + + class _RecordingTracer(RealTracer): + @override + async def handle(self, signal: Any) -> None: + received_signals.append(signal) + await super().handle(signal) + + fake_tracing = FakeTracing() + tracer = _RecordingTracer( + trace_id="trace1", + parent_span_id="parent", + task_id="task1", + tracing=fake_tracing, + ) + + agent = _make_agent() + async with agent.run_stream_events("What is the weather in Paris?") as stream: + turn = PydanticAITurn(stream, model="test") + emitter = UnifiedEmitter( + task_id="task1", + trace_id="trace1", + parent_span_id="parent", + tracer=tracer, + ) + [_ async for _ in emitter.yield_turn(turn)] + + assert len(received_signals) == 2 + assert isinstance(received_signals[0], OpenSpan) + assert isinstance(received_signals[1], CloseSpan) + assert received_signals[0].name == "get_weather" + + +@pytest.mark.parametrize( + "user_msg", + [ + "What is the weather in Paris?", + "Tell me the weather in London.", + ], +) +async def test_sync_handler_produces_events_for_various_inputs(user_msg: str) -> None: + """Yield path produces at least a tool_response Full for any user message.""" + agent = _make_agent() + events = await _run_yield_turn(agent, user_msg=user_msg) + + full_event_types = [ + getattr(getattr(ev, "content", None), "type", None) for ev in events if isinstance(ev, StreamTaskMessageFull) + ] + assert "tool_response" in full_event_types diff --git a/tests/lib/core/harness/test_harness_pydantic_ai_temporal.py b/tests/lib/core/harness/test_harness_pydantic_ai_temporal.py new file mode 100644 index 000000000..0ead8e832 --- /dev/null +++ b/tests/lib/core/harness/test_harness_pydantic_ai_temporal.py @@ -0,0 +1,370 @@ +"""Integration test: Temporal-backed pydantic-ai agent, offline. + +Exercises the core of the Temporal pydantic-ai harness path — the +event_stream_handler activity — with a TemporalAgent backed by TestModel so the +test runs fully offline (no Temporal server, no Redis, no API keys). + +Architecture overview +--------------------- +In a real Temporal deployment the pydantic-ai Temporal harness runs like this: + + HTTP POST /task/event/send + -> @workflow.signal on At110PydanticAiWorkflow + -> temporal_agent.run(user_message, deps=TaskDeps(...)) + internally schedules: + 1. request_activity (LLM HTTP call — recorded by Temporal) + 2. call_tool_activity (for each tool call — also recorded) + 3. event_stream_handler_activity (streams events to Redis) + +The third activity is what we test here: it receives a +``RunContext[TaskDeps]`` and an ``AsyncIterable[AgentStreamEvent]`` from +pydantic-ai, calls ``stream_pydantic_ai_events`` (which internally constructs +a ``UnifiedEmitter`` + ``PydanticAITurn`` and calls ``auto_send_turn``), and +pushes the resulting messages to Redis. + +What we test +----------- +Since ``TemporalAgent.run_stream_events`` works offline with TestModel (it does +not schedule Temporal activities — it runs in-process), we can: + +1. Build a TemporalAgent with TestModel. +2. Call ``run_stream_events`` on it directly, just as the event_stream_handler + would see the event iterable. +3. Feed that stream into ``stream_pydantic_ai_events`` backed by a fake streaming + backend, and assert the canonical message sequence. + +This covers the full inner harness chain that the Temporal workflow exercises, +minus the Temporal scheduling/durability layer itself. + +What is NOT covered without live infrastructure +----------------------------------------------- +- Temporal scheduling (the workflow.signal -> activity dispatch chain). +- Temporal durability guarantees and replay behaviour. +- Redis streaming (requires a running Redis instance). +- Multi-turn history (pydantic-ai message_history round-tripping via Temporal + workflow state). +- Real LLM calls or production model behaviour. +- The full temporal_agent.run(...) path, which schedules activities and cannot + run without a connected Temporal client. + +To test with live infrastructure: spin up Temporal + Redis + the ACP server + +the Temporal worker, then use the AsyncAgentex client to create a task, send a +message, and poll for messages — exactly as the existing examples/tutorials/ +10_async/10_temporal/110_pydantic_ai/tests/test_agent.py does. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from pydantic import BaseModel +from pydantic_ai import Agent +from pydantic_ai.models.test import TestModel +from pydantic_ai.durable_exec.temporal import TemporalAgent + +from agentex.types.task_message import TaskMessage +from agentex.lib.core.harness.emitter import UnifiedEmitter +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn + +# --------------------------------------------------------------------------- +# Agent under test (mirrors examples/tutorials/10_async/10_temporal/110_pydantic_ai) +# --------------------------------------------------------------------------- + + +class TaskDeps(BaseModel): + """Per-run dependencies injected via RunContext.deps.""" + + task_id: str + parent_span_id: str | None = None + + +def _make_temporal_agent() -> TemporalAgent[TaskDeps, str]: + """Build a TemporalAgent with TestModel and one weather tool. + + The underlying pydantic-ai Agent is constructed with TaskDeps as the + deps_type, mirroring the real temporal tutorial agent. TestModel makes + the run deterministic and offline. + """ + model = TestModel( + call_tools=["get_weather"], + custom_output_text="The weather in Paris is sunny and 72F.", + ) + base: Agent[TaskDeps, str] = Agent(model, deps_type=TaskDeps) + + @base.tool_plain + def get_weather(city: str) -> str: + """Get the current weather for a city.""" + return f"The weather in {city} is sunny and 72F" + + return TemporalAgent(base, name="test_temporal_agent") + + +# --------------------------------------------------------------------------- +# Fake streaming backend +# --------------------------------------------------------------------------- + + +class _FakeCtx: + def __init__(self, sink: list[Any], ctype: str, initial_content: Any) -> None: + self.sink = sink + self.ctype = ctype + self.task_message = TaskMessage(id="msg-1", task_id="task1", content=initial_content) + + async def __aenter__(self) -> "_FakeCtx": + self.sink.append(("open", self.ctype, self.task_message.content)) + return self + + async def __aexit__(self, *args: Any) -> bool: + await self.close() + return False + + async def close(self) -> None: + self.sink.append(("close", self.ctype)) + + async def stream_update(self, update: Any) -> Any: + self.sink.append(("delta", self.ctype, update)) + return update + + +class _FakeStreaming: + def __init__(self) -> None: + self.sink: list[Any] = [] + self.messages_opened: list[Any] = [] + + def streaming_task_message_context( + self, + task_id: str, + initial_content: Any, + streaming_mode: str = "coalesced", + created_at: Any = None, + ) -> _FakeCtx: + ctype = getattr(initial_content, "type", None) or "" + self.messages_opened.append(initial_content) + return _FakeCtx(self.sink, ctype, initial_content) + + +# --------------------------------------------------------------------------- +# Helpers: the event_stream_handler pattern tested offline +# --------------------------------------------------------------------------- + + +async def _run_event_stream_handler( + temporal_agent: TemporalAgent[TaskDeps, str], + user_msg: str = "What is the weather in Paris?", + task_id: str = "task1", +) -> _FakeStreaming: + """Simulate the event_stream_handler activity offline. + + In production the event_stream_handler receives the event stream from + pydantic-ai's model activity and calls stream_pydantic_ai_events. + Here we obtain the stream directly from run_stream_events (which works + offline with TestModel) and forward it to stream_pydantic_ai_events backed + by a fake streaming backend. + + This is equivalent to: + async def event_handler(ctx: RunContext[TaskDeps], events: AsyncIterable[AgentStreamEvent]) -> None: + await stream_pydantic_ai_events(events, ctx.deps.task_id) + but without requiring a running Temporal server. + """ + fake_streaming = _FakeStreaming() + + async with temporal_agent.run_stream_events(user_msg) as stream: + await _fake_stream_pydantic_ai_events(stream, task_id, fake_streaming) + + return fake_streaming + + +async def _fake_stream_pydantic_ai_events( + stream: Any, + task_id: str, + fake_streaming: _FakeStreaming, +) -> str: + """Like stream_pydantic_ai_events but uses an injected fake streaming backend. + + Mirrors the exact chain that stream_pydantic_ai_events uses internally: + PydanticAITurn(stream) + + UnifiedEmitter.auto_send_turn(turn) + but with the fake backend injected so no Redis is needed. + """ + turn = PydanticAITurn(stream, model=None) + emitter = UnifiedEmitter( + task_id=task_id, + trace_id=None, + parent_span_id=None, + tracer=False, + streaming=fake_streaming, + ) + result = await emitter.auto_send_turn(turn) + return result.final_text + + +# --------------------------------------------------------------------------- +# Tests: TemporalAgent + event_stream_handler pattern +# --------------------------------------------------------------------------- + + +class TestTemporalEventStreamHandlerMessageOrder: + """The event_stream_handler pushes messages in canonical order.""" + + async def test_tool_request_before_tool_response(self) -> None: + """tool_request is pushed before tool_response.""" + temporal_agent = _make_temporal_agent() + fake_streaming = await _run_event_stream_handler(temporal_agent) + + types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] + assert "tool_request" in types + assert "tool_response" in types + assert types.index("tool_request") < types.index("tool_response") + + async def test_text_is_last(self) -> None: + """Text content is pushed last (after the tool round-trip).""" + temporal_agent = _make_temporal_agent() + fake_streaming = await _run_event_stream_handler(temporal_agent) + + types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] + assert types[-1] == "text" + + async def test_exactly_three_messages(self) -> None: + """Exactly tool_request + tool_response + text are pushed.""" + temporal_agent = _make_temporal_agent() + fake_streaming = await _run_event_stream_handler(temporal_agent) + + assert len(fake_streaming.messages_opened) == 3, ( + f"Expected 3 messages, got {len(fake_streaming.messages_opened)}: " + f"{[getattr(m, 'type', None) for m in fake_streaming.messages_opened]}" + ) + + +class TestTemporalEventStreamHandlerContent: + """Content verification for the messages pushed by the event_stream_handler.""" + + async def test_tool_request_is_get_weather(self) -> None: + """The pushed tool_request is for the get_weather function.""" + temporal_agent = _make_temporal_agent() + fake_streaming = await _run_event_stream_handler(temporal_agent) + + tool_reqs = [m for m in fake_streaming.messages_opened if isinstance(m, ToolRequestContent)] + assert len(tool_reqs) == 1 + assert tool_reqs[0].name == "get_weather" + + async def test_tool_response_contains_weather_result(self) -> None: + """The pushed tool_response contains the get_weather return value.""" + temporal_agent = _make_temporal_agent() + fake_streaming = await _run_event_stream_handler(temporal_agent) + + tool_resps = [m for m in fake_streaming.messages_opened if isinstance(m, ToolResponseContent)] + assert len(tool_resps) == 1 + assert isinstance(tool_resps[0].content, str) + assert "72F" in tool_resps[0].content + assert tool_resps[0].name == "get_weather" + + async def test_tool_call_ids_match(self) -> None: + """tool_request and tool_response share the same tool_call_id.""" + temporal_agent = _make_temporal_agent() + fake_streaming = await _run_event_stream_handler(temporal_agent) + + tool_req = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolRequestContent)) + tool_resp = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolResponseContent)) + assert tool_req.tool_call_id == tool_resp.tool_call_id + + +class TestTemporalFinalText: + """stream_pydantic_ai_events returns the correct final text.""" + + async def test_final_text_matches_model_output(self) -> None: + """The returned final text equals the TestModel custom_output_text.""" + temporal_agent = _make_temporal_agent() + fake_streaming = _FakeStreaming() + + async with temporal_agent.run_stream_events("What is the weather in Paris?") as stream: + final = await _fake_stream_pydantic_ai_events(stream, "task1", fake_streaming) + + assert final == "The weather in Paris is sunny and 72F." + + async def test_context_lifecycle_complete(self) -> None: + """Every opened streaming context is also closed.""" + temporal_agent = _make_temporal_agent() + fake_streaming = await _run_event_stream_handler(temporal_agent) + + opens = [e for e in fake_streaming.sink if e[0] == "open"] + closes = [e for e in fake_streaming.sink if e[0] == "close"] + assert len(opens) == len(closes), "Every opened context must be closed" + + +class TestTemporalAgentStreamEventsOffline: + """TemporalAgent.run_stream_events produces the expected raw pydantic-ai events. + + This verifies that the TemporalAgent wrapper does not suppress event stream + delivery when used with TestModel, so the event_stream_handler pattern is + meaningful offline. + """ + + async def test_run_stream_events_yields_tool_call_and_text(self) -> None: + """TemporalAgent.run_stream_events with TestModel yields tool + text events.""" + + temporal_agent = _make_temporal_agent() + collected: list[Any] = [] + + async with temporal_agent.run_stream_events("What is the weather in Paris?") as stream: + async for ev in stream: + collected.append(ev) + + event_types = {type(ev).__name__ for ev in collected} + assert "FunctionToolResultEvent" in event_types, "Expected FunctionToolResultEvent proving tool call ran" + assert "PartDeltaEvent" in event_types or "PartEndEvent" in event_types, ( + "Expected text part events in the stream" + ) + + async def test_run_stream_events_contains_tool_result(self) -> None: + """The raw event stream contains a FunctionToolResultEvent with the tool output.""" + from pydantic_ai.messages import FunctionToolResultEvent + + temporal_agent = _make_temporal_agent() + + async with temporal_agent.run_stream_events("What is the weather in Paris?") as stream: + events = [ev async for ev in stream] + + tool_results = [ev for ev in events if isinstance(ev, FunctionToolResultEvent)] + assert len(tool_results) >= 1 + assert isinstance(tool_results[0].part.content, str) + assert "72F" in tool_results[0].part.content + + +class TestTemporalLiveInfraNote: + """Placeholder tests documenting what requires live Temporal infrastructure. + + These tests are skipped by design. They document the gap between what the + offline tests cover and what a full integration test would exercise. + """ + + @pytest.mark.skip( + reason=( + "Requires live Temporal server + Redis + ACP server + worker. " + "See examples/tutorials/10_async/10_temporal/110_pydantic_ai/tests/test_agent.py " + "for the live integration test that exercises this path end-to-end." + ) + ) + async def test_temporal_workflow_full_round_trip(self) -> None: + """Full Temporal workflow: create_task -> send_event -> poll_messages.""" + pass # Covered by the live tutorial test + + +@pytest.mark.parametrize( + "user_msg", + [ + "What is the weather in Paris?", + "Tell me the weather in London.", + ], +) +async def test_temporal_handler_pushes_messages_for_various_inputs(user_msg: str) -> None: + """event_stream_handler pushes tool_request + tool_response + text for any input.""" + temporal_agent = _make_temporal_agent() + fake_streaming = await _run_event_stream_handler(temporal_agent, user_msg=user_msg) + + types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] + assert "tool_request" in types + assert "tool_response" in types + assert "text" in types diff --git a/tests/lib/core/harness/test_span_derivation.py b/tests/lib/core/harness/test_span_derivation.py new file mode 100644 index 000000000..6376dc0c6 --- /dev/null +++ b/tests/lib/core/harness/test_span_derivation.py @@ -0,0 +1,365 @@ +from agentex.types.text_content import TextContent +from agentex.lib.core.harness.types import OpenSpan, CloseSpan +from agentex.types.reasoning_content import ReasoningContent +from agentex.types.tool_request_delta import ToolRequestDelta +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageDelta, + StreamTaskMessageStart, +) +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.types.reasoning_content_delta import ReasoningContentDelta +from agentex.types.reasoning_summary_delta import ReasoningSummaryDelta +from agentex.lib.core.harness.span_derivation import SpanDeriver + + +def _signals(deriver, events): + out = [] + for e in events: + out.extend(deriver.observe(e)) + out.extend(deriver.flush()) + return out + + +def _tool_req(idx, tcid, name, args): + return StreamTaskMessageStart( + type="start", + index=idx, + content=ToolRequestContent(type="tool_request", author="agent", tool_call_id=tcid, name=name, arguments=args), + ) + + +def test_text_only_yields_no_spans(): + d = SpanDeriver() + events = [ + StreamTaskMessageStart(type="start", index=0, content=TextContent(type="text", author="agent", content="")), + StreamTaskMessageDelta(type="delta", index=0, delta=None), + StreamTaskMessageDone(type="done", index=0), + ] + assert _signals(d, events) == [] + + +def test_single_tool_opens_on_done_closes_on_response(): + d = SpanDeriver() + events = [ + _tool_req(0, "call_1", "Bash", {"cmd": "ls"}), + StreamTaskMessageDone(type="done", index=0), + StreamTaskMessageFull( + type="full", + index=1, + content=ToolResponseContent( + type="tool_response", author="agent", tool_call_id="call_1", name="Bash", content="files" + ), + ), + ] + sigs = _signals(d, events) + assert sigs == [ + OpenSpan(key="call_1", kind="tool", name="Bash", input={"cmd": "ls"}), + CloseSpan(key="call_1", output="files", is_complete=True), + ] + # No status reported -> CloseSpan carries is_error=None. + assert sigs[1].is_error is None + + +def test_tool_response_is_error_propagates_to_close_span(): + """ToolResponseContent.is_error flows onto the CloseSpan so a derived tool + span can be marked as a failure (AGX1-371).""" + d = SpanDeriver() + events = [ + _tool_req(0, "call_err", "Bash", {"cmd": "false"}), + StreamTaskMessageDone(type="done", index=0), + StreamTaskMessageFull( + type="full", + index=1, + content=ToolResponseContent( + type="tool_response", + author="agent", + tool_call_id="call_err", + name="Bash", + content="boom", + is_error=True, + ), + ), + ] + sigs = _signals(d, events) + assert sigs == [ + OpenSpan(key="call_err", kind="tool", name="Bash", input={"cmd": "false"}), + CloseSpan(key="call_err", output="boom", is_complete=True, is_error=True), + ] + + +def test_reasoning_opens_on_start_closes_on_done(): + d = SpanDeriver() + events = [ + StreamTaskMessageStart( + type="start", index=0, content=ReasoningContent(type="reasoning", author="agent", summary=[], content=[]) + ), + StreamTaskMessageDone(type="done", index=0), + ] + sigs = _signals(d, events) + assert sigs[0] == OpenSpan(key="reasoning:0", kind="reasoning", name="reasoning", input={}) + # No deltas -> nothing to record, so output stays None (not an empty string). + assert sigs[1] == CloseSpan(key="reasoning:0", output=None, is_complete=True) + + +def test_reasoning_content_deltas_recorded_as_output(): + """The chain-of-thought streamed via ReasoningContentDelta lands on the + reasoning span's output (previously dropped, leaving the span blank).""" + d = SpanDeriver() + events = [ + StreamTaskMessageStart( + type="start", index=0, content=ReasoningContent(type="reasoning", author="agent", summary=[], content=[]) + ), + StreamTaskMessageDelta( + type="delta", + index=0, + delta=ReasoningContentDelta(type="reasoning_content", content_index=0, content_delta="Let me "), + ), + StreamTaskMessageDelta( + type="delta", + index=0, + delta=ReasoningContentDelta(type="reasoning_content", content_index=0, content_delta="think."), + ), + StreamTaskMessageDone(type="done", index=0), + ] + sigs = _signals(d, events) + assert sigs[0] == OpenSpan(key="reasoning:0", kind="reasoning", name="reasoning", input={}) + assert sigs[1] == CloseSpan(key="reasoning:0", output="Let me think.", is_complete=True) + + +def test_reasoning_summary_deltas_recorded_as_output(): + """Reasoning-model summary deltas (o-series) also land on the span output.""" + d = SpanDeriver() + events = [ + StreamTaskMessageStart( + type="start", index=0, content=ReasoningContent(type="reasoning", author="agent", summary=[], content=[]) + ), + StreamTaskMessageDelta( + type="delta", + index=0, + delta=ReasoningSummaryDelta(type="reasoning_summary", summary_index=0, summary_delta="Summary text"), + ), + StreamTaskMessageDone(type="done", index=0), + ] + sigs = _signals(d, events) + assert sigs[1] == CloseSpan(key="reasoning:0", output="Summary text", is_complete=True) + + +def test_reasoning_text_seeded_from_start_content(): + """A non-streaming harness that carries the full thinking on the Start + content still records it as output even with no deltas.""" + d = SpanDeriver() + events = [ + StreamTaskMessageStart( + type="start", + index=0, + content=ReasoningContent(type="reasoning", author="agent", summary=[], content=["full thought"]), + ), + StreamTaskMessageDone(type="done", index=0), + ] + sigs = _signals(d, events) + assert sigs[1] == CloseSpan(key="reasoning:0", output="full thought", is_complete=True) + + +def test_reasoning_unclosed_flushes_with_text(): + """An unclosed reasoning span flushes incomplete but still carries its text.""" + d = SpanDeriver() + events = [ + StreamTaskMessageStart( + type="start", index=0, content=ReasoningContent(type="reasoning", author="agent", summary=[], content=[]) + ), + StreamTaskMessageDelta( + type="delta", + index=0, + delta=ReasoningContentDelta(type="reasoning_content", content_index=0, content_delta="partial"), + ), + ] + sigs = _signals(d, events) + assert sigs[-1] == CloseSpan(key="reasoning:0", output="partial", is_complete=False) + + +def test_parallel_tools_pair_by_tool_call_id(): + d = SpanDeriver() + events = [ + _tool_req(0, "a", "T1", {}), + _tool_req(1, "b", "T2", {}), + StreamTaskMessageDone(type="done", index=0), + StreamTaskMessageDone(type="done", index=1), + StreamTaskMessageFull( + type="full", + index=2, + content=ToolResponseContent( + type="tool_response", author="agent", tool_call_id="b", name="T2", content="rb" + ), + ), + StreamTaskMessageFull( + type="full", + index=3, + content=ToolResponseContent( + type="tool_response", author="agent", tool_call_id="a", name="T1", content="ra" + ), + ), + ] + sigs = _signals(d, events) + opens = [s for s in sigs if isinstance(s, OpenSpan)] + closes = [s for s in sigs if isinstance(s, CloseSpan)] + assert {o.key for o in opens} == {"a", "b"} + assert [c.key for c in closes] == ["b", "a"] + assert all(c.is_complete for c in closes) + + +def test_streamed_args_accumulate_into_open_input(): + d = SpanDeriver() + events = [ + StreamTaskMessageStart( + type="start", + index=0, + content=ToolRequestContent( + type="tool_request", author="agent", tool_call_id="c", name="Bash", arguments={} + ), + ), + StreamTaskMessageDelta( + type="delta", + index=0, + delta=ToolRequestDelta(type="tool_request", tool_call_id="c", name="Bash", arguments_delta='{"cmd":'), + ), + StreamTaskMessageDelta( + type="delta", + index=0, + delta=ToolRequestDelta(type="tool_request", tool_call_id="c", name="Bash", arguments_delta='"ls"}'), + ), + StreamTaskMessageDone(type="done", index=0), + ] + sigs = _signals(d, events) + assert sigs[0] == OpenSpan(key="c", kind="tool", name="Bash", input={"cmd": "ls"}) + + +def test_unclosed_tool_closed_incomplete_on_flush(): + d = SpanDeriver() + events = [ + _tool_req(0, "x", "Bash", {}), + StreamTaskMessageDone(type="done", index=0), + ] + sigs = _signals(d, events) + assert sigs[0] == OpenSpan(key="x", kind="tool", name="Bash", input={}) + assert sigs[1] == CloseSpan(key="x", output=None, is_complete=False) + + +def test_none_index_is_skipped(): + d = SpanDeriver() + events = [ + StreamTaskMessageStart( + type="start", + index=None, + content=ToolRequestContent( + type="tool_request", author="agent", tool_call_id="n", name="Bash", arguments={} + ), + ), + StreamTaskMessageDone(type="done", index=None), + ] + assert _signals(d, events) == [] + + +def test_orphan_tool_response_ignored(): + d = SpanDeriver() + events = [ + StreamTaskMessageFull( + type="full", + index=0, + content=ToolResponseContent( + type="tool_response", author="agent", tool_call_id="z", name="Bash", content="r" + ), + ), + ] + assert _signals(d, events) == [] + + +def test_full_tool_request_opens_span(): + """Full(ToolRequestContent) must open a tool span (for LangGraph-style harnesses).""" + d = SpanDeriver() + events = [ + StreamTaskMessageFull( + type="full", + index=0, + content=ToolRequestContent( + type="tool_request", + author="agent", + tool_call_id="call_x", + name="Bash", + arguments={"cmd": "ls"}, + ), + ), + ] + sigs = _signals(d, events) + assert sigs[0] == OpenSpan(key="call_x", kind="tool", name="Bash", input={"cmd": "ls"}) + assert sigs[1] == CloseSpan(key="call_x", output=None, is_complete=False) + + +def test_full_tool_request_and_response_paired(): + """Full(ToolRequestContent) + Full(ToolResponseContent) produces a complete span pair.""" + d = SpanDeriver() + events = [ + StreamTaskMessageFull( + type="full", + index=0, + content=ToolRequestContent( + type="tool_request", + author="agent", + tool_call_id="call_y", + name="Grep", + arguments={}, + ), + ), + StreamTaskMessageFull( + type="full", + index=1, + content=ToolResponseContent( + type="tool_response", + author="agent", + tool_call_id="call_y", + name="Grep", + content="result", + ), + ), + ] + sigs = _signals(d, events) + assert sigs == [ + OpenSpan(key="call_y", kind="tool", name="Grep", input={}), + CloseSpan(key="call_y", output="result", is_complete=True), + ] + + +def test_full_tool_request_does_not_double_open(): + """A Full(ToolRequestContent) for an already-open tool_call_id is a no-op.""" + d = SpanDeriver() + events = [ + StreamTaskMessageStart( + type="start", + index=0, + content=ToolRequestContent( + type="tool_request", + author="agent", + tool_call_id="call_z", + name="X", + arguments={}, + ), + ), + StreamTaskMessageDone(type="done", index=0), + StreamTaskMessageFull( + type="full", + index=1, + content=ToolRequestContent( + type="tool_request", + author="agent", + tool_call_id="call_z", + name="X", + arguments={}, + ), + ), + ] + sigs = _signals(d, events) + opens = [s for s in sigs if isinstance(s, OpenSpan)] + assert len(opens) == 1 + assert opens[0].key == "call_z" diff --git a/tests/lib/core/harness/test_tracer.py b/tests/lib/core/harness/test_tracer.py new file mode 100644 index 000000000..9bd17b90c --- /dev/null +++ b/tests/lib/core/harness/test_tracer.py @@ -0,0 +1,98 @@ +from typing import override + +import pytest + +from agentex.lib.core.harness.types import OpenSpan, CloseSpan +from agentex.lib.core.harness.tracer import SpanTracer + +from ._fakes import FakeTracing + + +@pytest.mark.asyncio +async def test_open_then_close_starts_and_ends_span(): + fake = FakeTracing() + tracer = SpanTracer(trace_id="t1", parent_span_id="p1", tracing=fake) + await tracer.handle(OpenSpan(key="call_1", kind="tool", name="Bash", input={"cmd": "ls"})) + await tracer.handle(CloseSpan(key="call_1", output="files", is_complete=True)) + assert fake.started == [("Bash", "p1", {"cmd": "ls"})] + # A plain-string output is wrapped in a dict (SGP spans require an object). + assert fake.ended == [("Bash", {"output": "files"})] + + +@pytest.mark.asyncio +async def test_non_dict_payloads_are_wrapped_in_a_dict(): + """SGP spans reject scalar input/output with a 422; the tracer wraps any + non-dict payload so reasoning spans (string output) are not dropped.""" + fake = FakeTracing() + tracer = SpanTracer(trace_id="t1", parent_span_id="p1", tracing=fake) + await tracer.handle(OpenSpan(key="reasoning:0", kind="reasoning", name="reasoning", input={})) + await tracer.handle(CloseSpan(key="reasoning:0", output="chain of thought", is_complete=True)) + # Empty-dict input stays a dict; string output is wrapped. + assert fake.started == [("reasoning", "p1", {})] + assert fake.ended == [("reasoning", {"output": "chain of thought"})] + + +@pytest.mark.asyncio +async def test_dict_and_none_payloads_pass_through_unchanged(): + fake = FakeTracing() + tracer = SpanTracer(trace_id="t1", parent_span_id="p1", tracing=fake) + await tracer.handle(OpenSpan(key="c", kind="tool", name="T", input={"a": 1})) + await tracer.handle(CloseSpan(key="c", output={"result": "x"}, is_complete=True)) + await tracer.handle(OpenSpan(key="d", kind="tool", name="U", input={})) + await tracer.handle(CloseSpan(key="d", output=None, is_complete=False)) + assert fake.ended == [("T", {"result": "x"}), ("U", None)] + + +@pytest.mark.asyncio +async def test_close_records_is_error_on_span_data(): + """A CloseSpan carrying is_error records the status on span.data (AGX1-371).""" + fake = FakeTracing() + tracer = SpanTracer(trace_id="t1", parent_span_id="p1", tracing=fake) + await tracer.handle(OpenSpan(key="call_err", kind="tool", name="Bash", input={})) + await tracer.handle(CloseSpan(key="call_err", output="boom", is_complete=True, is_error=True)) + assert fake.ended_spans[0].data == {"is_error": True} + + +@pytest.mark.asyncio +async def test_close_without_status_leaves_span_data_untouched(): + """is_error=None (no status reported) must not write to span.data.""" + fake = FakeTracing() + tracer = SpanTracer(trace_id="t1", parent_span_id="p1", tracing=fake) + await tracer.handle(OpenSpan(key="call_1", kind="tool", name="Bash", input={})) + await tracer.handle(CloseSpan(key="call_1", output="files", is_complete=True)) + assert fake.ended_spans[0].data is None + + +@pytest.mark.asyncio +async def test_no_trace_id_is_noop(): + fake = FakeTracing() + tracer = SpanTracer(trace_id="", parent_span_id=None, tracing=fake) + await tracer.handle(OpenSpan(key="k", kind="tool", name="X")) + await tracer.handle(CloseSpan(key="k")) + assert fake.started == [] and fake.ended == [] + + +@pytest.mark.asyncio +async def test_tracing_failure_is_swallowed(): + class _Boom(FakeTracing): + @override + async def start_span(self, **kw): + raise RuntimeError("backend down") + + tracer = SpanTracer(trace_id="t1", parent_span_id="p1", tracing=_Boom()) + # Must not raise. + await tracer.handle(OpenSpan(key="k", kind="tool", name="X")) + await tracer.handle(CloseSpan(key="k")) + assert tracer._open == {} + + +@pytest.mark.asyncio +async def test_duplicate_open_replaces_silently(): + fake = FakeTracing() + tracer = SpanTracer(trace_id="t1", parent_span_id="p1", tracing=fake) + await tracer.handle(OpenSpan(key="k", kind="tool", name="A")) + await tracer.handle(OpenSpan(key="k", kind="tool", name="B")) + await tracer.handle(CloseSpan(key="k")) + # Both opens started spans, but only the second ("B") is closed. + assert [name for name, _, _ in fake.started] == ["A", "B"] + assert fake.ended == [("B", None)] diff --git a/tests/lib/core/harness/test_tracer_lineage.py b/tests/lib/core/harness/test_tracer_lineage.py new file mode 100644 index 000000000..75799caee --- /dev/null +++ b/tests/lib/core/harness/test_tracer_lineage.py @@ -0,0 +1,53 @@ +"""SpanTracer stamps registered data-source refs onto tool spans (SGP-6513).""" + +import pytest + +from agentex.lib.core.harness.types import OpenSpan, CloseSpan +from agentex.lib.core.harness.tracer import SpanTracer +from agentex.lib.core.tracing.lineage import ( + LINEAGE_REFS_KEY, + DataSourceRef, + clear_tool_sources, + register_tool_sources, +) + +from ._fakes import FakeTracing + + +@pytest.fixture(autouse=True) +def _clean_registry(): + clear_tool_sources() + yield + clear_tool_sources() + + +@pytest.mark.asyncio +async def test_tool_open_span_carries_registered_refs(): + register_tool_sources( + "query_guidance", + refs=[DataSourceRef("databricks://ey-tax", "guidance.rulings")], + resolver=lambda args: [DataSourceRef("elasticsearch://ey", args["index"])], + ) + fake = FakeTracing() + tracer = SpanTracer(trace_id="t1", parent_span_id="p1", tracing=fake) + + await tracer.handle(OpenSpan(key="c1", kind="tool", name="query_guidance", input={"index": "filings"})) + await tracer.handle(CloseSpan(key="c1", output={"ok": True}, is_complete=True)) + + (span,) = fake.ended_spans + namespaces = {ref["namespace"] for ref in span.data[LINEAGE_REFS_KEY]} + assert namespaces == {"databricks://ey-tax", "elasticsearch://ey"} + + +@pytest.mark.asyncio +async def test_unregistered_tool_and_reasoning_spans_carry_no_refs(): + fake = FakeTracing() + tracer = SpanTracer(trace_id="t1", parent_span_id=None, tracing=fake) + + await tracer.handle(OpenSpan(key="c1", kind="tool", name="unregistered", input={})) + await tracer.handle(CloseSpan(key="c1", output=None, is_complete=True)) + await tracer.handle(OpenSpan(key="reasoning:0", kind="reasoning", name="reasoning", input={})) + await tracer.handle(CloseSpan(key="reasoning:0", output="thought", is_complete=True)) + + for span in fake.ended_spans: + assert not (isinstance(span.data, dict) and LINEAGE_REFS_KEY in span.data) diff --git a/tests/lib/core/harness/test_types.py b/tests/lib/core/harness/test_types.py new file mode 100644 index 000000000..68bc89ce2 --- /dev/null +++ b/tests/lib/core/harness/test_types.py @@ -0,0 +1,53 @@ +from typing import AsyncIterator + +from agentex.lib.core.harness.types import ( + OpenSpan, + CloseSpan, + TurnUsage, + TurnResult, + HarnessTurn, + StreamTaskMessage, +) + + +def test_open_close_span_construct(): + o = OpenSpan(key="call_1", kind="tool", name="Bash", input={"cmd": "ls"}) + c = CloseSpan(key="call_1", output="files", is_complete=True) + assert o.key == c.key == "call_1" + assert o.kind == "tool" + assert c.is_complete is True + + +def test_turn_usage_defaults_are_none(): + u = TurnUsage(model="claude-opus-4-6") + assert u.model == "claude-opus-4-6" + assert u.input_tokens is None + assert u.num_tool_calls == 0 + + +def test_turn_result_wraps_usage(): + r = TurnResult(final_text="hi", usage=TurnUsage(model="m")) + assert r.final_text == "hi" + assert r.usage.model == "m" + + +def test_close_span_defaults(): + c = CloseSpan(key="x") + assert c.output is None + assert c.is_complete is True + + +def test_harness_turn_runtime_check(): + class _Turn: + @property + def events(self) -> AsyncIterator[StreamTaskMessage]: + async def _gen() -> AsyncIterator[StreamTaskMessage]: + if False: + yield # pragma: no cover + + return _gen() + + def usage(self) -> TurnUsage: + return TurnUsage(model="m") + + assert isinstance(_Turn(), HarnessTurn) is True diff --git a/tests/lib/core/harness/test_yield_delivery.py b/tests/lib/core/harness/test_yield_delivery.py new file mode 100644 index 000000000..21c93a95c --- /dev/null +++ b/tests/lib/core/harness/test_yield_delivery.py @@ -0,0 +1,78 @@ +import pytest + +from agentex.lib.core.harness.tracer import SpanTracer +from agentex.types.task_message_update import ( + StreamTaskMessageDone, + StreamTaskMessageFull, + StreamTaskMessageStart, +) +from agentex.types.tool_request_content import ToolRequestContent +from agentex.types.tool_response_content import ToolResponseContent +from agentex.lib.core.harness.yield_delivery import yield_events + +from ._fakes import FakeTracing + + +async def _gen(events): + for e in events: + yield e + + +@pytest.mark.asyncio +async def test_yield_passes_events_through_and_traces(): + fake = FakeTracing() + tracer = SpanTracer(trace_id="t", parent_span_id="p", tracing=fake) + events = [ + StreamTaskMessageStart( + type="start", + index=0, + content=ToolRequestContent( + type="tool_request", author="agent", tool_call_id="c", name="Bash", arguments={} + ), + ), + StreamTaskMessageDone(type="done", index=0), + StreamTaskMessageFull( + type="full", + index=1, + content=ToolResponseContent( + type="tool_response", author="agent", tool_call_id="c", name="Bash", content="ok" + ), + ), + ] + out = [e async for e in yield_events(_gen(events), tracer=tracer)] + assert out == events # passthrough unchanged + assert fake.started_names == ["Bash"] # span derived + opened + # String tool output is wrapped in a dict (SGP spans require an object). + assert fake.ended_outputs == [{"output": "ok"}] # span closed with response + + +@pytest.mark.asyncio +async def test_yield_without_tracer_is_pure_passthrough(): + events = [ + StreamTaskMessageDone(type="done", index=0), + ] + out = [e async for e in yield_events(_gen(events), tracer=None)] + assert out == events + + +@pytest.mark.asyncio +async def test_flush_runs_on_early_close(): + fake = FakeTracing() + tracer = SpanTracer(trace_id="t", parent_span_id="p", tracing=fake) + events = [ + StreamTaskMessageStart( + type="start", + index=0, + content=ToolRequestContent( + type="tool_request", author="agent", tool_call_id="c", name="Bash", arguments={} + ), + ), + StreamTaskMessageDone(type="done", index=0), + # response intentionally never arrives + ] + gen = yield_events(_gen(events), tracer=tracer) + first = await gen.__anext__() # Start + second = await gen.__anext__() # Done -> tool span opens here + await gen.aclose() # triggers the finally -> flush() + assert fake.started_names == ["Bash"] + assert fake.ended_outputs == [None] # flush closed the unpaired span (incomplete, no output) diff --git a/tests/lib/core/services/__init__.py b/tests/lib/core/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/lib/core/services/adk/__init__.py b/tests/lib/core/services/adk/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/lib/core/services/adk/test_streaming.py b/tests/lib/core/services/adk/test_streaming.py new file mode 100644 index 000000000..a8068f307 --- /dev/null +++ b/tests/lib/core/services/adk/test_streaming.py @@ -0,0 +1,599 @@ +"""Tests for the streaming service: ``CoalescingBuffer``, merge helpers, and +``StreamingTaskMessageContext`` mode dispatch. + +These exercise the in-process behavior of the streaming layer without hitting +Redis or any AgentEx HTTP endpoints — everything below the +``StreamingService.stream_update`` boundary is mocked. +""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from agentex.types.task_message import TaskMessage +from agentex.types.text_content import TextContent +from agentex.types.task_message_delta import ( + DataDelta, + TextDelta, + ToolRequestDelta, + ToolResponseDelta, + ReasoningSummaryDelta, +) +from agentex.types.task_message_update import ( + StreamTaskMessageFull, + StreamTaskMessageDelta, +) +from agentex.lib.core.services.adk.streaming import ( + CoalescingBuffer, + StreamingTaskMessageContext, + _can_merge, + _merge_pair, + _delta_char_len, + _merge_consecutive, +) + + +@pytest.fixture +def task_message() -> TaskMessage: + return TaskMessage( + id="m1", + task_id="t1", + content=TextContent(author="agent", content="", format="markdown"), + streaming_status="IN_PROGRESS", + ) + + +def _text(tm: TaskMessage, s: str) -> StreamTaskMessageDelta: + return StreamTaskMessageDelta( + parent_task_message=tm, + delta=TextDelta(type="text", text_delta=s), + type="delta", + ) + + +def _reasoning_summary(tm: TaskMessage, idx: int, s: str) -> StreamTaskMessageDelta: + return StreamTaskMessageDelta( + parent_task_message=tm, + delta=ReasoningSummaryDelta(type="reasoning_summary", summary_index=idx, summary_delta=s), + type="delta", + ) + + +async def _make_context(streaming_mode: str) -> tuple[StreamingTaskMessageContext, MagicMock, TaskMessage]: + tm = TaskMessage( + id="m1", + task_id="t1", + content=TextContent(author="agent", content="", format="markdown"), + streaming_status="IN_PROGRESS", + ) + svc = MagicMock() + svc.stream_update = AsyncMock() + client = MagicMock() + client.messages.create = AsyncMock(return_value=tm) + client.messages.update = AsyncMock() + ctx = StreamingTaskMessageContext( + task_id="t1", + initial_content=TextContent(author="agent", content="", format="markdown"), + agentex_client=client, + streaming_service=svc, + streaming_mode=streaming_mode, # type: ignore[arg-type] + ) + await ctx.open() + return ctx, svc, tm + + +class TestDeltaCharLen: + def test_text_delta(self) -> None: + assert _delta_char_len(TextDelta(type="text", text_delta="hello")) == 5 + + def test_reasoning_summary_delta(self) -> None: + assert ( + _delta_char_len(ReasoningSummaryDelta(type="reasoning_summary", summary_index=0, summary_delta="abc")) == 3 + ) + + def test_none_delta_is_zero(self) -> None: + assert _delta_char_len(None) == 0 + + def test_empty_string_delta(self) -> None: + assert _delta_char_len(TextDelta(type="text", text_delta="")) == 0 + + +class TestCanMerge: + def test_same_text_type(self) -> None: + a = TextDelta(type="text", text_delta="a") + b = TextDelta(type="text", text_delta="b") + assert _can_merge(a, b) is True + + def test_different_types_never_merge(self) -> None: + text = TextDelta(type="text", text_delta="a") + data = DataDelta(type="data", data_delta="b") + assert _can_merge(text, data) is False + + def test_reasoning_summary_same_index_merges(self) -> None: + a = ReasoningSummaryDelta(type="reasoning_summary", summary_index=0, summary_delta="x") + b = ReasoningSummaryDelta(type="reasoning_summary", summary_index=0, summary_delta="y") + assert _can_merge(a, b) is True + + def test_reasoning_summary_different_index_blocks_merge(self) -> None: + a = ReasoningSummaryDelta(type="reasoning_summary", summary_index=0, summary_delta="x") + b = ReasoningSummaryDelta(type="reasoning_summary", summary_index=1, summary_delta="y") + assert _can_merge(a, b) is False + + def test_tool_request_same_call_id_merges(self) -> None: + a = ToolRequestDelta(type="tool_request", tool_call_id="c1", name="t", arguments_delta="{") + b = ToolRequestDelta(type="tool_request", tool_call_id="c1", name="t", arguments_delta="}") + assert _can_merge(a, b) is True + + def test_tool_request_different_call_id_blocks_merge(self) -> None: + a = ToolRequestDelta(type="tool_request", tool_call_id="c1", name="t", arguments_delta="{") + b = ToolRequestDelta(type="tool_request", tool_call_id="c2", name="t", arguments_delta="}") + assert _can_merge(a, b) is False + + +class TestMergePair: + def test_text_concatenates(self) -> None: + merged = _merge_pair( + TextDelta(type="text", text_delta="Hello "), + TextDelta(type="text", text_delta="world"), + ) + assert isinstance(merged, TextDelta) + assert merged.text_delta == "Hello world" + + def test_reasoning_summary_concatenates_and_keeps_index(self) -> None: + merged = _merge_pair( + ReasoningSummaryDelta(type="reasoning_summary", summary_index=2, summary_delta="hello "), + ReasoningSummaryDelta(type="reasoning_summary", summary_index=2, summary_delta="world"), + ) + assert isinstance(merged, ReasoningSummaryDelta) + assert merged.summary_index == 2 + assert merged.summary_delta == "hello world" + + def test_tool_response_concatenates_and_keeps_call_id(self) -> None: + merged = _merge_pair( + ToolResponseDelta(type="tool_response", tool_call_id="c1", name="t", content_delta="part1 "), + ToolResponseDelta(type="tool_response", tool_call_id="c1", name="t", content_delta="part2"), + ) + assert isinstance(merged, ToolResponseDelta) + assert merged.tool_call_id == "c1" + assert merged.content_delta == "part1 part2" + + def test_handles_none_string_fields(self) -> None: + """Pydantic allows the *_delta fields to be None; merge must coerce to empty.""" + merged = _merge_pair( + TextDelta(type="text", text_delta=None), + TextDelta(type="text", text_delta="late"), + ) + assert isinstance(merged, TextDelta) + assert merged.text_delta == "late" + + +class TestMergeConsecutive: + def test_pure_text_collapses_to_one(self, task_message: TaskMessage) -> None: + deltas = [_text(task_message, s) for s in ["Hello", " ", "world", "!"]] + merged = _merge_consecutive(deltas) + assert len(merged) == 1 + assert merged[0].delta is not None + assert isinstance(merged[0].delta, TextDelta) + assert merged[0].delta.text_delta == "Hello world!" + + def test_empty_input_returns_empty_list(self) -> None: + assert _merge_consecutive([]) == [] + + def test_single_delta_passes_through(self, task_message: TaskMessage) -> None: + deltas = [_text(task_message, "lone")] + merged = _merge_consecutive(deltas) + assert len(merged) == 1 + assert merged[0] is deltas[0] # same object, no merge happened + + def test_cross_channel_order_preserved_for_reasoning(self, task_message: TaskMessage) -> None: + """Consecutive same-(type, index) merges; distinct channels never reorder.""" + deltas = [ + _reasoning_summary(task_message, 0, "Let me "), + _reasoning_summary(task_message, 0, "think..."), + _reasoning_summary(task_message, 1, "Maybe "), + _reasoning_summary(task_message, 0, " Actually,"), + _reasoning_summary(task_message, 0, " yes."), + ] + merged = _merge_consecutive(deltas) + # Three groups: idx=0 run, idx=1 single, idx=0 run again — order preserved. + assert len(merged) == 3 + assert merged[0].delta is not None and isinstance(merged[0].delta, ReasoningSummaryDelta) + assert merged[1].delta is not None and isinstance(merged[1].delta, ReasoningSummaryDelta) + assert merged[2].delta is not None and isinstance(merged[2].delta, ReasoningSummaryDelta) + assert merged[0].delta.summary_index == 0 + assert merged[0].delta.summary_delta == "Let me think..." + assert merged[1].delta.summary_index == 1 + assert merged[1].delta.summary_delta == "Maybe " + assert merged[2].delta.summary_index == 0 + assert merged[2].delta.summary_delta == " Actually, yes." + + def test_per_channel_concat_matches_per_token_semantics(self, task_message: TaskMessage) -> None: + """Reconstructing per-channel content from the merged stream must match + what a per-token consumer would have seen.""" + deltas = [ + _reasoning_summary(task_message, 0, "Hel"), + _reasoning_summary(task_message, 0, "lo"), + _reasoning_summary(task_message, 1, "World"), + _reasoning_summary(task_message, 0, "!"), + ] + merged = _merge_consecutive(deltas) + + per_index: dict[int, str] = {} + for u in merged: + d = u.delta + assert isinstance(d, ReasoningSummaryDelta) + per_index[d.summary_index] = per_index.get(d.summary_index, "") + (d.summary_delta or "") + + assert per_index == {0: "Hello!", 1: "World"} + + +class TestCoalescingBufferTimeWindow: + @pytest.mark.asyncio + async def test_first_delta_flushes_immediately(self, task_message: TaskMessage) -> None: + """The first-delta-immediate optimization should trip a flush in <=20ms, + well below the 50ms time window, so consumers see ``something started``.""" + flushed: list[StreamTaskMessageDelta] = [] + + async def on_flush(u: StreamTaskMessageDelta) -> None: + flushed.append(u) + + buf = CoalescingBuffer(on_flush=on_flush) + buf.start() + try: + await buf.add(_text(task_message, "hi")) + # Give the ticker a single tick to drain the signal. + await asyncio.sleep(0.020) + assert len(flushed) == 1 + assert flushed[0].delta is not None and isinstance(flushed[0].delta, TextDelta) + assert flushed[0].delta.text_delta == "hi" + finally: + await buf.close() + + @pytest.mark.asyncio + async def test_size_threshold_triggers_early_flush(self, task_message: TaskMessage) -> None: + """Adding more than MAX_BUFFERED_CHARS in one shot should flush within + a single asyncio tick, well before the 50ms timer would fire.""" + flushed: list[StreamTaskMessageDelta] = [] + + async def on_flush(u: StreamTaskMessageDelta) -> None: + flushed.append(u) + + buf = CoalescingBuffer(on_flush=on_flush) + buf.start() + try: + # Burn the first-delta-immediate slot so we're on the steady-state path. + await buf.add(_text(task_message, "x")) + await asyncio.sleep(0.020) + flushed.clear() + + # Now add 200 chars in one delta — well over MAX_BUFFERED_CHARS=128. + await buf.add(_text(task_message, "A" * 200)) + await asyncio.sleep(0.010) # half the timer interval; only size can fire here + assert len(flushed) == 1 + assert flushed[0].delta is not None and isinstance(flushed[0].delta, TextDelta) + assert flushed[0].delta.text_delta == "A" * 200 + finally: + await buf.close() + + @pytest.mark.asyncio + async def test_subsequent_deltas_coalesce_within_window(self, task_message: TaskMessage) -> None: + """Three small deltas added inside one timer window should publish as + one merged delta (after the initial first-flush burns).""" + flushed: list[StreamTaskMessageDelta] = [] + + async def on_flush(u: StreamTaskMessageDelta) -> None: + flushed.append(u) + + buf = CoalescingBuffer(on_flush=on_flush) + buf.start() + try: + await buf.add(_text(task_message, "first")) # immediate flush + await asyncio.sleep(0.020) + flushed.clear() + + for chunk in ("ab", "cd", "ef"): + await buf.add(_text(task_message, chunk)) + # Wait past the 50ms window so the timer fires. + await asyncio.sleep(0.080) + # All three small deltas merge into a single publish. + assert len(flushed) == 1 + assert flushed[0].delta is not None and isinstance(flushed[0].delta, TextDelta) + assert flushed[0].delta.text_delta == "abcdef" + finally: + await buf.close() + + +class TestCoalescingBufferClose: + @pytest.mark.asyncio + async def test_close_drains_remaining_buffered_items(self, task_message: TaskMessage) -> None: + """Items added after the last timer tick must still flush before close() + completes — the persisted message body and the stream contract both + require it.""" + flushed: list[StreamTaskMessageDelta] = [] + + async def on_flush(u: StreamTaskMessageDelta) -> None: + flushed.append(u) + + buf = CoalescingBuffer(on_flush=on_flush) + buf.start() + await buf.add(_text(task_message, "first")) # immediate + await asyncio.sleep(0.020) + flushed.clear() + + # Add an item and immediately close — too fast for the 50ms timer. + await buf.add(_text(task_message, "last")) + await buf.close() + + assert len(flushed) == 1 + assert flushed[0].delta is not None and isinstance(flushed[0].delta, TextDelta) + assert flushed[0].delta.text_delta == "last" + + @pytest.mark.asyncio + async def test_close_when_idle_is_safe(self, task_message: TaskMessage) -> None: + """``close()`` with no buffered items must not raise.""" + buf = CoalescingBuffer(on_flush=AsyncMock()) + buf.start() + await buf.close() # no items, no signal, just exit cleanly + + @pytest.mark.asyncio + async def test_add_after_close_is_noop(self, task_message: TaskMessage) -> None: + """Defensive: ``add`` after ``close`` must silently do nothing rather + than raise. Real flows shouldn't hit this but tests racing close() + should not blow up.""" + flushed: list[StreamTaskMessageDelta] = [] + + async def on_flush(u: StreamTaskMessageDelta) -> None: + flushed.append(u) + + buf = CoalescingBuffer(on_flush=on_flush) + buf.start() + await buf.close() + # Fully drained and closed; this should silently no-op. + await buf.add(_text(task_message, "after")) + assert flushed == [] + + @pytest.mark.asyncio + async def test_add_racing_close_is_not_stranded(self, task_message: TaskMessage) -> None: + """TOCTOU: a delta that passes add()'s pre-lock _closed check but only + acquires the lock after close() set _closed must be dropped, not appended + to a drained, ticker-less buffer where it would never be published.""" + buf = CoalescingBuffer(on_flush=AsyncMock()) + buf.start() + # Hold the lock so add() parks *after* its pre-lock _closed check. + await buf._lock.acquire() + add_task = asyncio.create_task(buf.add(_text(task_message, "racing"))) + await asyncio.sleep(0) # add() passes the _closed check, blocks on the lock + buf._closed = True # close() wins the race + buf._lock.release() + await add_task + + assert buf._buf == [], "racing delta was stranded in the closed buffer" + await buf.close() # cleanup + + +class TestCoalescingBufferCloseDuringFlush: + @pytest.mark.asyncio + async def test_close_during_flush_is_exactly_once( + self, task_message: TaskMessage + ) -> None: + """Regression: ``close()`` while the ticker is mid-flush must publish + each delta exactly once — no loss, no duplicate. + + The earlier implementation cancelled the ticker task during ``close()`` + and re-enqueued the in-flight item to avoid silent loss; that produced + a duplicated tail on the Redis stream when the Redis write had in fact + completed before the cancellation landed. The current implementation + signals the ticker to exit naturally after its next drain pass, which + gives exactly-once delivery without the duplication. + """ + flushed: list[StreamTaskMessageDelta] = [] + first_started = asyncio.Event() + first_continue = asyncio.Event() + + async def slow_flush(u: StreamTaskMessageDelta) -> None: + flushed.append(u) + if len(flushed) == 1: + first_started.set() + # Block the first publish until the test releases it; this + # parks close() inside the ticker's flush loop. + await first_continue.wait() + + buf = CoalescingBuffer(on_flush=slow_flush) + buf.start() + # Add five items quickly; they all land in self._buf and the ticker + # will drain them as one merged batch. + for i in range(5): + await buf.add(_text(task_message, f"chunk{i}")) + + await asyncio.wait_for(first_started.wait(), timeout=2.0) + # Trigger close() while the first flush is blocked, then release it. + close_task = asyncio.create_task(buf.close()) + # Give close() a tick to set _closed and start awaiting the ticker. + await asyncio.sleep(0) + first_continue.set() + await close_task + + full = "".join( + u.delta.text_delta or "" + for u in flushed + if isinstance(u.delta, TextDelta) + ) + # Exactly the five chunks, in order, with no duplication of any + # chunk's tail. + assert full == "chunk0chunk1chunk2chunk3chunk4", ( + f"expected exactly-once delivery; got: {full!r} " + f"(payloads: {[u.delta.text_delta for u in flushed if isinstance(u.delta, TextDelta)]})" + ) + + +class TestStreamingTaskMessageContextModes: + @pytest.mark.asyncio + async def test_off_mode_skips_publishes_but_persists_full_content(self) -> None: + ctx, svc, tm = await _make_context("off") + svc.stream_update.reset_mock() + for chunk in ("Hello", " ", "world"): + await ctx.stream_update(_text(tm, chunk)) + # Plenty of time for any background ticker — none should exist. + await asyncio.sleep(0.080) + assert svc.stream_update.call_count == 0, "off mode must publish zero per-delta updates" + + await ctx.close() + # The persisted message body must still contain the full assembled text, + # because the accumulator was fed even when publishing was suppressed. + update_kwargs = ctx._agentex_client.messages.update.call_args.kwargs + assert update_kwargs["content"]["content"] == "Hello world" + + @pytest.mark.asyncio + async def test_per_token_mode_publishes_each_delta_immediately(self) -> None: + ctx, svc, tm = await _make_context("per_token") + svc.stream_update.reset_mock() + for chunk in ("a", "b", "c"): + await ctx.stream_update(_text(tm, chunk)) + # Per-token mode must publish synchronously, no waiting required. + assert svc.stream_update.call_count == 3 + await ctx.close() + + @pytest.mark.asyncio + async def test_coalesced_mode_batches_and_persists_full_content(self) -> None: + ctx, svc, tm = await _make_context("coalesced") + svc.stream_update.reset_mock() + for chunk in ("Hello", " ", "world", "!"): + await ctx.stream_update(_text(tm, chunk)) + await ctx.close() + + # Assembled content is the union of all per-delta text. + update_kwargs = ctx._agentex_client.messages.update.call_args.kwargs + assert update_kwargs["content"]["content"] == "Hello world!" + + # Coalesced mode produces fewer publishes than per_token (4) but at + # least the start + at least one delta + done. + delta_publishes = [ + call + for call in svc.stream_update.call_args_list + if isinstance(call.args[0] if call.args else None, StreamTaskMessageDelta) + ] + assert len(delta_publishes) >= 1, "coalesced mode should publish at least one delta" + assert len(delta_publishes) < 4, "coalesced mode should batch at least some of the four chunks" + + +class TestStreamingTaskMessageContextCreatedAt: + """Verifies the workflow-supplied created_at is forwarded to messages.create + on open(), and omitted (server default) when no timestamp is supplied.""" + + @pytest.mark.asyncio + async def test_open_forwards_created_at(self) -> None: + from datetime import datetime, timezone + + from agentex._types import omit + + ts = datetime(2026, 5, 13, 18, 30, 0, tzinfo=timezone.utc) + tm = TaskMessage( + id="m1", + task_id="t1", + content=TextContent(author="agent", content="", format="markdown"), + streaming_status="IN_PROGRESS", + ) + svc = MagicMock() + svc.stream_update = AsyncMock() + client = MagicMock() + client.messages.create = AsyncMock(return_value=tm) + client.messages.update = AsyncMock() + ctx = StreamingTaskMessageContext( + task_id="t1", + initial_content=TextContent(author="agent", content="", format="markdown"), + agentex_client=client, + streaming_service=svc, + streaming_mode="off", + created_at=ts, + ) + await ctx.open() + + kwargs = client.messages.create.call_args.kwargs + assert kwargs["created_at"] == ts + assert kwargs["created_at"] is not omit + + @pytest.mark.asyncio + async def test_open_without_created_at_passes_omit(self) -> None: + from agentex._types import omit + + tm = TaskMessage( + id="m1", + task_id="t1", + content=TextContent(author="agent", content="", format="markdown"), + streaming_status="IN_PROGRESS", + ) + svc = MagicMock() + svc.stream_update = AsyncMock() + client = MagicMock() + client.messages.create = AsyncMock(return_value=tm) + client.messages.update = AsyncMock() + ctx = StreamingTaskMessageContext( + task_id="t1", + initial_content=TextContent(author="agent", content="", format="markdown"), + agentex_client=client, + streaming_service=svc, + streaming_mode="off", + ) + await ctx.open() + + kwargs = client.messages.create.call_args.kwargs + assert kwargs["created_at"] is omit + + +class TestFullMessageClosesBuffer: + """A StreamTaskMessageFull must stop the buffer ticker and drain its deltas + before the terminal Full. Marking the context done without closing the + buffer leaves close()'s _is_closed short-circuit to orphan the ticker, and + publishing buffered deltas after the Full reads as a stale duplicate tail.""" + + @pytest.mark.asyncio + async def test_full_message_stops_ticker(self) -> None: + ctx, _svc, tm = await _make_context("coalesced") + # A delta makes the buffer and its ticker live. + await ctx.stream_update(_text(tm, "hello")) + buf = ctx._buffer + assert buf is not None + task = buf._task + assert task is not None and not task.done() + + await ctx.stream_update( + StreamTaskMessageFull( + parent_task_message=tm, + content=TextContent(author="agent", content="final", format="markdown"), + type="full", + ) + ) + + assert ctx._buffer is None, "Full message left the buffer un-closed" + assert task.done(), "coalescing-buffer ticker still running after Full (orphaned)" + + @pytest.mark.asyncio + async def test_full_is_terminal_publish_no_trailing_deltas(self) -> None: + # Buffered deltas must publish BEFORE the Full, never after (a trailing + # delta after the terminal Full reads as a stale duplicate tail). + ctx, svc, tm = await _make_context("coalesced") + # Two deltas through the buffer. Regardless of how the coalescing window + # batches them (1 or 2 publishes), the invariant under test is the same: + # every delta publishes before the terminal Full, never after it. + await ctx.stream_update(_text(tm, "alpha")) + await ctx.stream_update(_text(tm, "beta")) + + full = StreamTaskMessageFull( + parent_task_message=tm, + content=TextContent(author="agent", content="alphabeta", format="markdown"), + type="full", + ) + await ctx.stream_update(full) + + published = [c.args[0] for c in svc.stream_update.await_args_list] + assert published, "nothing was published" + assert published[-1] is full, ( + f"Full must be the terminal publish; saw trailing " + f"{type(published[-1]).__name__} after it (stale duplicate tail)" + ) + assert any(isinstance(u, StreamTaskMessageDelta) for u in published[:-1]), ( + "expected the buffered deltas to be published before the Full" + ) diff --git a/tests/lib/core/services/test_temporal_task_service.py b/tests/lib/core/services/test_temporal_task_service.py new file mode 100644 index 000000000..7589863cd --- /dev/null +++ b/tests/lib/core/services/test_temporal_task_service.py @@ -0,0 +1,140 @@ +"""Unit tests for TemporalTaskService idempotency behavior. + +Covers the ``task/create`` idempotency guarantee: duplicate submits for the +same task ID must not raise ``WorkflowAlreadyStartedError``. The service +achieves this by passing ``ConflictWorkflowPolicy.USE_EXISTING`` through the +``TemporalClient`` wrapper, which maps to ``WorkflowIDConflictPolicy.USE_EXISTING`` +on the underlying temporalio client so Temporal returns a handle to the +existing run instead of erroring. +""" + +from __future__ import annotations + +from unittest.mock import Mock, AsyncMock + +import pytest +from temporalio.common import WorkflowIDConflictPolicy + +from agentex.types.task import Task +from agentex.types.agent import Agent +from agentex.lib.core.clients.temporal.types import ( + ConflictWorkflowPolicy, + DuplicateWorkflowPolicy, +) +from agentex.lib.core.clients.temporal.temporal_client import TemporalClient +from agentex.lib.core.temporal.services.temporal_task_service import TemporalTaskService + + +def _agent() -> Agent: + return Agent( + id="test-agent-456", + name="test-agent", + description="test-agent", + acp_type="async", + created_at="2023-01-01T00:00:00Z", + updated_at="2023-01-01T00:00:00Z", + ) + + +def _task() -> Task: + return Task(id="test-task-123", status="RUNNING") + + +def _env_vars() -> Mock: + env_vars = Mock() + env_vars.WORKFLOW_NAME = "test-workflow" + env_vars.WORKFLOW_TASK_QUEUE = "test-queue" + env_vars.WORKFLOW_EXECUTION_TIMEOUT_SECONDS = 0 + return env_vars + + +class TestSubmitTaskIdempotency: + async def test_submit_task_uses_use_existing_conflict_policy(self) -> None: + """Duplicate task/create must be idempotent. + + Passing ``ConflictWorkflowPolicy.USE_EXISTING`` tells Temporal to + return the existing workflow handle instead of raising + ``WorkflowAlreadyStartedError`` when a run with that ID is already + active. Without this, load-balanced agentex-agent replicas racing on + the same task ID surface Temporal's start conflict as an error log. + """ + temporal_client = Mock() + temporal_client.start_workflow = AsyncMock(return_value="test-task-123") + + service = TemporalTaskService(temporal_client=temporal_client, env_vars=_env_vars()) + + result = await service.submit_task(agent=_agent(), task=_task(), params=None) + + temporal_client.start_workflow.assert_awaited_once() + kwargs = temporal_client.start_workflow.await_args.kwargs + assert kwargs["conflict_policy"] == ConflictWorkflowPolicy.USE_EXISTING + assert kwargs["id"] == "test-task-123" + assert result == "test-task-123" + + +class TestTemporalClientConflictPolicyPlumbing: + """Boundary tests: ``TemporalClient.start_workflow`` wraps + ``WorkflowIDConflictPolicy`` in a local ``ConflictWorkflowPolicy`` enum + (mirroring the existing ``DuplicateWorkflowPolicy`` pattern) so SDK users + don't have to import ``temporalio.common`` to opt into non-default behavior. + Also guards the incompatible ``TERMINATE_IF_RUNNING`` + explicit-conflict + combo client-side rather than letting it round-trip to the frontend as + ``InvalidArgument``. + """ + + async def test_forwards_conflict_policy_when_set(self) -> None: + inner_client = Mock() + inner_handle = Mock() + inner_handle.id = "wf-1" + inner_client.start_workflow = AsyncMock(return_value=inner_handle) + + tc = TemporalClient(temporal_client=inner_client) + + await tc.start_workflow( + workflow="w", + arg={}, + id="id-1", + task_queue="q", + conflict_policy=ConflictWorkflowPolicy.USE_EXISTING, + ) + + kwargs = inner_client.start_workflow.await_args.kwargs + assert kwargs["id_conflict_policy"] == WorkflowIDConflictPolicy.USE_EXISTING + + async def test_default_conflict_policy_is_unspecified(self) -> None: + inner_client = Mock() + inner_handle = Mock() + inner_handle.id = "wf-1" + inner_client.start_workflow = AsyncMock(return_value=inner_handle) + + tc = TemporalClient(temporal_client=inner_client) + + await tc.start_workflow(workflow="w", arg={}, id="id-1", task_queue="q") + + kwargs = inner_client.start_workflow.await_args.kwargs + assert kwargs["id_conflict_policy"] == WorkflowIDConflictPolicy.UNSPECIFIED + + async def test_terminate_if_running_with_explicit_conflict_policy_raises(self) -> None: + """temporalio rejects this combo at the frontend as InvalidArgument; + we fail fast client-side with a clearer message. + """ + inner_client = Mock() + inner_client.start_workflow = AsyncMock() + + tc = TemporalClient(temporal_client=inner_client) + + with pytest.raises(ValueError, match="TERMINATE_EXISTING"): + await tc.start_workflow( + workflow="w", + arg={}, + id="id-1", + task_queue="q", + duplicate_policy=DuplicateWorkflowPolicy.TERMINATE_IF_RUNNING, + conflict_policy=ConflictWorkflowPolicy.USE_EXISTING, + ) + + inner_client.start_workflow.assert_not_awaited() + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(pytest.main([__file__, "-v"])) diff --git a/tests/lib/core/temporal/__init__.py b/tests/lib/core/temporal/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/lib/core/temporal/plugins/__init__.py b/tests/lib/core/temporal/plugins/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/lib/core/temporal/plugins/openai_agents/__init__.py b/tests/lib/core/temporal/plugins/openai_agents/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/lib/core/temporal/plugins/openai_agents/test_model_usage.py b/tests/lib/core/temporal/plugins/openai_agents/test_model_usage.py new file mode 100644 index 000000000..bf3dc8006 --- /dev/null +++ b/tests/lib/core/temporal/plugins/openai_agents/test_model_usage.py @@ -0,0 +1,182 @@ +"""Tests that the openai_agents streaming model copies real token usage onto spans. + +The backend bills per-call usage from ``span.output["usage"]``; these tests +assert the streaming model writes the API-reported usage there (and into the +returned ``ModelResponse.usage``) instead of dropping it. +""" + +from __future__ import annotations + +from typing import Any +from datetime import UTC, datetime +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from agents import ModelSettings +from openai.types.responses import Response, ResponseCompletedEvent +from openai.types.responses.response_usage import ( + ResponseUsage, + InputTokensDetails, + OutputTokensDetails, +) + +import agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model as tsm +from agentex.types.span import Span +from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ( + streaming_task_id, + streaming_trace_id, + streaming_parent_span_id, +) + +pytestmark = pytest.mark.asyncio + + +class FakeTrace: + """Captures spans handed out by trace.span() so tests can inspect them.""" + + def __init__(self) -> None: + self.spans: list[Span] = [] + + @asynccontextmanager + async def span(self, name, parent_id=None, input=None, data=None, task_id=None): + span = Span( + id=f"span-{len(self.spans)}", + name=name, + start_time=datetime.now(UTC), + trace_id="trace-1", + parent_id=parent_id, + input=input, + data=data, + task_id=task_id, + ) + self.spans.append(span) + yield span + + +class FakeTracer: + def __init__(self) -> None: + self.trace_obj = FakeTrace() + + def trace(self, trace_id): + return self.trace_obj + + +@pytest.fixture +def tracing_contextvars(): + tokens = [ + streaming_task_id.set("task-1"), + streaming_trace_id.set("trace-1"), + streaming_parent_span_id.set("parent-span-1"), + ] + yield + streaming_task_id.reset(tokens[0]) + streaming_trace_id.reset(tokens[1]) + streaming_parent_span_id.reset(tokens[2]) + + +EXPECTED_USAGE_BLOB = { + "input_tokens": 120, + "output_tokens": 80, + "total_tokens": 200, + "cached_input_tokens": 30, + "reasoning_tokens": 40, +} + + +def _output_dict(span: Span) -> dict[str, Any]: + assert isinstance(span.output, dict) + return span.output + + +class FakeStream: + def __init__(self, events) -> None: + self._events = events + + def __aiter__(self): + async def gen(): + for event in self._events: + yield event + + return gen() + + +class TestTemporalStreamingModel: + async def test_streaming_model_captures_final_response_usage(self, tracing_contextvars): + usage = ResponseUsage( + input_tokens=120, + output_tokens=80, + total_tokens=200, + input_tokens_details=InputTokensDetails(cached_tokens=30), + output_tokens_details=OutputTokensDetails(reasoning_tokens=40), + ) + completed = ResponseCompletedEvent.model_construct( + type="response.completed", + response=Response.model_construct(output=[], usage=usage), + ) + + fake_tracer = FakeTracer() + openai_client = MagicMock() + openai_client.responses.create = AsyncMock(return_value=FakeStream([completed])) + + with patch.object(tsm, "create_async_agentex_client", return_value=MagicMock()): + with patch.object(tsm, "AsyncTracer", return_value=fake_tracer): + model = tsm.TemporalStreamingModel(model_name="gpt-4o", openai_client=openai_client) + + response = await model.get_response( + system_instructions=None, + input="hello", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + # Real usage lands on the returned ModelResponse (was zeroed before) + assert response.usage.input_tokens == 120 + assert response.usage.output_tokens == 80 + assert response.usage.total_tokens == 200 + assert response.usage.input_tokens_details.cached_tokens == 30 + assert response.usage.output_tokens_details.reasoning_tokens == 40 + + # And on the span output for billing + assert len(fake_tracer.trace_obj.spans) == 1 + span = fake_tracer.trace_obj.spans[0] + assert _output_dict(span)["usage"] == EXPECTED_USAGE_BLOB + + async def test_streaming_model_writes_zero_usage_when_api_reports_none(self, tracing_contextvars): + completed = ResponseCompletedEvent.model_construct( + type="response.completed", + response=Response.model_construct(output=[], usage=None), + ) + + fake_tracer = FakeTracer() + openai_client = MagicMock() + openai_client.responses.create = AsyncMock(return_value=FakeStream([completed])) + + with patch.object(tsm, "create_async_agentex_client", return_value=MagicMock()): + with patch.object(tsm, "AsyncTracer", return_value=fake_tracer): + model = tsm.TemporalStreamingModel(model_name="gpt-4o", openai_client=openai_client) + + response = await model.get_response( + system_instructions=None, + input="hello", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=None, + ) + + # No usage from the API: the model reports zeros rather than omitting, + # so billing sums 0 instead of missing the span + assert response.usage.input_tokens == 0 + span = fake_tracer.trace_obj.spans[0] + assert _output_dict(span)["usage"] == { + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "cached_input_tokens": 0, + "reasoning_tokens": 0, + } diff --git a/tests/lib/core/temporal/test_base_workflow_continue_as_new.py b/tests/lib/core/temporal/test_base_workflow_continue_as_new.py new file mode 100644 index 000000000..bc899c41b --- /dev/null +++ b/tests/lib/core/temporal/test_base_workflow_continue_as_new.py @@ -0,0 +1,75 @@ +"""Unit tests for BaseWorkflow's continue-as-new lifecycle helpers. + +These exercise the pure decision helpers (``should_continue_as_new`` and +``is_continued_run``) by faking ``workflow.info()`` so we don't need a running +Temporal server. The drain + ``workflow.continue_as_new`` mechanics in +``drain_and_continue_as_new`` / ``run_until_complete`` are best covered by a +replay/integration test against a Temporal test environment (a follow-up). +""" + +from __future__ import annotations + +from typing import override + +import pytest + +from agentex.lib.core.temporal.workflows import workflow as base_workflow_module +from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow + + +class _ConcreteWorkflow(BaseWorkflow): + """Minimal concrete subclass so we can instantiate the ABC in a test.""" + + def __init__(self) -> None: + self.display_name = "test" + + @override + async def on_task_event_send(self, params) -> None: # pragma: no cover - unused + raise NotImplementedError + + @override + async def on_task_create(self, params) -> None: # pragma: no cover - unused + raise NotImplementedError + + +class _FakeInfo: + def __init__(self, *, suggested: bool, continued_run_id: str | None = None) -> None: + self._suggested = suggested + self.continued_run_id = continued_run_id + + def is_continue_as_new_suggested(self) -> bool: + return self._suggested + + +@pytest.fixture +def patch_info(monkeypatch): + """Patch ``workflow.info`` used inside the BaseWorkflow module.""" + + def _apply(*, suggested: bool = False, continued_run_id: str | None = None) -> None: + monkeypatch.setattr( + base_workflow_module.workflow, + "info", + lambda: _FakeInfo(suggested=suggested, continued_run_id=continued_run_id), + ) + + return _apply + + +def test_recycles_when_temporal_suggests(patch_info): + patch_info(suggested=True) + assert _ConcreteWorkflow().should_continue_as_new() is True + + +def test_no_recycle_when_not_suggested(patch_info): + patch_info(suggested=False) + assert _ConcreteWorkflow().should_continue_as_new() is False + + +def test_is_continued_run_false_on_original_run(patch_info): + patch_info(continued_run_id=None) + assert _ConcreteWorkflow().is_continued_run() is False + + +def test_is_continued_run_true_after_recycle(patch_info): + patch_info(continued_run_id="run-123") + assert _ConcreteWorkflow().is_continued_run() is True diff --git a/tests/lib/core/temporal/workers/__init__.py b/tests/lib/core/temporal/workers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/lib/core/temporal/workers/test_worker_version_guard.py b/tests/lib/core/temporal/workers/test_worker_version_guard.py new file mode 100644 index 000000000..5c2c9fb47 --- /dev/null +++ b/tests/lib/core/temporal/workers/test_worker_version_guard.py @@ -0,0 +1,70 @@ +"""AgentexWorker wires the backend version guard into worker startup. + +A Temporal worker runs as its own process and never goes through the ACP server +lifespan, so the guard must run inside `_register_agent` — before `register_agent`, +and only when `AGENTEX_BASE_URL` is set. +""" + +from __future__ import annotations + +from unittest.mock import Mock, AsyncMock + +import pytest + +from agentex.lib.core.temporal.workers import worker as worker_mod +from agentex.lib.core.compat.version_guard import IncompatibleBackendError + + +def _worker(): + # explicit health_check_port so __init__ doesn't read EnvironmentVariables + return worker_mod.AgentexWorker(task_queue="test-queue", health_check_port=8080) + + +def _patch_env(monkeypatch, base_url): + env = Mock() + env.AGENTEX_BASE_URL = base_url + fake_cls = Mock() + fake_cls.refresh.return_value = env + monkeypatch.setattr(worker_mod, "EnvironmentVariables", fake_cls) + return env + + +async def test_guard_runs_before_register_agent(monkeypatch): + env = _patch_env(monkeypatch, "http://backend") + order: list[str] = [] + guard = AsyncMock(side_effect=lambda *a, **k: order.append("guard")) + register = AsyncMock(side_effect=lambda *a, **k: order.append("register")) + monkeypatch.setattr(worker_mod, "assert_backend_compatible", guard) + monkeypatch.setattr(worker_mod, "register_agent", register) + + await _worker()._register_agent() + + guard.assert_awaited_once_with("http://backend") + register.assert_awaited_once_with(env, agent_card=None) + assert order == ["guard", "register"] # guard must precede registration + + +async def test_incompatible_backend_blocks_registration(monkeypatch): + _patch_env(monkeypatch, "http://backend") + guard = AsyncMock(side_effect=IncompatibleBackendError("backend too old")) + register = AsyncMock() + monkeypatch.setattr(worker_mod, "assert_backend_compatible", guard) + monkeypatch.setattr(worker_mod, "register_agent", register) + + with pytest.raises(IncompatibleBackendError): + await _worker()._register_agent() + + register.assert_not_awaited() # fail fast — never register against an unsupported backend + + +async def test_no_base_url_skips_guard_and_registration(monkeypatch): + _patch_env(monkeypatch, None) + guard = AsyncMock() + register = AsyncMock() + monkeypatch.setattr(worker_mod, "assert_backend_compatible", guard) + monkeypatch.setattr(worker_mod, "register_agent", register) + + await _worker()._register_agent() + + guard.assert_not_awaited() + register.assert_not_awaited() diff --git a/tests/lib/core/tracing/__init__.py b/tests/lib/core/tracing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/lib/core/tracing/processors/__init__.py b/tests/lib/core/tracing/processors/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/lib/core/tracing/processors/test_agentex_tracing_processor.py b/tests/lib/core/tracing/processors/test_agentex_tracing_processor.py new file mode 100644 index 000000000..84f37b495 --- /dev/null +++ b/tests/lib/core/tracing/processors/test_agentex_tracing_processor.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import asyncio +import weakref +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# AgentexAsyncTracingProcessor pulls in agentex.lib.adk via +# create_async_agentex_client, which in turn imports pydantic_ai at package +# init. Skip these tests cleanly when pydantic_ai isn't installed (the SDK +# dev venv state) so collection doesn't error out. +pytest.importorskip( + "pydantic_ai", + reason="agentex.lib.adk import chain requires pydantic_ai", +) + +# Import the processor module up front so unittest.mock.patch() can resolve +# attributes by string path. The tracing_processor_manager only loads this +# module lazily, so without this explicit import the patches below would fail +# with AttributeError at __enter__ time. +import agentex.lib.core.tracing.processors.agentex_tracing_processor # noqa: E402, F401 + +MODULE = "agentex.lib.core.tracing.processors.agentex_tracing_processor" + + +SKIP_ENV = "AGENTEX_TRACING_SKIP_AGENTEX_SPAN_START" + + +def _make_config() -> MagicMock: + """Empty config — AgentexTracingProcessorConfig is unused by __init__.""" + return MagicMock() + + +def _make_span(): + from agentex.types.span import Span + + now = datetime.now(timezone.utc) + return Span( + id="span-1", + trace_id="trace-1", + name="test-span", + start_time=now, + end_time=now, + input={"in": 1}, + output={"out": 2}, + ) + + +class TestAgentexSyncSkipSpanStart: + """The Agentex backend writes create-on-start + update-on-end by default. + End-only ingest (default) skips the start write and makes the END a single + create — verify the start is a no-op and end does an INSERT, not an UPDATE. + """ + + def test_start_skipped_and_end_creates_by_default(self, monkeypatch): + monkeypatch.delenv(SKIP_ENV, raising=False) # default ON + with patch(f"{MODULE}.Agentex") as MockAgentex: + from agentex.lib.core.tracing.processors.agentex_tracing_processor import ( + AgentexSyncTracingProcessor, + ) + + processor = AgentexSyncTracingProcessor(_make_config()) + client = MockAgentex.return_value + span = _make_span() + + processor.on_span_start(span) + client.spans.create.assert_not_called() # start skipped + client.spans.update.assert_not_called() + + processor.on_span_end(span) + client.spans.create.assert_called_once() # single INSERT on end + client.spans.update.assert_not_called() # never a 404-prone UPDATE + + def test_start_creates_and_end_updates_when_skip_disabled(self, monkeypatch): + monkeypatch.setenv(SKIP_ENV, "0") + with patch(f"{MODULE}.Agentex") as MockAgentex: + from agentex.lib.core.tracing.processors.agentex_tracing_processor import ( + AgentexSyncTracingProcessor, + ) + + processor = AgentexSyncTracingProcessor(_make_config()) + client = MockAgentex.return_value + span = _make_span() + + processor.on_span_start(span) + client.spans.create.assert_called_once() # start write restored + + processor.on_span_end(span) + client.spans.update.assert_called_once() # end is the UPDATE + + def test_skip_decision_captured_at_init_not_per_call(self, monkeypatch): + """The two halves of a span MUST use the same skip decision. A flag + toggled after construction must not split it (start-skip + end-update + would 404). The decision is captured once at init. + """ + monkeypatch.delenv(SKIP_ENV, raising=False) # construct with skip ON + with patch(f"{MODULE}.Agentex") as MockAgentex: + from agentex.lib.core.tracing.processors.agentex_tracing_processor import ( + AgentexSyncTracingProcessor, + ) + + processor = AgentexSyncTracingProcessor(_make_config()) + client = MockAgentex.return_value + span = _make_span() + + processor.on_span_start(span) # skipped (cached ON) + monkeypatch.setenv(SKIP_ENV, "0") # toggle mid-span — must be ignored + processor.on_span_end(span) + + client.spans.create.assert_called_once() # still end-only INSERT + client.spans.update.assert_not_called() # NOT a 404-prone UPDATE + + +class TestAgentexAsyncSkipSpanStart: + async def test_start_skipped_and_end_creates_by_default(self, monkeypatch): + monkeypatch.delenv(SKIP_ENV, raising=False) # default ON + with patch(f"{MODULE}.create_async_agentex_client") as mock_factory: + client = MagicMock() + client.spans.create = AsyncMock() + client.spans.update = AsyncMock() + mock_factory.return_value = client + + from agentex.lib.core.tracing.processors.agentex_tracing_processor import ( + AgentexAsyncTracingProcessor, + ) + + processor = AgentexAsyncTracingProcessor(_make_config()) + span = _make_span() + + await processor.on_span_start(span) + client.spans.create.assert_not_called() # start skipped + client.spans.update.assert_not_called() + + await processor.on_span_end(span) + client.spans.create.assert_awaited_once() # single INSERT on end + client.spans.update.assert_not_called() + + async def test_start_creates_and_end_updates_when_skip_disabled(self, monkeypatch): + monkeypatch.setenv(SKIP_ENV, "0") + with patch(f"{MODULE}.create_async_agentex_client") as mock_factory: + client = MagicMock() + client.spans.create = AsyncMock() + client.spans.update = AsyncMock() + mock_factory.return_value = client + + from agentex.lib.core.tracing.processors.agentex_tracing_processor import ( + AgentexAsyncTracingProcessor, + ) + + processor = AgentexAsyncTracingProcessor(_make_config()) + span = _make_span() + + await processor.on_span_start(span) + client.spans.create.assert_awaited_once() # start write restored + + await processor.on_span_end(span) + client.spans.update.assert_awaited_once() # end is the UPDATE + + async def test_skip_decision_captured_at_init_not_per_call(self, monkeypatch): + """A flag toggled after construction must not split a span's lifecycle.""" + monkeypatch.delenv(SKIP_ENV, raising=False) # construct with skip ON + with patch(f"{MODULE}.create_async_agentex_client") as mock_factory: + client = MagicMock() + client.spans.create = AsyncMock() + client.spans.update = AsyncMock() + mock_factory.return_value = client + + from agentex.lib.core.tracing.processors.agentex_tracing_processor import ( + AgentexAsyncTracingProcessor, + ) + + processor = AgentexAsyncTracingProcessor(_make_config()) + span = _make_span() + + await processor.on_span_start(span) # skipped (cached ON) + monkeypatch.setenv(SKIP_ENV, "0") # toggle mid-span — must be ignored + await processor.on_span_end(span) + + client.spans.create.assert_awaited_once() # still end-only INSERT + client.spans.update.assert_not_called() # NOT a 404-prone UPDATE + + +class TestAgentexAsyncTracingProcessor: + """Coverage for the per-event-loop client cache. The SGP processor has + matching tests; mirror them here so a regression in the Agentex side + (e.g. an accidental refactor that switches back to a plain dict, or + drops the lazy lookup) does not slip through unnoticed. + """ + + async def test_client_caches_per_event_loop(self): + """First access builds the client; subsequent accesses in the same + running loop must return the cached instance. + """ + with patch(f"{MODULE}.create_async_agentex_client") as mock_factory: + mock_factory.side_effect = lambda **kwargs: MagicMock() + + from agentex.lib.core.tracing.processors.agentex_tracing_processor import ( + AgentexAsyncTracingProcessor, + ) + + processor = AgentexAsyncTracingProcessor(_make_config()) + + # Construction must not eagerly build the client (no running loop + # guarantee at module import time). + assert mock_factory.call_count == 0 + + c1 = processor.client + c2 = processor.client + c3 = processor.client + + assert mock_factory.call_count == 1, ( + f"Expected client to be built once per loop, but " + f"create_async_agentex_client was called {mock_factory.call_count} times" + ) + assert c1 is c2 is c3 + + async def test_client_keepalive_is_enabled(self): + """Regression guard: the per-loop client must use keepalive — the + whole reason for the per-loop cache. Verify max_keepalive_connections > 0. + """ + import httpx as _httpx + + captured_limits: list[_httpx.Limits] = [] + original_async_client = _httpx.AsyncClient + + def capture_limits(*args, **kwargs): + limits = kwargs.get("limits") + if limits is not None: + captured_limits.append(limits) + return original_async_client(*args, **kwargs) + + with patch(f"{MODULE}.create_async_agentex_client") as mock_factory, patch( + "httpx.AsyncClient", side_effect=capture_limits + ): + mock_factory.side_effect = lambda **kwargs: MagicMock() + + from agentex.lib.core.tracing.processors.agentex_tracing_processor import ( + AgentexAsyncTracingProcessor, + ) + + processor = AgentexAsyncTracingProcessor(_make_config()) + _ = processor.client + + assert len(captured_limits) == 1 + max_keepalive = captured_limits[0].max_keepalive_connections + assert max_keepalive is not None and max_keepalive > 0, ( + f"Agentex async client should have keepalive enabled, got " + f"max_keepalive_connections={max_keepalive}" + ) + + def test_cache_is_weakkeydict_and_evicts_dead_loops(self): + """Regression guard for the id()-reuse bug: the per-loop cache must + be a WeakKeyDictionary so a GC'd loop's entry is evicted. Otherwise + a new loop landing at the same memory address would reuse the dead + loop's client, reintroducing the "bound to a different event loop" + error the per-loop cache was built to prevent. + """ + import gc + + with patch(f"{MODULE}.create_async_agentex_client"): + from agentex.lib.core.tracing.processors.agentex_tracing_processor import ( + AgentexAsyncTracingProcessor, + ) + + processor = AgentexAsyncTracingProcessor(_make_config()) + + # Storage type itself: WeakKeyDictionary, not plain dict. + assert isinstance(processor._clients_by_loop, weakref.WeakKeyDictionary) + + # End-to-end check: insert under a loop, drop the loop, the entry + # must vanish after GC. + loop = asyncio.new_event_loop() + try: + processor._clients_by_loop[loop] = MagicMock() + assert len(processor._clients_by_loop) == 1 + finally: + loop.close() + del loop + gc.collect() + assert len(processor._clients_by_loop) == 0, ( + "WeakKeyDictionary should have evicted the dead loop's entry; " + "remaining keys would cause stale-client reuse on id() recycling." + ) diff --git a/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py b/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py new file mode 100644 index 000000000..6cd324f01 --- /dev/null +++ b/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py @@ -0,0 +1,573 @@ +from __future__ import annotations + +import uuid +import asyncio +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from agentex.types.span import Span +from agentex.lib.types.tracing import SGPTracingProcessorConfig + +MODULE = "agentex.lib.core.tracing.processors.sgp_tracing_processor" + + +def _make_config() -> SGPTracingProcessorConfig: + return SGPTracingProcessorConfig( + sgp_api_key="test-key", + sgp_account_id="test-account", + ) + + +def _make_span(span_id: str | None = None) -> Span: + return Span( + id=span_id or str(uuid.uuid4()), + name="test-span", + start_time=datetime.now(UTC), + trace_id="trace-1", + ) + + +def _make_mock_sgp_span() -> MagicMock: + sgp_span = MagicMock() + sgp_span.to_request_params.return_value = {"mock": "params"} + sgp_span.start_time = None + sgp_span.end_time = None + sgp_span.output = None + sgp_span.metadata = None + return sgp_span + + +class TestSourceStamps: + def test_agent_identity_and_version_stamped_into_span_data(self): + from agentex.lib.core.tracing.processors.sgp_tracing_processor import _add_source_to_span + + env = MagicMock(ACP_TYPE="async", AGENT_NAME="emu-tax", AGENT_ID="a1", AGENT_VERSION="sha-abc123") + span = _make_span() + _add_source_to_span(span, env) + assert span.data == { + "__source__": "agentex", + "__acp_type__": "async", + "__agent_name__": "emu-tax", + "__agent_id__": "a1", + "__agent_version__": "sha-abc123", + } + + SHA = "b362b171a9c4e1f09d8e7a6b5c4d3e2f1a0b9c8d" + + def test_commit_sha_is_not_stamped_without_opt_in(self, monkeypatch): + """Upgrading the SDK must not start emitting __commit_sha__ on its own, + even when the environment carries a perfectly good SHA.""" + from agentex.lib.core.tracing import code_revision + from agentex.lib.core.tracing.processors.sgp_tracing_processor import _sgp_metadata + + monkeypatch.setenv("AGENT_COMMIT_SHA", self.SHA) + code_revision.disable() + + span = _make_span(); span.data = {} + assert "__commit_sha__" not in (_sgp_metadata(span) or {}) + + def test_commit_sha_is_stamped_after_opt_in(self, monkeypatch): + from agentex.lib.core.tracing import code_revision + from agentex.lib.core.tracing.processors.sgp_tracing_processor import _sgp_metadata + + monkeypatch.setenv("AGENT_COMMIT_SHA", self.SHA) + code_revision.enable() + try: + span = _make_span(); span.data = {"caller": "kept"} + metadata = _sgp_metadata(span) + assert metadata["__commit_sha__"] == self.SHA + assert metadata["caller"] == "kept" + finally: + code_revision.disable() + + def test_commit_sha_does_not_leak_onto_the_shared_span(self, monkeypatch): + """trace.py hands ONE Span to every processor. If the commit SHA were + written onto span.data, a co-registered Agentex processor would + serialize it too, and it would surface in caller-visible span data.""" + from agentex.lib.core.tracing import code_revision + from agentex.lib.core.tracing.processors.sgp_tracing_processor import _sgp_metadata + from agentex.lib.core.tracing.processors.agentex_tracing_processor import _create_kwargs + + monkeypatch.setenv("AGENT_COMMIT_SHA", self.SHA) + code_revision.enable() + try: + span = _make_span(); span.data = {} + assert _sgp_metadata(span)["__commit_sha__"] == self.SHA # SGP sees it + assert "__commit_sha__" not in span.data # the span does not + assert "__commit_sha__" not in (_create_kwargs(span)["data"] or {}) + finally: + code_revision.disable() + + def test_list_shaped_data_is_left_alone(self, monkeypatch): + """`data` may be a list of dicts; there is nowhere to put a metadata key, + and dropping the caller's data would be worse than omitting the field.""" + from agentex.lib.core.tracing import code_revision + from agentex.lib.core.tracing.processors.sgp_tracing_processor import _sgp_metadata + + monkeypatch.setenv("AGENT_COMMIT_SHA", self.SHA) + code_revision.enable() + try: + span = _make_span(); span.data = [{"a": 1}] + assert _sgp_metadata(span) == [{"a": 1}] + finally: + code_revision.disable() + + def test_unset_identity_fields_are_omitted(self): + from agentex.lib.core.tracing.processors.sgp_tracing_processor import _add_source_to_span + + env = MagicMock(ACP_TYPE=None, AGENT_NAME=None, AGENT_ID=None, AGENT_VERSION=None) + span = _make_span() + _add_source_to_span(span, env) + assert span.data == {"__source__": "agentex"} + + +# --------------------------------------------------------------------------- +# Sync processor tests +# --------------------------------------------------------------------------- + + +class TestSGPSyncTracingProcessor: + @staticmethod + def _make_processor(): + mock_env = MagicMock() + mock_env.refresh.return_value = MagicMock(ACP_TYPE=None, AGENT_NAME=None, AGENT_ID=None, AGENT_VERSION=None) + mock_create_span = MagicMock(side_effect=lambda **kwargs: _make_mock_sgp_span()) + + with patch(f"{MODULE}.EnvironmentVariables", mock_env), patch(f"{MODULE}.SGPClient"), patch( + f"{MODULE}.tracing" + ), patch(f"{MODULE}.flush_queue"), patch(f"{MODULE}.create_span", mock_create_span): + from agentex.lib.core.tracing.processors.sgp_tracing_processor import ( + SGPSyncTracingProcessor, + ) + + processor = SGPSyncTracingProcessor(_make_config()) + + return processor, mock_create_span + + def test_processor_holds_no_per_span_state(self): + """Stateless processor must not retain any per-span dict between lifecycle events.""" + processor, _ = self._make_processor() + assert not hasattr(processor, "_spans") + + def test_span_lifecycle_produces_two_flushes(self, monkeypatch): + """With start writes enabled, each span produces one flush on start and one on end.""" + monkeypatch.setenv("AGENTEX_TRACING_SKIP_SPAN_START", "0") + processor, _ = self._make_processor() + + with patch(f"{MODULE}.create_span", side_effect=lambda **kw: _make_mock_sgp_span()) as mock_cs: + for _ in range(100): + span = _make_span() + processor.on_span_start(span) + span.end_time = datetime.now(UTC) + processor.on_span_end(span) + + # 100 spans × (1 start + 1 end) = 200 build calls. + assert mock_cs.call_count == 200 + + def test_span_end_without_prior_start_still_flushes(self): + """Cross-pod Temporal case: END activity lands on a pod that never saw START. + + Today this used to be a silent no-op. After the stateless refactor it + must still flush a complete span (start_time + end_time + payload). + """ + processor, _ = self._make_processor() + + captured_spans: list[MagicMock] = [] + + def capture_create_span(**kwargs): + sgp_span = _make_mock_sgp_span() + captured_spans.append(sgp_span) + return sgp_span + + with patch(f"{MODULE}.create_span", side_effect=capture_create_span): + span = _make_span() + span.end_time = datetime.now(UTC) + # No on_span_start — END lands here for the first time. + processor.on_span_end(span) + + assert len(captured_spans) == 1 + assert captured_spans[0].flush.called + assert captured_spans[0].start_time is not None + assert captured_spans[0].end_time is not None + + def test_span_start_skipped_by_default(self, monkeypatch): + """Default (end-only): on_span_start is a no-op; only on_span_end writes.""" + monkeypatch.delenv("AGENTEX_TRACING_SKIP_SPAN_START", raising=False) + processor, _ = self._make_processor() + + with patch(f"{MODULE}.create_span", side_effect=lambda **kw: _make_mock_sgp_span()) as mock_cs: + span = _make_span() + processor.on_span_start(span) + assert mock_cs.call_count == 0 # start skipped — nothing built or flushed + span.end_time = datetime.now(UTC) + processor.on_span_end(span) + + assert mock_cs.call_count == 1 # only the end write + + def test_span_start_emitted_when_skip_disabled(self, monkeypatch): + """With skip disabled, on_span_start builds and flushes a span.""" + monkeypatch.setenv("AGENTEX_TRACING_SKIP_SPAN_START", "0") + processor, _ = self._make_processor() + + captured: list[MagicMock] = [] + + def capture(**kwargs): + sgp_span = _make_mock_sgp_span() + captured.append(sgp_span) + return sgp_span + + with patch(f"{MODULE}.create_span", side_effect=capture): + processor.on_span_start(_make_span()) + + assert len(captured) == 1 + assert captured[0].flush.called + + +# --------------------------------------------------------------------------- +# Async processor tests +# --------------------------------------------------------------------------- + + +class TestSGPAsyncTracingProcessor: + @staticmethod + def _make_processor(): + mock_env = MagicMock() + mock_env.refresh.return_value = MagicMock(ACP_TYPE=None, AGENT_NAME=None, AGENT_ID=None, AGENT_VERSION=None) + mock_create_span = MagicMock(side_effect=lambda **kwargs: _make_mock_sgp_span()) + + mock_async_client = MagicMock() + mock_async_client.spans.upsert_batch = AsyncMock() + + with patch(f"{MODULE}.EnvironmentVariables", mock_env), patch(f"{MODULE}.create_span", mock_create_span), patch( + f"{MODULE}.AsyncSGPClient", return_value=mock_async_client + ): + from agentex.lib.core.tracing.processors.sgp_tracing_processor import ( + SGPAsyncTracingProcessor, + ) + + processor = SGPAsyncTracingProcessor(_make_config()) + + # Force the per-loop cache to return the mock for whatever loop the + # test runs on, by stubbing _get_client directly. + processor._get_client = lambda: mock_async_client # type: ignore[method-assign] + + return processor, mock_create_span, mock_async_client + + def test_processor_holds_no_per_span_state(self): + """Stateless processor must not retain any per-span dict between lifecycle events.""" + processor, _, _ = self._make_processor() + assert not hasattr(processor, "_spans") + + async def test_span_lifecycle_produces_two_upserts(self, monkeypatch): + """With start writes enabled, each span produces one upsert on start and one on end.""" + monkeypatch.setenv("AGENTEX_TRACING_SKIP_SPAN_START", "0") + processor, _, mock_client = self._make_processor() + + with patch(f"{MODULE}.create_span", side_effect=lambda **kw: _make_mock_sgp_span()): + span = _make_span() + await processor.on_span_start(span) + span.end_time = datetime.now(UTC) + await processor.on_span_end(span) + + assert mock_client.spans.upsert_batch.call_count == 2 + + async def test_spans_start_skipped_by_default(self, monkeypatch): + """Default (end-only): on_spans_start makes no upsert; on_spans_end does.""" + monkeypatch.delenv("AGENTEX_TRACING_SKIP_SPAN_START", raising=False) + processor, _, mock_client = self._make_processor() + + with patch(f"{MODULE}.create_span", side_effect=lambda **kw: _make_mock_sgp_span()): + spans = [_make_span() for _ in range(3)] + await processor.on_spans_start(spans) + assert mock_client.spans.upsert_batch.call_count == 0 # start skipped + for s in spans: + s.end_time = datetime.now(UTC) + await processor.on_spans_end(spans) + + assert mock_client.spans.upsert_batch.call_count == 1 # only the end write + + async def test_spans_start_emitted_when_skip_disabled(self, monkeypatch): + """With skip disabled, on_spans_start makes one upsert_batch call.""" + monkeypatch.setenv("AGENTEX_TRACING_SKIP_SPAN_START", "0") + processor, _, mock_client = self._make_processor() + + with patch(f"{MODULE}.create_span", side_effect=lambda **kw: _make_mock_sgp_span()): + await processor.on_spans_start([_make_span()]) + + assert mock_client.spans.upsert_batch.call_count == 1 + + async def test_span_end_without_prior_start_still_upserts(self): + """Cross-pod Temporal case: END activity lands on a pod that never saw START. + + Today this used to be a silent no-op. After the stateless refactor it + must still upsert a complete span via upsert_batch. + """ + processor, _, mock_client = self._make_processor() + + with patch(f"{MODULE}.create_span", side_effect=lambda **kw: _make_mock_sgp_span()): + span = _make_span() + span.end_time = datetime.now(UTC) + # No on_span_start — END lands here for the first time. + await processor.on_span_end(span) + + assert mock_client.spans.upsert_batch.call_count == 1 + items = mock_client.spans.upsert_batch.call_args.kwargs["items"] + assert len(items) == 1 + + async def test_sgp_span_input_and_output_propagated_on_end(self, monkeypatch): + """on_span_end should send the span's current input and output via upsert_batch.""" + monkeypatch.setenv("AGENTEX_TRACING_SKIP_SPAN_START", "0") + processor, _, mock_client = self._make_processor() + + captured: list[MagicMock] = [] + + def capture_create_span(**kwargs): + sgp_span = _make_mock_sgp_span() + captured.append(sgp_span) + return sgp_span + + mock_create_span = MagicMock(side_effect=capture_create_span) + with patch(f"{MODULE}.create_span", mock_create_span): + span = _make_span() + span.input = {"messages": [{"role": "user", "content": "hello"}]} + await processor.on_span_start(span) + + span.input = { + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ] + } + span.output = {"response": "hi"} + span.end_time = datetime.now(UTC) + await processor.on_span_end(span) + + assert mock_client.spans.upsert_batch.call_count == 2 # start + end + # The end-time SGPSpan should have end_time populated. + end_span = captured[-1] + assert end_span.end_time is not None + # Verify the updated input/output reached create_span on the end call. + end_call_kwargs = mock_create_span.call_args_list[-1].kwargs + assert end_call_kwargs["input"]["messages"][-1]["role"] == "assistant" + assert end_call_kwargs["output"] == {"response": "hi"} + + async def test_on_spans_start_sends_single_upsert_for_batch(self, monkeypatch): + """Given N spans at once, on_spans_start should make ONE upsert_batch HTTP call.""" + monkeypatch.setenv("AGENTEX_TRACING_SKIP_SPAN_START", "0") + processor, _, mock_client = self._make_processor() + + n = 10 + spans = [_make_span() for _ in range(n)] + with patch(f"{MODULE}.create_span", side_effect=lambda **kw: _make_mock_sgp_span()): + await processor.on_spans_start(spans) + + assert mock_client.spans.upsert_batch.call_count == 1, ( + "Batched on_spans_start must make exactly one upsert_batch HTTP call" + ) + items = mock_client.spans.upsert_batch.call_args.kwargs["items"] + assert len(items) == n + + async def test_on_spans_start_records_export_success_metrics(self, monkeypatch): + monkeypatch.setenv("AGENTEX_TRACING_METRICS", "1") + monkeypatch.setenv("AGENTEX_TRACING_SKIP_SPAN_START", "0") + import agentex.lib.core.observability.tracing_metrics_recording as recording + + recording._metrics_enabled = None + recording._tracing = None + processor, _, mock_client = self._make_processor() + mock_metrics = MagicMock() + + n = 4 + spans = [_make_span() for _ in range(n)] + with patch(f"{MODULE}.create_span", side_effect=lambda **kw: _make_mock_sgp_span()), patch( + "agentex.lib.core.observability.tracing_metrics.get_tracing_metrics", + return_value=mock_metrics, + ): + await processor.on_spans_start(spans) + + mock_metrics.export_batches.add.assert_called_once_with( + 1, + {"processor": "sgp", "event_type": "start"}, + ) + mock_metrics.export_spans.add.assert_called_once_with( + n, + {"processor": "sgp", "event_type": "start"}, + ) + assert mock_client.spans.upsert_batch.call_count == 1 + + async def test_get_client_caches_per_event_loop(self): + """The processor must keep one client per event loop, and reuse it + across calls within the same loop. This is what enables connection + keepalive instead of paying a TLS handshake per span. + """ + mock_env = MagicMock() + mock_env.refresh.return_value = MagicMock(ACP_TYPE=None, AGENT_NAME=None, AGENT_ID=None, AGENT_VERSION=None) + + with patch(f"{MODULE}.EnvironmentVariables", mock_env), patch(f"{MODULE}.AsyncSGPClient") as mock_sgp_cls: + mock_sgp_cls.side_effect = lambda **kwargs: MagicMock() + + from agentex.lib.core.tracing.processors.sgp_tracing_processor import ( + SGPAsyncTracingProcessor, + ) + + processor = SGPAsyncTracingProcessor(_make_config()) + + # Construction should NOT eagerly build the client (no running + # loop guarantee at import time). + assert mock_sgp_cls.call_count == 0 + + c1 = processor._get_client() + c2 = processor._get_client() + c3 = processor._get_client() + + # First call builds the client; subsequent calls in the same + # loop return the cached one. + assert mock_sgp_cls.call_count == 1, ( + f"Expected client to be built once per loop, but AsyncSGPClient " + f"was called {mock_sgp_cls.call_count} times" + ) + assert c1 is c2 is c3 + + async def test_get_client_keepalive_is_enabled(self): + """Regression guard: the per-loop client must use keepalive (the whole + point of the per-loop cache). Verify max_keepalive_connections > 0. + """ + import httpx as _httpx + + captured_limits: list[_httpx.Limits] = [] + + original_async_client = _httpx.AsyncClient + + def capture_limits(*args, **kwargs): + limits = kwargs.get("limits") + if limits is not None: + captured_limits.append(limits) + return original_async_client(*args, **kwargs) + + mock_env = MagicMock() + mock_env.refresh.return_value = MagicMock(ACP_TYPE=None, AGENT_NAME=None, AGENT_ID=None, AGENT_VERSION=None) + + with patch(f"{MODULE}.EnvironmentVariables", mock_env), patch(f"{MODULE}.AsyncSGPClient"), patch( + "httpx.AsyncClient", side_effect=capture_limits + ): + from agentex.lib.core.tracing.processors.sgp_tracing_processor import ( + SGPAsyncTracingProcessor, + ) + + processor = SGPAsyncTracingProcessor(_make_config()) + processor._get_client() + + assert len(captured_limits) == 1 + max_keepalive = captured_limits[0].max_keepalive_connections + assert max_keepalive is not None and max_keepalive > 0, ( + f"SGP async client should have keepalive enabled, got max_keepalive_connections={max_keepalive}" + ) + + def test_cache_is_weakkeydict_and_evicts_dead_loops(self): + """Regression guard for the id()-reuse bug: the per-loop cache must + be a WeakKeyDictionary so a GC'd loop's entry is evicted. Otherwise + a new loop landing at the same memory address would reuse the dead + loop's client, reintroducing the "bound to a different event loop" + error the per-loop cache was built to prevent. + """ + import gc + import weakref + + mock_env = MagicMock() + mock_env.refresh.return_value = MagicMock(ACP_TYPE=None, AGENT_NAME=None, AGENT_ID=None, AGENT_VERSION=None) + + with patch(f"{MODULE}.EnvironmentVariables", mock_env), patch(f"{MODULE}.AsyncSGPClient"): + from agentex.lib.core.tracing.processors.sgp_tracing_processor import ( + SGPAsyncTracingProcessor, + ) + + processor = SGPAsyncTracingProcessor(_make_config()) + + # Storage type itself: WeakKeyDictionary, not plain dict. + assert isinstance(processor._clients_by_loop, weakref.WeakKeyDictionary) + + # End-to-end check: insert under a loop, drop the loop, the entry + # must vanish after GC. + loop = asyncio.new_event_loop() + try: + processor._clients_by_loop[loop] = MagicMock() + assert len(processor._clients_by_loop) == 1 + finally: + loop.close() + del loop + gc.collect() + assert len(processor._clients_by_loop) == 0, ( + "WeakKeyDictionary should have evicted the dead loop's entry; " + "remaining keys would cause stale-client reuse on id() recycling." + ) + + async def test_disabled_processor_returns_none_client(self): + """When config is missing api_key/account_id, _get_client must return + None and no HTTP client must be constructed.""" + from agentex.lib.types.tracing import SGPTracingProcessorConfig + + mock_env = MagicMock() + mock_env.refresh.return_value = MagicMock(ACP_TYPE=None, AGENT_NAME=None, AGENT_ID=None, AGENT_VERSION=None) + + with patch(f"{MODULE}.EnvironmentVariables", mock_env), patch(f"{MODULE}.AsyncSGPClient") as mock_sgp_cls: + from agentex.lib.core.tracing.processors.sgp_tracing_processor import ( + SGPAsyncTracingProcessor, + ) + + processor = SGPAsyncTracingProcessor(SGPTracingProcessorConfig(sgp_api_key="", sgp_account_id="")) + + assert processor._get_client() is None + assert mock_sgp_cls.call_count == 0 + + async def test_on_spans_end_sends_single_upsert_for_batch(self): + """Given N spans at once, on_spans_end should make ONE upsert_batch HTTP call.""" + processor, _, mock_client = self._make_processor() + + n = 10 + spans = [_make_span() for _ in range(n)] + with patch(f"{MODULE}.create_span", side_effect=lambda **kw: _make_mock_sgp_span()): + await processor.on_spans_start(spans) + + mock_client.spans.upsert_batch.reset_mock() + + for span in spans: + span.end_time = datetime.now(UTC) + await processor.on_spans_end(spans) + + assert mock_client.spans.upsert_batch.call_count == 1, ( + "Batched on_spans_end must make exactly one upsert_batch HTTP call" + ) + items = mock_client.spans.upsert_batch.call_args.kwargs["items"] + assert len(items) == n + + +# --------------------------------------------------------------------------- +# AGENTEX_TRACING_SKIP_SPAN_START env parsing +# --------------------------------------------------------------------------- + + +class TestSkipSpanStartEnv: + @staticmethod + def _fn(): + from agentex.lib.core.tracing.processors.sgp_tracing_processor import ( + _skip_span_start_enabled, + ) + + return _skip_span_start_enabled + + def test_default_is_skip_enabled(self, monkeypatch): + """Unset → skip span-start (end-only ingest is the default).""" + monkeypatch.delenv("AGENTEX_TRACING_SKIP_SPAN_START", raising=False) + assert self._fn()() is True + + @pytest.mark.parametrize("val", ["0", "false", "no", "off", "FALSE", "Off", " no "]) + def test_falsy_values_restore_span_start(self, monkeypatch, val): + monkeypatch.setenv("AGENTEX_TRACING_SKIP_SPAN_START", val) + assert self._fn()() is False + + @pytest.mark.parametrize("val", ["1", "true", "yes", "on", "anything"]) + def test_other_values_keep_skip_enabled(self, monkeypatch, val): + monkeypatch.setenv("AGENTEX_TRACING_SKIP_SPAN_START", val) + assert self._fn()() is True diff --git a/tests/lib/core/tracing/processors/test_tracing_processor_interface.py b/tests/lib/core/tracing/processors/test_tracing_processor_interface.py new file mode 100644 index 000000000..12847b70d --- /dev/null +++ b/tests/lib/core/tracing/processors/test_tracing_processor_interface.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import uuid +import logging +from typing import override +from datetime import UTC, datetime + +from agentex.types.span import Span +from agentex.lib.types.tracing import TracingProcessorConfig +from agentex.lib.core.tracing.processors.tracing_processor_interface import ( + AsyncTracingProcessor, +) + + +def _make_span(span_id: str | None = None) -> Span: + return Span( + id=span_id or str(uuid.uuid4()), + name="test-span", + start_time=datetime.now(UTC), + trace_id="trace-1", + ) + + +class _RecordingProcessor(AsyncTracingProcessor): + """Test processor that records every on_span_* call and fails on demand.""" + + def __init__(self, fail_ids: set[str] | None = None) -> None: + self.started_ids: list[str] = [] + self.ended_ids: list[str] = [] + self._fail_ids = fail_ids or set() + + @override + async def on_span_start(self, span: Span) -> None: + self.started_ids.append(span.id) + if span.id in self._fail_ids: + raise RuntimeError(f"boom-start-{span.id}") + + @override + async def on_span_end(self, span: Span) -> None: + self.ended_ids.append(span.id) + if span.id in self._fail_ids: + raise RuntimeError(f"boom-end-{span.id}") + + @override + async def shutdown(self) -> None: + pass + + +class TestDefaultBatchedFanout: + """The default on_spans_start / on_spans_end in AsyncTracingProcessor must: + - dispatch to the single-span method for every span + - continue after individual failures (not short-circuit) + - log each failure individually + - not propagate exceptions to the caller + """ + + async def test_on_spans_start_runs_every_span_despite_failures(self, caplog): + proc = _RecordingProcessor(fail_ids={"span-1"}) + spans = [_make_span(f"span-{i}") for i in range(3)] + + with caplog.at_level(logging.ERROR): + # Must not raise, even though span-1 fails. + await proc.on_spans_start(spans) + + # Every span's on_span_start was invoked + assert proc.started_ids == ["span-0", "span-1", "span-2"] + + async def test_on_spans_start_logs_each_failure(self, caplog): + proc = _RecordingProcessor(fail_ids={"span-0", "span-2"}) + spans = [_make_span(f"span-{i}") for i in range(3)] + + with caplog.at_level(logging.ERROR): + await proc.on_spans_start(spans) + + # Two distinct error log records, one per failing span + error_records = [r for r in caplog.records if r.levelno == logging.ERROR] + messages = " ".join(r.getMessage() for r in error_records) + assert "span-0" in messages + assert "span-2" in messages + + async def test_on_spans_end_runs_every_span_despite_failures(self, caplog): + proc = _RecordingProcessor(fail_ids={"span-1"}) + spans = [_make_span(f"span-{i}") for i in range(3)] + + with caplog.at_level(logging.ERROR): + await proc.on_spans_end(spans) + + assert proc.ended_ids == ["span-0", "span-1", "span-2"] + + async def test_dummy_config_construction(self): + """AsyncTracingProcessor's __init__ is abstract — verify concrete + subclass above satisfies the interface.""" + _ = TracingProcessorConfig + proc = _RecordingProcessor() + await proc.on_spans_start([]) + await proc.on_spans_end([]) + assert proc.started_ids == [] + assert proc.ended_ids == [] diff --git a/tests/lib/core/tracing/test_code_revision.py b/tests/lib/core/tracing/test_code_revision.py new file mode 100644 index 000000000..0b89b88f2 --- /dev/null +++ b/tests/lib/core/tracing/test_code_revision.py @@ -0,0 +1,109 @@ +"""Opt-in commit-SHA stamping. + +The contract that matters: an agent that does not call ``enable()`` gets nothing, +so upgrading the SDK never starts emitting this field on its own. +""" + +from __future__ import annotations + +import pytest + +from agentex.lib.core.tracing import code_revision + +SHA = "b362b171a9c4e1f09d8e7a6b5c4d3e2f1a0b9c8d" + + +@pytest.fixture(autouse=True) +def _reset(): + """State is process-wide (like the lineage registry), so isolate each test.""" + code_revision.disable() + yield + code_revision.disable() + + +class TestOptIn: + def test_disabled_by_default(self, monkeypatch): + """Even with the env fully populated, nothing resolves until enable().""" + monkeypatch.setenv("AGENT_COMMIT_SHA", SHA) + monkeypatch.setenv("AGENT_VERSION", SHA) + assert code_revision.commit_sha() is None + assert code_revision.is_enabled() is False + + def test_enable_reads_agent_commit_sha(self, monkeypatch): + monkeypatch.setenv("AGENT_COMMIT_SHA", SHA) + code_revision.enable() + assert code_revision.commit_sha() == SHA + assert code_revision.is_enabled() is True + + def test_explicit_argument_wins(self, monkeypatch): + monkeypatch.setenv("AGENT_COMMIT_SHA", SHA) + code_revision.enable("7f3a91c2") + assert code_revision.commit_sha() == "7f3a91c2" + + def test_disable_turns_it_back_off(self, monkeypatch): + monkeypatch.setenv("AGENT_COMMIT_SHA", SHA) + code_revision.enable() + code_revision.disable() + assert code_revision.commit_sha() is None + + +class TestValueIsAlwaysACommit: + """A field named for a commit must never hold an image tag.""" + + @pytest.mark.parametrize( + "value", + [ + "latest", + "v1.2.3", + "0.2.4-v4", + "rocket_mock_agent-b362b171a9c4e1f09d8e7a6b5c4d3e2f1a0b9c8d", # AWS ECR composite + "abc", # shorter than git's 7-char minimum + "z" * 40, # right length, not hex + ], + ) + def test_non_sha_is_refused(self, monkeypatch, value): + monkeypatch.setenv("AGENT_COMMIT_SHA", value) + code_revision.enable() + assert code_revision.commit_sha() is None + + @pytest.mark.parametrize("value", [SHA, SHA.upper(), "b362b17", "a" * 64]) + def test_git_object_names_are_accepted(self, monkeypatch, value): + monkeypatch.setenv("AGENT_COMMIT_SHA", value) + code_revision.enable() + assert code_revision.commit_sha() == value + + def test_whitespace_only_is_refused(self, monkeypatch): + monkeypatch.setenv("AGENT_COMMIT_SHA", " ") + code_revision.enable() + assert code_revision.commit_sha() is None + + def test_enable_with_nothing_available_is_a_no_op(self, monkeypatch): + monkeypatch.delenv("AGENT_COMMIT_SHA", raising=False) + monkeypatch.delenv("AGENT_VERSION", raising=False) + code_revision.enable() + assert code_revision.commit_sha() is None + + +class TestAgentVersionFallback: + def test_falls_back_to_agent_version_when_sha_shaped(self, monkeypatch): + """A platform deploy already sets AGENT_VERSION; on GCP/Azure it is a + bare SHA, so an opting-in agent needs no extra plumbing.""" + monkeypatch.delenv("AGENT_COMMIT_SHA", raising=False) + monkeypatch.setenv("AGENT_VERSION", SHA) + code_revision.enable() + assert code_revision.commit_sha() == SHA + + def test_does_not_fall_back_to_a_non_sha_agent_version(self, monkeypatch): + """AGENT_VERSION is 'latest' or an AWS composite much of the time.""" + monkeypatch.delenv("AGENT_COMMIT_SHA", raising=False) + monkeypatch.setenv("AGENT_VERSION", "latest") + code_revision.enable() + assert code_revision.commit_sha() is None + + def test_bad_explicit_value_does_not_fall_through(self, monkeypatch): + """An explicit AGENT_COMMIT_SHA is a statement of intent: if it is wrong, + say so rather than silently substituting the image tag.""" + monkeypatch.setenv("AGENT_COMMIT_SHA", "not-a-sha") + monkeypatch.setenv("AGENT_VERSION", SHA) + code_revision.enable() + assert code_revision.commit_sha() is None diff --git a/tests/lib/core/tracing/test_lineage.py b/tests/lib/core/tracing/test_lineage.py new file mode 100644 index 000000000..c0fc3ebb9 --- /dev/null +++ b/tests/lib/core/tracing/test_lineage.py @@ -0,0 +1,147 @@ +"""Unit tests for the data-source ref module (sgp.lineage.refs capture).""" + +import json + +import pytest +from pydantic import ValidationError + +from agentex.lib.core.tracing.lineage import ( + LINEAGE_REFS_KEY, + DataSourceRef, + record, + data_sources, + resolve_refs, + clear_tool_sources, + merge_refs_into_data, + register_tool_sources, + resolve_refs_from_items, +) + + +@pytest.fixture(autouse=True) +def _clean_registry(): + clear_tool_sources() + yield + clear_tool_sources() + + +ES_REF = DataSourceRef("elasticsearch://ey-embryonic", "companies_v3") +DBX_REF = DataSourceRef("databricks://ey-tax", "guidance.rulings", role="input") + + +class TestDataSourceRef: + def test_positional_construction(self): + ref = DataSourceRef("s3://bucket", "key", version="v1", role="output") + assert ref.namespace == "s3://bucket" + assert ref.name == "key" + assert ref.version == "v1" + assert ref.role == "output" + + def test_non_uri_namespace_rejected(self): + with pytest.raises(ValidationError): + DataSourceRef("not-a-uri", "name") + + def test_underscore_host_rejected(self): + with pytest.raises(ValidationError): + DataSourceRef("mcp://ey_tax_server", "competitive-edge") + + def test_host_with_path_segment_allowed(self): + DataSourceRef("confluence://ey-tax/TAX", "page-123") + + def test_empty_name_rejected(self): + with pytest.raises(ValidationError): + DataSourceRef("s3://bucket", "") + + def test_bad_role_rejected(self): + with pytest.raises(ValidationError): + DataSourceRef("s3://bucket", "key", role="sideways") + + +class TestRegistryAndResolve: + def test_unregistered_tool_resolves_empty(self): + assert resolve_refs("unknown_tool", {}) == [] + + def test_static_refs(self): + register_tool_sources("search", refs=[ES_REF]) + refs = resolve_refs("search", {"q": "acme"}) + assert refs == [{"namespace": "elasticsearch://ey-embryonic", "name": "companies_v3", "role": "input"}] + + def test_resolver_refs_combined_with_static(self): + register_tool_sources( + "query_table", + refs=[ES_REF], + resolver=lambda args: [DataSourceRef("databricks://ey-tax", args["table"])], + ) + refs = resolve_refs("query_table", {"table": "guidance.rulings"}) + assert {r["namespace"] for r in refs} == {"elasticsearch://ey-embryonic", "databricks://ey-tax"} + + def test_resolver_failure_keeps_static_refs(self): + register_tool_sources("flaky", refs=[ES_REF], resolver=lambda args: args["missing"]) + refs = resolve_refs("flaky", {}) + assert len(refs) == 1 + + def test_reregistration_replaces(self): + register_tool_sources("search", refs=[ES_REF]) + register_tool_sources("search", refs=[DBX_REF]) + assert resolve_refs("search", {})[0]["namespace"] == "databricks://ey-tax" + + def test_dedupe(self): + register_tool_sources("search", refs=[ES_REF, ES_REF]) + assert len(resolve_refs("search", {})) == 1 + + +class TestDecorator: + def test_registers_by_function_name(self): + @data_sources(ES_REF) + def search_companies(q: str) -> str: + return q + + assert search_companies("x") == "x" + assert resolve_refs("search_companies", {}) != [] + + def test_registers_by_name_attribute(self): + class FakeFunctionTool: + name = "mcp_search" + + data_sources(DBX_REF)(FakeFunctionTool()) + assert resolve_refs("mcp_search", {}) != [] + + +class TestResolveFromItems: + def test_matches_function_call_items_and_parses_string_arguments(self): + register_tool_sources( + "query_table", + resolver=lambda args: [DataSourceRef("databricks://ey-tax", args["table"])], + ) + items = [ + {"type": "message", "content": []}, + {"type": "function_call", "name": "query_table", "arguments": json.dumps({"table": "t1"})}, + {"type": "function_call", "name": "unregistered", "arguments": "{}"}, + "not-a-dict", + ] + refs = resolve_refs_from_items(items) + assert refs == [{"namespace": "databricks://ey-tax", "name": "t1", "role": "input"}] + + def test_malformed_arguments_fall_back_to_static(self): + register_tool_sources("search", refs=[ES_REF]) + items = [{"type": "function_call", "name": "search", "arguments": "{not json"}] + assert len(resolve_refs_from_items(items)) == 1 + + +class TestRecordAndMerge: + def test_record_on_none_span_is_noop(self): + record(None, [ES_REF]) + + def test_record_merges_into_span_data(self): + class Span: + data = {"__span_type__": "CUSTOM"} + + span = Span() + record(span, [ES_REF]) + assert span.data["__span_type__"] == "CUSTOM" + assert span.data[LINEAGE_REFS_KEY][0]["name"] == "companies_v3" + + def test_merge_dedupes_against_existing(self): + data = merge_refs_into_data(None, [ES_REF.model_dump(exclude_none=True)]) + data = merge_refs_into_data(data, [ES_REF.model_dump(exclude_none=True)]) + assert len(data[LINEAGE_REFS_KEY]) == 1 diff --git a/tests/lib/core/tracing/test_obs_ids.py b/tests/lib/core/tracing/test_obs_ids.py new file mode 100644 index 000000000..5cdeb81b8 --- /dev/null +++ b/tests/lib/core/tracing/test_obs_ids.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import sys +import types +from typing import Any + +import pytest + +from agentex.lib.core.tracing import obs_ids +from agentex.lib.core.tracing.obs_ids import get_obs_mode, obs_correlation + + +class TestGetObsMode: + @pytest.mark.parametrize( + "raw, expected", + [ + (None, "dd_only"), # unset + ("", "dd_only"), # empty + ("dd_only", "dd_only"), + ("lgtm", "lgtm"), + ("LGTM", "lgtm"), # case-insensitive + (" lgtm ", "lgtm"), # trimmed + ("dual", "dd_only"), # removed mode -> safe degrade + ("garbage", "dd_only"), # unrecognized -> safe degrade + ], + ) + def test_mode_resolution(self, monkeypatch, raw, expected): + if raw is None: + monkeypatch.delenv("SGP_OBS_MODE", raising=False) + else: + monkeypatch.setenv("SGP_OBS_MODE", raw) + assert get_obs_mode() == expected + + +class TestObsCorrelation: + def test_lgtm_mode_reads_otel_and_emits_underscored_keys(self, monkeypatch): + monkeypatch.setenv("SGP_OBS_MODE", "lgtm") + monkeypatch.setattr(obs_ids, "_lgtm_ids", lambda: ("otel_trace", "otel_span")) + # In lgtm mode ddtrace must NOT be consulted. + monkeypatch.setattr(obs_ids, "_ddtrace_ids", lambda: pytest.fail("ddtrace read in lgtm mode")) + + assert obs_correlation() == { + "obs_trace_id": "otel_trace", + "obs_span_id": "otel_span", + } + + def test_dd_only_mode_reads_ddtrace(self, monkeypatch): + monkeypatch.setenv("SGP_OBS_MODE", "dd_only") + monkeypatch.setattr(obs_ids, "_ddtrace_ids", lambda: ("dd_trace", "dd_span")) + monkeypatch.setattr(obs_ids, "_lgtm_ids", lambda: pytest.fail("otel read in dd_only mode")) + + assert obs_correlation() == { + "obs_trace_id": "dd_trace", + "obs_span_id": "dd_span", + } + + def test_stale_dual_degrades_to_ddtrace(self, monkeypatch): + """A leftover SGP_OBS_MODE=dual must behave as dd_only, not read OTel.""" + monkeypatch.setenv("SGP_OBS_MODE", "dual") + monkeypatch.setattr(obs_ids, "_ddtrace_ids", lambda: ("dd_trace", "dd_span")) + monkeypatch.setattr(obs_ids, "_lgtm_ids", lambda: pytest.fail("otel read for stale dual mode")) + + assert obs_correlation() == { + "obs_trace_id": "dd_trace", + "obs_span_id": "dd_span", + } + + def test_no_active_context_returns_empty(self, monkeypatch): + monkeypatch.delenv("SGP_OBS_MODE", raising=False) # dd_only + monkeypatch.setattr(obs_ids, "_ddtrace_ids", lambda: None) + + assert obs_correlation() == {} + + def test_resolver_exception_is_swallowed(self, monkeypatch): + """A misbehaving tracer must not propagate out of obs_correlation.""" + monkeypatch.delenv("SGP_OBS_MODE", raising=False) # dd_only + + def boom(): + raise RuntimeError("tracer blew up") + + monkeypatch.setattr(obs_ids, "_ddtrace_ids", boom) + assert obs_correlation() == {} + + +class TestIdFormatting: + """Pin the W3C hex shape (32-hex trace, 16-hex span) of the resolvers.""" + + def test_ddtrace_ids_formats_w3c_hex(self, monkeypatch): + ctx = types.SimpleNamespace(trace_id=0xABC, span_id=0xFF) + tracer = types.SimpleNamespace(current_trace_context=lambda: ctx) + fake_ddtrace: Any = types.ModuleType("ddtrace") + fake_trace: Any = types.ModuleType("ddtrace.trace") + fake_trace.tracer = tracer + monkeypatch.setitem(sys.modules, "ddtrace", fake_ddtrace) + monkeypatch.setitem(sys.modules, "ddtrace.trace", fake_trace) + + result = obs_ids._ddtrace_ids() + assert result is not None + trace_id, span_id = result + assert trace_id == "00000000000000000000000000000abc" + assert span_id == "000000000000000000ff"[-16:] # 16-hex + assert len(trace_id) == 32 and len(span_id) == 16 + + def test_lgtm_ids_formats_w3c_hex(self, monkeypatch): + span_ctx = types.SimpleNamespace(trace_id=0xABC, span_id=0xFF, is_valid=True) + current_span = types.SimpleNamespace(get_span_context=lambda: span_ctx) + fake_trace_mod = types.SimpleNamespace(get_current_span=lambda: current_span) + fake_otel: Any = types.ModuleType("opentelemetry") + fake_otel.trace = fake_trace_mod + monkeypatch.setitem(sys.modules, "opentelemetry", fake_otel) + + result = obs_ids._lgtm_ids() + assert result is not None + trace_id, span_id = result + assert trace_id == "00000000000000000000000000000abc" + assert len(trace_id) == 32 and len(span_id) == 16 + + def test_ddtrace_ids_none_when_no_context(self, monkeypatch): + tracer = types.SimpleNamespace(current_trace_context=lambda: None) + fake_ddtrace: Any = types.ModuleType("ddtrace") + fake_trace: Any = types.ModuleType("ddtrace.trace") + fake_trace.tracer = tracer + monkeypatch.setitem(sys.modules, "ddtrace", fake_ddtrace) + monkeypatch.setitem(sys.modules, "ddtrace.trace", fake_trace) + + assert obs_ids._ddtrace_ids() is None diff --git a/tests/lib/core/tracing/test_obs_span.py b/tests/lib/core/tracing/test_obs_span.py new file mode 100644 index 000000000..a9c5739bc --- /dev/null +++ b/tests/lib/core/tracing/test_obs_span.py @@ -0,0 +1,516 @@ +from __future__ import annotations + +import sys +import types +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from agentex.lib.core.tracing import trace as trace_module, obs_span +from agentex.lib.core.tracing.trace import Trace + + +@pytest.fixture(autouse=True) +def _clear_obs_handles(): + """The obs-handle registry is module-level (survives across Trace instances, + which is the whole point of the fix). Clear it around each test so leftover + handles never leak between tests.""" + trace_module._OBS_HANDLES.clear() + yield + trace_module._OBS_HANDLES.clear() + + +# --------------------------------------------------------------------------- # +# Fake OTel (lgtm) and fake ddtrace (dd_only) SDKs injected via sys.modules. +# --------------------------------------------------------------------------- # +class _FakeSpanContext: + def __init__(self, trace_id: int, span_id: int, is_valid: bool = True): + self.trace_id = trace_id + self.span_id = span_id + self.is_valid = is_valid + + +class _FakeStatusCode: + ERROR = "ERROR" + OK = "OK" + UNSET = "UNSET" + + +def _FakeStatus(code, description=None): + return {"code": code, "description": description} + + +class _FakeOtelSpan: + def __init__(self, name: str, trace_id: int, span_id: int): + self.name = name + self._ctx = _FakeSpanContext(trace_id, span_id) + self.ended = False + self.attributes: dict = {} + self.status = None + + def set_attribute(self, key, value): + self.attributes[key] = value + + def set_status(self, status): + self.status = status + + def get_span_context(self): + return self._ctx + + def end(self): + self.ended = True + + +def _install_fake_otel(monkeypatch, *, trace_id=0xABC, span_id=0xFF): + record: dict[str, Any] = {"span": None, "attached": [], "detached": []} + + def start_span(name): + span = _FakeOtelSpan(name, trace_id, span_id) + record["span"] = span + return span + + tracer = types.SimpleNamespace(start_span=start_span) + fake_trace = types.SimpleNamespace( + get_tracer=lambda _name: tracer, + set_span_in_context=lambda span: {"span": span}, + Status=_FakeStatus, + StatusCode=_FakeStatusCode, + ) + fake_context = types.SimpleNamespace( + attach=lambda ctx: record["attached"].append(ctx) or object(), + detach=lambda token: record["detached"].append(token), + ) + fake_otel: Any = types.ModuleType("opentelemetry") + fake_otel.trace = fake_trace + fake_otel.context = fake_context + monkeypatch.setitem(sys.modules, "opentelemetry", fake_otel) + return record + + +class _FakeDDSpan: + def __init__(self, name: str, trace_id: int, span_id: int): + self.name = name + self.trace_id = trace_id + self.span_id = span_id + self.finished = False + self.error = 0 + self.tags: dict = {} + + def set_tag(self, key, value): + self.tags[key] = value + + def finish(self): + self.finished = True + + +def _install_fake_ddtrace(monkeypatch, *, active=True, trace_id=0xABC, span_id=0xFF): + record: dict[str, Any] = {"span": None, "started": []} + ctx_obj = object() if active else None + record["ctx"] = ctx_obj + + def start_span(name, child_of=None, activate=False): + span = _FakeDDSpan(name, trace_id, span_id) + record["span"] = span + record["started"].append({"name": name, "child_of": child_of, "activate": activate}) + return span + + tracer = types.SimpleNamespace( + current_trace_context=lambda: ctx_obj, + start_span=start_span, + ) + fake_ddtrace: Any = types.ModuleType("ddtrace") + fake_trace: Any = types.ModuleType("ddtrace.trace") + fake_trace.tracer = tracer + monkeypatch.setitem(sys.modules, "ddtrace", fake_ddtrace) + monkeypatch.setitem(sys.modules, "ddtrace.trace", fake_trace) + return record + + +# --------------------------------------------------------------------------- # +# lgtm -> OTel wrapper +# --------------------------------------------------------------------------- # +class TestOtelWrapper: + def test_lgtm_opens_named_span_and_reads_its_ids(self, monkeypatch): + monkeypatch.setenv("SGP_OBS_MODE", "lgtm") + record = _install_fake_otel(monkeypatch, trace_id=0xABC, span_id=0xFF) + + handle = obs_span.open_obs_span("rocket.tool.fetch", business_span_id="bspan-1", business_trace_id="btrace-1") + + assert handle is not None + assert record["span"].name == "rocket.tool.fetch" # named for the step + assert len(record["attached"]) == 1 # made active + assert handle.correlation == { + "obs_trace_id": "00000000000000000000000000000abc", + "obs_span_id": "000000000000000000ff"[-16:], + } + # reverse tag: business ids stamped on the obs span + assert record["span"].attributes == { + "agentex.business_span_id": "bspan-1", + "agentex.business_trace_id": "btrace-1", + } + + def test_invalid_span_context_returns_none_for_fallback(self, monkeypatch): + """Invalid wrapper context (proxy NonRecordingSpan / no TracerProvider): + open_obs_span returns None so the caller falls back to the ambient + obs_correlation() instead of taking an empty-correlation handle (which + would suppress the fallback and strip obs_* ids). It also detaches the + context it attached and ends the no-op span, so nothing leaks.""" + monkeypatch.setenv("SGP_OBS_MODE", "lgtm") + record = _install_fake_otel(monkeypatch) + + made: dict = {} + + def start_span(name): + span = _FakeOtelSpan(name, 0, 0) + span._ctx = _FakeSpanContext(0, 0, is_valid=False) + made["span"] = span + return span + + sys.modules["opentelemetry"].trace.get_tracer = lambda _n: types.SimpleNamespace(start_span=start_span) + handle = obs_span.open_obs_span("step") + assert handle is None + # cleaned up: the attached context was detached and the no-op span ended + assert len(record["detached"]) == 1 + assert made["span"].ended is True + + def test_close_detaches_and_ends(self, monkeypatch): + monkeypatch.setenv("SGP_OBS_MODE", "lgtm") + record = _install_fake_otel(monkeypatch) + handle = obs_span.open_obs_span("step") + + obs_span.close_obs_span(handle) + + assert record["span"].ended is True + assert len(record["detached"]) == 1 + + def test_close_none_is_noop(self): + obs_span.close_obs_span(None) # must not raise + + def test_close_with_error_marks_otel_status(self, monkeypatch): + monkeypatch.setenv("SGP_OBS_MODE", "lgtm") + record = _install_fake_otel(monkeypatch) + handle = obs_span.open_obs_span("step") + + obs_span.close_obs_span(handle, error={"type": "ValueError", "message": "boom"}) + + assert record["span"].status == {"code": "ERROR", "description": "boom"} + assert record["span"].attributes.get("error.type") == "ValueError" + assert record["span"].ended is True + + def test_close_without_error_leaves_otel_status_unset(self, monkeypatch): + monkeypatch.setenv("SGP_OBS_MODE", "lgtm") + record = _install_fake_otel(monkeypatch) + handle = obs_span.open_obs_span("step") + + obs_span.close_obs_span(handle) # success path + + assert record["span"].status is None + assert record["span"].ended is True + + +# --------------------------------------------------------------------------- # +# dd_only -> ddtrace wrapper (only when a request trace is active) +# --------------------------------------------------------------------------- # +class TestDdtraceWrapper: + def test_dd_only_with_active_ctx_opens_named_span(self, monkeypatch): + monkeypatch.setenv("SGP_OBS_MODE", "dd_only") + record = _install_fake_ddtrace(monkeypatch, active=True, trace_id=0xABC, span_id=0xFF) + + handle = obs_span.open_obs_span("rocket.tool.fetch", business_span_id="bspan-9", business_trace_id="btrace-9") + + assert handle is not None + assert record["span"].name == "rocket.tool.fetch" + started = record["started"][0] + assert started["name"] == "rocket.tool.fetch" + assert started["activate"] is True + # child_of is the active request/turn context -> the wrapper nests under + # it instead of minting a new root trace (ddtrace does not auto-parent). + assert started["child_of"] is record["ctx"] + assert handle.correlation == { + "obs_trace_id": "00000000000000000000000000000abc", + "obs_span_id": "000000000000000000ff"[-16:], + } + # reverse tag on the ddtrace span + assert record["span"].tags == { + "agentex.business_span_id": "bspan-9", + "agentex.business_trace_id": "btrace-9", + } + + obs_span.close_obs_span(handle) + assert record["span"].finished is True + + def test_dd_only_without_active_ctx_returns_none(self, monkeypatch): + """Bare-uvicorn / no ddtrace-run: nothing active -> no orphan wrapper.""" + monkeypatch.setenv("SGP_OBS_MODE", "dd_only") + record = _install_fake_ddtrace(monkeypatch, active=False) + + assert obs_span.open_obs_span("step") is None + assert record["span"] is None # never created a span + + def test_close_with_error_marks_ddtrace_span(self, monkeypatch): + monkeypatch.setenv("SGP_OBS_MODE", "dd_only") + record = _install_fake_ddtrace(monkeypatch, active=True) + handle = obs_span.open_obs_span("step") + + obs_span.close_obs_span(handle, error={"type": "ValueError", "message": "boom"}) + + assert record["span"].error == 1 + assert record["span"].tags.get("error.type") == "ValueError" + assert record["span"].tags.get("error.message") == "boom" + assert record["span"].finished is True + + +# --------------------------------------------------------------------------- # +# End-to-end through Trace.start_span / end_span +# --------------------------------------------------------------------------- # +class TestTraceIntegration: + def test_lgtm_business_span_tagged_with_wrapper_ids(self, monkeypatch): + monkeypatch.setenv("SGP_OBS_MODE", "lgtm") + record = _install_fake_otel(monkeypatch, trace_id=0x111, span_id=0x222) + + trace = Trace(processors=[], client=MagicMock(), trace_id="task-run-1") + span = trace.start_span(name="chat_completion") + + assert record["span"].name == "chat_completion" # dedicated named span + assert isinstance(span.data, dict) + assert span.data["obs_trace_id"] == "00000000000000000000000000000111" + assert span.data["obs_span_id"] == "0000000000000222" + assert span.trace_id == "task-run-1" # business id unchanged + assert span.id in trace_module._OBS_HANDLES + # bidirectional: the obs span carries the business ids (reverse tag), + # and the business span carries the obs ids (forward edge). + assert record["span"].attributes == { + "agentex.business_span_id": span.id, + "agentex.business_trace_id": "task-run-1", + } + + trace.end_span(span) + assert record["span"].ended is True + assert span.id not in trace_module._OBS_HANDLES + + def test_wrapper_ends_across_separate_trace_instances(self, monkeypatch): + # Regression for the export bug: TracingService creates a FRESH trace + # object for start_span AND for end_span (self._tracer.trace(trace_id) in + # both). The obs handle is stored in the module-level registry, so a + # DIFFERENT instance ending the span still finds it and calls .end() on + # the OTel wrapper. With an instance-local dict this regressed: end_span's + # new instance had an empty dict -> close_obs_span(None) -> the wrapper + # span was never ended -> never exported to Tempo (recording, ids stored, + # but absent from the trace backend). + monkeypatch.setenv("SGP_OBS_MODE", "lgtm") + record = _install_fake_otel(monkeypatch, trace_id=0x111, span_id=0x222) + + starter = Trace(processors=[], client=MagicMock(), trace_id="task-run-x") + span = starter.start_span(name="chat_completion") + assert record["span"].ended is False + assert span.id in trace_module._OBS_HANDLES + + # A completely separate Trace instance ends the span. + ender = Trace(processors=[], client=MagicMock(), trace_id="task-run-x") + ender.end_span(span) + + assert record["span"].ended is True # wrapper WAS ended -> exportable + assert span.id not in trace_module._OBS_HANDLES # handle cleaned up + + def test_dd_only_business_span_tagged_via_ddtrace(self, monkeypatch): + monkeypatch.setenv("SGP_OBS_MODE", "dd_only") + record = _install_fake_ddtrace(monkeypatch, active=True, trace_id=0x111, span_id=0x222) + + trace = Trace(processors=[], client=MagicMock(), trace_id="task-run-2") + span = trace.start_span(name="get_state") + + assert record["span"].name == "get_state" + assert isinstance(span.data, dict) + assert span.data["obs_trace_id"] == "00000000000000000000000000000111" + assert span.data["obs_span_id"] == "0000000000000222" + assert record["span"].tags == { + "agentex.business_span_id": span.id, + "agentex.business_trace_id": "task-run-2", + } + + trace.end_span(span) + assert record["span"].finished is True + assert span.id not in trace_module._OBS_HANDLES + + def test_lgtm_wrapper_marked_error_when_business_step_raises(self, monkeypatch): + monkeypatch.setenv("SGP_OBS_MODE", "lgtm") + record = _install_fake_otel(monkeypatch, trace_id=0x111, span_id=0x222) + + trace = Trace(processors=[], client=MagicMock(), trace_id="task-run-err") + with pytest.raises(ValueError): + with trace.span(name="chat_completion"): + raise ValueError("boom") + + # the failed step's obs span reflects the failure, not a false green + assert record["span"].name == "chat_completion" + assert record["span"].ended is True + assert record["span"].status == {"code": "ERROR", "description": "boom"} + assert record["span"].attributes.get("error.type") == "ValueError" + + def test_dd_only_no_ctx_falls_back_to_ambient(self, monkeypatch): + monkeypatch.setenv("SGP_OBS_MODE", "dd_only") + _install_fake_ddtrace(monkeypatch, active=False) + monkeypatch.setattr("agentex.lib.core.tracing.trace.obs_correlation", lambda **_k: {}) + + trace = Trace(processors=[], client=MagicMock(), trace_id="task-run-3") + span = trace.start_span(name="get_state") + + assert span.id not in trace_module._OBS_HANDLES # no wrapper opened + assert span.data is None # nothing tagged + trace.end_span(span) # must not raise + + +# --------------------------------------------------------------------------- # +# Non-interference: the two backends are mutually exclusive per mode. +# --------------------------------------------------------------------------- # +class TestNonInterference: + def test_lgtm_touches_only_otel(self, monkeypatch): + monkeypatch.setenv("SGP_OBS_MODE", "lgtm") + otel = _install_fake_otel(monkeypatch) + dd = _install_fake_ddtrace(monkeypatch, active=True) + + obs_span.open_obs_span("step") + + assert otel["span"] is not None # OTel wrapper opened + assert dd["span"] is None # ddtrace never touched + + def test_dd_only_touches_only_ddtrace(self, monkeypatch): + monkeypatch.setenv("SGP_OBS_MODE", "dd_only") + otel = _install_fake_otel(monkeypatch) + dd = _install_fake_ddtrace(monkeypatch, active=True) + + obs_span.open_obs_span("step") + + assert dd["span"] is not None # ddtrace wrapper opened + assert otel["span"] is None # OTel never touched + + +# --------------------------------------------------------------------------- # +# No-op when unconfigured, and never fails the app call. +# --------------------------------------------------------------------------- # +class TestNeverFails: + def test_lgtm_no_otel_installed_returns_none(self, monkeypatch): + monkeypatch.setenv("SGP_OBS_MODE", "lgtm") + monkeypatch.setitem(sys.modules, "opentelemetry", None) # import -> ImportError + assert obs_span.open_obs_span("step") is None + + def test_dd_only_no_ddtrace_installed_returns_none(self, monkeypatch): + monkeypatch.setenv("SGP_OBS_MODE", "dd_only") + monkeypatch.setitem(sys.modules, "ddtrace.trace", None) # import -> ImportError + assert obs_span.open_obs_span("step") is None + + def test_backend_exception_is_swallowed(self, monkeypatch): + monkeypatch.setenv("SGP_OBS_MODE", "lgtm") + _install_fake_otel(monkeypatch) + + def boom(_name): + raise RuntimeError("tracer blew up") + + sys.modules["opentelemetry"].trace.get_tracer = boom + assert obs_span.open_obs_span("step") is None # inner guard + + def test_top_level_guard_swallows_get_mode_error(self, monkeypatch): + # Even if mode resolution itself raises, open_obs_span must not. + monkeypatch.setattr(obs_span, "get_obs_mode", lambda: (_ for _ in ()).throw(RuntimeError())) + assert obs_span.open_obs_span("step") is None + + def test_close_swallows_closer_error(self): + handle = obs_span.ObsSpanHandle({}, lambda: (_ for _ in ()).throw(RuntimeError())) + obs_span.close_obs_span(handle) # must not raise + + def test_unconfigured_lgtm_yields_usable_span_no_raise(self, monkeypatch): + # lgtm requested but OTel not installed: the REAL open_obs_span returns + # None, obs_correlation() returns {} (also no tracer) -> the business + # span is created and fully usable, and nothing raised. + monkeypatch.setenv("SGP_OBS_MODE", "lgtm") + monkeypatch.setitem(sys.modules, "opentelemetry", None) + + trace = Trace(processors=[], client=MagicMock(), trace_id="task-run-4") + span = trace.start_span(name="safe") + + assert span.trace_id == "task-run-4" + assert span.id not in trace_module._OBS_HANDLES # no wrapper + trace.end_span(span) # must not raise + + +def _install_fake_otel_sequence(monkeypatch, *, trace_id: int, first_span_id: int): + """Fake OTel whose wrapper spans all share ``trace_id`` (children of the one + turn/request obs trace) but get sequential distinct span ids.""" + state: dict = {"next": first_span_id, "spans": []} + + def start_span(name): + sid = state["next"] + state["next"] += 1 + span = _FakeOtelSpan(name, trace_id, sid) + state["spans"].append(span) + return span + + tracer = types.SimpleNamespace(start_span=start_span) + fake_trace = types.SimpleNamespace( + get_tracer=lambda _name: tracer, + set_span_in_context=lambda span: {"span": span}, + Status=_FakeStatus, + StatusCode=_FakeStatusCode, + ) + fake_context = types.SimpleNamespace( + attach=lambda ctx: object(), + detach=lambda token: None, + ) + fake_otel: Any = types.ModuleType("opentelemetry") + fake_otel.trace = fake_trace + fake_otel.context = fake_context + monkeypatch.setitem(sys.modules, "opentelemetry", fake_otel) + return state + + +class TestTurn2Example: + """Maps the 3-turn mortgage example, Turn 2 (obs trace B): + + get_state -> wrapper wB1 -> obs_span_id = wB1 + retrieve_docs -> wrapper wB2 -> obs_span_id = wB2 + chat_completion -> wrapper wB3 -> obs_span_id = wB3 + create_message -> wrapper wB4 -> obs_span_id = wB4 + + Each step opens its OWN dedicated span named for the step; all four share the + one turn obs trace B, but obs_span_id is distinct per step (not all rB). + """ + + def test_turn2_each_step_gets_distinct_named_wrapper_under_trace_B(self, monkeypatch): + monkeypatch.setenv("SGP_OBS_MODE", "lgtm") + # Turn 2's request obs trace = B (0xB); wrappers get span ids 0xB1.. . + state = _install_fake_otel_sequence(monkeypatch, trace_id=0xB, first_span_id=0xB1) + + run_id = "task-run-mortgage" # business trace_id = the run/task id + trace = Trace(processors=[], client=MagicMock(), trace_id=run_id) + + steps = ["get_state", "retrieve_docs", "chat_completion", "create_message"] + business = [] + for step in steps: + with trace.span(name=step) as s: + business.append(s) + + obs_trace_B = format(0xB, "032x") + expected_obs_span = [format(sid, "016x") for sid in (0xB1, 0xB2, 0xB3, 0xB4)] + + # one dedicated wrapper per step, named for the step, in order + assert [w.name for w in state["spans"]] == steps + + for biz, wrapper, exp_span in zip(business, state["spans"], expected_obs_span): + # forward edge: business span carries the wrapper's ids + assert biz.data["obs_trace_id"] == obs_trace_B # all under trace B + assert biz.data["obs_span_id"] == exp_span # distinct wBn + # reverse tag: wrapper carries the business ids + assert wrapper.attributes == { + "agentex.business_span_id": biz.id, + "agentex.business_trace_id": run_id, + } + + # the whole point of the fix: obs_span_id is DISTINCT per step ... + obs_span_ids = [b.data["obs_span_id"] for b in business] + assert obs_span_ids == expected_obs_span + assert len(set(obs_span_ids)) == 4 + # ... while all four share the single turn obs trace B + assert {b.data["obs_trace_id"] for b in business} == {obs_trace_B} + # business trace stays the run/task id, not the obs trace + assert {b.trace_id for b in business} == {run_id} diff --git a/tests/lib/core/tracing/test_span_error.py b/tests/lib/core/tracing/test_span_error.py new file mode 100644 index 000000000..02e9645a4 --- /dev/null +++ b/tests/lib/core/tracing/test_span_error.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +import uuid +from typing import Any +from datetime import UTC, datetime +from unittest.mock import MagicMock, patch + +import pytest +from scale_gp_beta.lib.tracing import ( + PlatformError as SGPPlatformError, + ApplicationError as SGPApplicationError, + CategorizedError as SGPCategorizedError, +) + +from agentex.types.span import Span +from agentex.lib.core.tracing.trace import Trace, AsyncTrace +from agentex.lib.core.tracing.span_error import ( + SPAN_ERROR_KEY, + PlatformError, + ApplicationError, + CategorizedError, + get_span_error, + set_span_error, +) + +PROCESSOR_MODULE = "agentex.lib.core.tracing.processors.sgp_tracing_processor" + + +def _make_span(data=None) -> Span: + return Span( + id=str(uuid.uuid4()), + name="test-span", + start_time=datetime.now(UTC), + trace_id="trace-1", + data=data, + ) + + +# --------------------------------------------------------------------------- +# Helpers: set_span_error / get_span_error +# --------------------------------------------------------------------------- + + +class TestSpanErrorHelpers: + def test_uses_canonical_sgp_error_types(self): + assert CategorizedError is SGPCategorizedError + assert ApplicationError is SGPApplicationError + assert PlatformError is SGPPlatformError + + def test_set_then_get_on_none_data(self): + span = _make_span(data=None) + set_span_error(span, ValueError("boom")) + assert get_span_error(span) == { + "type": "ValueError", + "message": "boom", + "category": "unknown", + } + assert isinstance(span.data, dict) + assert span.data[SPAN_ERROR_KEY] == { + "type": "ValueError", + "message": "boom", + "category": "unknown", + } + + def test_set_uses_explicit_exception_category(self): + span = _make_span(data=None) + set_span_error(span, PlatformError("unavailable")) + assert get_span_error(span) == { + "type": "PlatformError", + "message": "unavailable", + "category": "platform", + } + + def test_explicit_category_takes_precedence(self): + span = _make_span(data=None) + set_span_error(span, PlatformError("bad input"), error_category="application") + assert get_span_error(span)["category"] == "application" # type: ignore[index] + + def test_set_uses_application_error_category(self): + span = _make_span(data=None) + set_span_error(span, ApplicationError("bad input")) + assert get_span_error(span)["category"] == "application" # type: ignore[index] + + def test_bare_exception_attribute_does_not_opt_in(self): + class ImplicitlyCategorizedError(RuntimeError): + error_category = "platform" + + span = _make_span(data=None) + set_span_error(span, ImplicitlyCategorizedError("boom")) + assert get_span_error(span)["category"] == "unknown" # type: ignore[index] + + def test_set_preserves_existing_dict_keys(self): + span = _make_span(data={"__span_type__": "LLM"}) + set_span_error(span, RuntimeError("nope")) + assert isinstance(span.data, dict) + assert span.data["__span_type__"] == "LLM" + err = get_span_error(span) + assert err is not None + assert err["type"] == "RuntimeError" + + def test_get_returns_none_when_no_error(self): + assert get_span_error(_make_span(data={"foo": "bar"})) is None + assert get_span_error(_make_span(data=None)) is None + + def test_set_is_noop_on_list_data(self): + span = _make_span(data=[{"a": 1}]) + set_span_error(span, ValueError("boom")) + # list-shaped data is left untouched (mirrors _add_source_to_span) + assert span.data == [{"a": 1}] + assert get_span_error(span) is None + + +# --------------------------------------------------------------------------- +# Capture: the context managers record body exceptions onto the span +# --------------------------------------------------------------------------- + + +class TestContextManagerCapture: + def test_sync_span_records_error_and_reraises(self): + trace = Trace(processors=[], client=MagicMock(), trace_id="t1") + captured = {} + with pytest.raises(ValueError, match="boom"): + with trace.span("op") as span: + captured["span"] = span + raise ValueError("boom") + err = get_span_error(captured["span"]) + assert err == { + "type": "ValueError", + "message": "boom", + "category": "unknown", + } + + def test_sync_span_success_has_no_error(self): + trace = Trace(processors=[], client=MagicMock(), trace_id="t1") + with trace.span("op") as span: + pass + assert get_span_error(span) is None + + @pytest.mark.asyncio + async def test_async_span_records_error_and_reraises(self): + trace = AsyncTrace(processors=[], client=MagicMock(), trace_id="t1") + captured = {} + with pytest.raises(RuntimeError, match="kaboom"): + async with trace.span("op") as span: + captured["span"] = span + raise RuntimeError("kaboom") + err = get_span_error(captured["span"]) + assert err == { + "type": "RuntimeError", + "message": "kaboom", + "category": "unknown", + } + + +# --------------------------------------------------------------------------- +# Map: _build_sgp_span translates the recorded error into SGP status=ERROR +# --------------------------------------------------------------------------- + + +class _FakeSGPSpan: + def __init__(self, metadata: dict[str, Any] | None) -> None: + self.status = "SUCCESS" + self.metadata: dict[str, Any] = metadata if metadata is not None else {} + self.start_time = None + + def set_error( + self, + error_type: str | None = None, + error_message: str | None = None, + exception: BaseException | None = None, # noqa: ARG002 + ) -> None: + self.status = "ERROR" + self.metadata["error"] = True + self.metadata["error_type"] = error_type + self.metadata["error_message"] = error_message + + +def _fake_create_span(**kwargs: Any) -> _FakeSGPSpan: + return _FakeSGPSpan(kwargs.get("metadata")) + + +class TestBuildSGPSpanMapping: + @staticmethod + def _env(): + return MagicMock(ACP_TYPE=None, AGENT_NAME=None, AGENT_ID=None) + + def test_error_maps_to_status_error(self): + from agentex.lib.core.tracing.processors.sgp_tracing_processor import _build_sgp_span + + span = _make_span( + data={ + SPAN_ERROR_KEY: { + "type": "ValueError", + "message": "boom", + "category": "application", + } + } + ) + with patch(f"{PROCESSOR_MODULE}.create_span", side_effect=_fake_create_span): + sgp_span = _build_sgp_span(span, self._env()) + + assert sgp_span.status == "ERROR" + assert sgp_span.metadata["error"] is True + assert sgp_span.metadata["error_type"] == "ValueError" + assert sgp_span.metadata["error_message"] == "boom" + assert sgp_span.metadata["error_category"] == "application" + + def test_no_error_leaves_status_success(self): + from agentex.lib.core.tracing.processors.sgp_tracing_processor import _build_sgp_span + + span = _make_span(data={"__span_type__": "LLM"}) + with patch(f"{PROCESSOR_MODULE}.create_span", side_effect=_fake_create_span): + sgp_span = _build_sgp_span(span, self._env()) + + assert sgp_span.status == "SUCCESS" + assert "error" not in sgp_span.metadata diff --git a/tests/lib/core/tracing/test_span_queue.py b/tests/lib/core/tracing/test_span_queue.py new file mode 100644 index 000000000..b8092daca --- /dev/null +++ b/tests/lib/core/tracing/test_span_queue.py @@ -0,0 +1,893 @@ +from __future__ import annotations + +import time +import uuid +import asyncio +from typing import cast +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock, patch + +from agentex.types.span import Span +from agentex.lib.core.tracing.span_queue import ( + _DEFAULT_BATCH_SIZE, + SpanEventType, + AsyncSpanQueue, +) + + +def _make_span(span_id: str | None = None) -> Span: + return Span( + id=span_id or str(uuid.uuid4()), + name="test-span", + start_time=datetime.now(UTC), + trace_id="trace-1", + ) + + +def _make_processor(**overrides: AsyncMock) -> AsyncMock: + """Build a mock processor compatible with the queue's batched dispatch. + + The queue now calls on_spans_start(list) / on_spans_end(list) on each + processor. Mirror the behavior of AsyncTracingProcessor's default fallback + by fanning out the list to per-span calls concurrently, so tests that + assert on on_span_start / on_span_end continue to observe per-span calls. + """ + proc = AsyncMock() + proc.on_span_start = overrides.get("on_span_start", AsyncMock()) + proc.on_span_end = overrides.get("on_span_end", AsyncMock()) + + async def _fanout_start(spans: list[Span]) -> None: + await asyncio.gather(*(proc.on_span_start(s) for s in spans), return_exceptions=True) + + async def _fanout_end(spans: list[Span]) -> None: + await asyncio.gather(*(proc.on_span_end(s) for s in spans), return_exceptions=True) + + proc.on_spans_start = AsyncMock(side_effect=_fanout_start) + proc.on_spans_end = AsyncMock(side_effect=_fanout_end) + return proc + + +class TestAsyncSpanQueueNonBlocking: + async def test_enqueue_does_not_block(self): + started = asyncio.Event() + + async def slow_start(span: Span) -> None: + started.set() + await asyncio.sleep(1.0) + + slow_processor = _make_processor( + on_span_start=AsyncMock(side_effect=slow_start), + ) + queue = AsyncSpanQueue() + span = _make_span() + + start = time.monotonic() + queue.enqueue(SpanEventType.START, span, [slow_processor]) + elapsed = time.monotonic() - start + + assert elapsed < 0.01, f"enqueue took {elapsed:.3f}s — should be instant" + + # Wait for the processor to start (proves it was called) + await asyncio.wait_for(started.wait(), timeout=2.0) + await queue.shutdown() + + +class TestAsyncSpanQueueOrdering: + async def test_per_span_start_before_end(self): + """START always completes before END for the same span, even with batching.""" + call_log: list[tuple[str, str]] = [] + + async def record_start(span: Span) -> None: + call_log.append(("start", span.id)) + + async def record_end(span: Span) -> None: + call_log.append(("end", span.id)) + + proc = _make_processor( + on_span_start=AsyncMock(side_effect=record_start), + on_span_end=AsyncMock(side_effect=record_end), + ) + queue = AsyncSpanQueue() + + span_a = _make_span("span-a") + span_b = _make_span("span-b") + + queue.enqueue(SpanEventType.START, span_a, [proc]) + queue.enqueue(SpanEventType.END, span_a, [proc]) + queue.enqueue(SpanEventType.START, span_b, [proc]) + queue.enqueue(SpanEventType.END, span_b, [proc]) + + await queue.shutdown() + + # All 4 events should fire + assert len(call_log) == 4 + + # Per-span invariant: START before END + for span_id in ("span-a", "span-b"): + start_idx = next(i for i, (ev, sid) in enumerate(call_log) if ev == "start" and sid == span_id) + end_idx = next(i for i, (ev, sid) in enumerate(call_log) if ev == "end" and sid == span_id) + assert start_idx < end_idx, f"START should come before END for {span_id}" + + # All STARTs before all ENDs within a batch + start_indices = [i for i, (ev, _) in enumerate(call_log) if ev == "start"] + end_indices = [i for i, (ev, _) in enumerate(call_log) if ev == "end"] + assert max(start_indices) < min(end_indices), "All STARTs should complete before any END" + + +class TestAsyncSpanQueueErrorHandling: + async def test_error_in_processor_does_not_stop_drain(self): + call_count = 0 + + async def failing_start(span: Span) -> None: + nonlocal call_count + call_count += 1 + if call_count == 1: + raise RuntimeError("simulated failure") + + proc = _make_processor( + on_span_start=AsyncMock(side_effect=failing_start), + ) + queue = AsyncSpanQueue() + + queue.enqueue(SpanEventType.START, _make_span(), [proc]) + queue.enqueue(SpanEventType.START, _make_span(), [proc]) + + await queue.shutdown() + + assert call_count == 2, "Second event should still be processed after first fails" + + +class TestAsyncSpanQueueShutdown: + async def test_shutdown_drains_remaining_items(self): + processed: list[str] = [] + + async def track(span: Span) -> None: + processed.append(span.id) + + proc = _make_processor(on_span_start=AsyncMock(side_effect=track)) + queue = AsyncSpanQueue() + + for i in range(5): + queue.enqueue(SpanEventType.START, _make_span(f"span-{i}"), [proc]) + + await queue.shutdown() + + assert len(processed) == 5 + + async def test_shutdown_timeout(self): + async def stuck_start(span: Span) -> None: + await asyncio.sleep(60) + + stuck_processor = _make_processor( + on_span_start=AsyncMock(side_effect=stuck_start), + ) + queue = AsyncSpanQueue() + queue.enqueue(SpanEventType.START, _make_span(), [stuck_processor]) + + # Give the drain loop a moment to pick up the item + await asyncio.sleep(0.05) + + start = time.monotonic() + await queue.shutdown(timeout=0.1) + elapsed = time.monotonic() - start + + assert elapsed < 1.0, f"shutdown should not hang — took {elapsed:.1f}s" + + async def test_enqueue_after_shutdown_is_dropped(self): + proc = _make_processor() + queue = AsyncSpanQueue() + await queue.shutdown() + + queue.enqueue(SpanEventType.START, _make_span(), [proc]) + + proc.on_span_start.assert_not_called() + + +class TestAsyncSpanQueueBatchConcurrency: + async def test_batch_processes_multiple_items_concurrently(self): + """Items in the same batch should run concurrently, not serially.""" + concurrency = 0 + max_concurrency = 0 + lock = asyncio.Lock() + + async def slow_start(span: Span) -> None: + nonlocal concurrency, max_concurrency + async with lock: + concurrency += 1 + max_concurrency = max(max_concurrency, concurrency) + await asyncio.sleep(0.05) + async with lock: + concurrency -= 1 + + proc = _make_processor(on_span_start=AsyncMock(side_effect=slow_start)) + queue = AsyncSpanQueue() + + # Enqueue 10 START events before the drain loop runs — they should + # all land in the same batch and be processed concurrently. + for i in range(10): + queue.enqueue(SpanEventType.START, _make_span(f"span-{i}"), [proc]) + + await queue.shutdown() + + assert max_concurrency > 1, f"Expected concurrent processing, but max concurrency was {max_concurrency}" + + async def test_batch_faster_than_serial(self): + """Batched drain should be significantly faster than serial for slow processors.""" + n_items = 10 + per_item_delay = 0.05 # 50ms per processor call + + async def slow_start(span: Span) -> None: + await asyncio.sleep(per_item_delay) + + proc = _make_processor(on_span_start=AsyncMock(side_effect=slow_start)) + queue = AsyncSpanQueue() + + for i in range(n_items): + queue.enqueue(SpanEventType.START, _make_span(f"span-{i}"), [proc]) + + start = time.monotonic() + await queue.shutdown() + elapsed = time.monotonic() - start + + serial_time = n_items * per_item_delay + assert elapsed < serial_time * 0.5, ( + f"Batch drain took {elapsed:.3f}s — serial would be {serial_time:.3f}s. " + f"Expected at least 2x speedup from concurrency." + ) + + +class TestProcessItemsPreconditions: + """_process_items assumes every item in the list has the same event_type. + Violating that precondition silently causes END events to be treated as + STARTs (or vice versa), which is a silent data-corruption bug. Guard it + with an assertion.""" + + async def test_mixed_event_types_raise_assertion(self): + from agentex.lib.core.tracing.span_queue import _SpanQueueItem + + proc = AsyncMock() + proc.on_spans_start = AsyncMock() + proc.on_spans_end = AsyncMock() + + mixed = [ + _SpanQueueItem(event_type=SpanEventType.START, span=_make_span("a"), processors=[proc]), + _SpanQueueItem(event_type=SpanEventType.END, span=_make_span("b"), processors=[proc]), + ] + + try: + await AsyncSpanQueue()._process_items(mixed) + except AssertionError: + return + else: + raise AssertionError("Expected AssertionError for mixed event types") + + +class TestAsyncSpanQueueBatchedDispatch: + """The queue should dispatch a whole drain batch to each processor via the + batched methods (on_spans_start / on_spans_end) in one call per processor, + so processors that support real HTTP batching can send one request instead + of N. + """ + + async def test_batched_start_dispatch_single_call_per_drain(self): + received: list[list[str]] = [] + + async def capture_starts(spans: list[Span]) -> None: + received.append([s.id for s in spans]) + + proc = AsyncMock() + proc.on_spans_start = AsyncMock(side_effect=capture_starts) + proc.on_spans_end = AsyncMock() + + queue = AsyncSpanQueue() + + # Enqueue several spans synchronously before the drain has a chance to + # run — they should all land in a single drain batch. + ids = [f"span-{i}" for i in range(5)] + for i in ids: + queue.enqueue(SpanEventType.START, _make_span(i), [proc]) + + await queue.shutdown() + + # on_spans_start must have been called exactly once with all 5 spans. + assert proc.on_spans_start.call_count == 1, f"Expected one batched call, got {proc.on_spans_start.call_count}" + assert received == [ids] + + async def test_batched_end_dispatch_single_call_per_drain(self): + received: list[list[str]] = [] + + async def capture_ends(spans: list[Span]) -> None: + received.append([s.id for s in spans]) + + proc = AsyncMock() + proc.on_spans_start = AsyncMock() + proc.on_spans_end = AsyncMock(side_effect=capture_ends) + + queue = AsyncSpanQueue() + + ids = [f"span-{i}" for i in range(5)] + for i in ids: + queue.enqueue(SpanEventType.END, _make_span(i), [proc]) + + await queue.shutdown() + + assert proc.on_spans_end.call_count == 1 + assert received == [ids] + + +class TestAsyncSpanQueueLinger: + """The drain loop should linger briefly after the first item arrives so + that concurrently-emitted spans coalesce into one batch, instead of each + span producing its own size-1 drain cycle. + """ + + async def test_linger_coalesces_staggered_enqueues_into_one_batch(self): + """Spans enqueued a few ms apart should land in the SAME drain batch + when the linger window is wider than the gap between them. + """ + received: list[list[str]] = [] + + async def capture_starts(spans: list[Span]) -> None: + received.append([s.id for s in spans]) + + proc = AsyncMock() + proc.on_spans_start = AsyncMock(side_effect=capture_starts) + proc.on_spans_end = AsyncMock() + + # Linger of 100ms; we enqueue 3 items 20ms apart, well inside the window. + queue = AsyncSpanQueue(linger_ms=100) + + for i in range(3): + queue.enqueue(SpanEventType.START, _make_span(f"span-{i}"), [proc]) + await asyncio.sleep(0.02) + + await queue.shutdown() + + # All three should arrive in one batched call thanks to the linger. + assert proc.on_spans_start.call_count == 1, ( + f"Expected one batch from linger-coalesced enqueues, got " + f"{proc.on_spans_start.call_count} batches: {received}" + ) + assert received == [["span-0", "span-1", "span-2"]] + + async def test_linger_zero_drains_immediately(self): + """With linger_ms=0, the drain loop should NOT wait — staggered + enqueues produce separate batches (back-compat with prior behavior). + """ + received: list[list[str]] = [] + + async def capture_starts(spans: list[Span]) -> None: + received.append([s.id for s in spans]) + + proc = AsyncMock() + proc.on_spans_start = AsyncMock(side_effect=capture_starts) + proc.on_spans_end = AsyncMock() + + queue = AsyncSpanQueue(linger_ms=0) + + for i in range(3): + queue.enqueue(SpanEventType.START, _make_span(f"span-{i}"), [proc]) + # Give the drain loop time to pick up and process each one. + await asyncio.sleep(0.05) + + await queue.shutdown() + + # With no linger, each staggered enqueue produces its own batch. + assert proc.on_spans_start.call_count == 3, ( + f"Expected three size-1 batches without linger, got {proc.on_spans_start.call_count}: {received}" + ) + + async def test_linger_respects_batch_size_cap(self): + """The linger must not push batches over batch_size.""" + received: list[list[str]] = [] + + async def capture_starts(spans: list[Span]) -> None: + received.append([s.id for s in spans]) + + proc = AsyncMock() + proc.on_spans_start = AsyncMock(side_effect=capture_starts) + proc.on_spans_end = AsyncMock() + + # Tight batch cap, linger wide enough to coalesce but not so large + # that the tail singleton stalls the test for hundreds of ms. + queue = AsyncSpanQueue(batch_size=3, linger_ms=50) + + ids = [f"span-{i}" for i in range(7)] + for i in ids: + queue.enqueue(SpanEventType.START, _make_span(i), [proc]) + + await queue.shutdown() + + # 7 spans / batch_size=3 ⇒ at least 3 batches (3, 3, 1). None should + # exceed the cap. + for batch in received: + assert len(batch) <= 3, f"Batch exceeded cap: {batch}" + assert sum(len(b) for b in received) == 7 + + + +class _FakeHTTPError(Exception): + """Mimics an SGP/httpx status error: carries a ``status_code`` attribute.""" + + def __init__(self, status_code: int) -> None: + self.status_code = status_code + super().__init__(f"HTTP {status_code}") + + +class TestAsyncSpanQueueDropObservability: + """Silent span loss should be counted so it is measurable, and a bounded + queue should shed load deterministically instead of growing without limit. + """ + + async def test_full_queue_drops_are_counted(self): + release = asyncio.Event() + + async def block_first(spans: list[Span]) -> None: + # Block the drain on its first batch so the queue can fill behind it. + await release.wait() + + proc = AsyncMock() + proc.on_spans_start = AsyncMock(side_effect=block_first) + proc.on_spans_end = AsyncMock() + + # max_size=1, no linger, concurrency=1: the drain dispatches item-0 and + # then blocks at the in-flight cap; item-1 fills the queue; items 2 and 3 + # are dropped. + queue = AsyncSpanQueue(max_size=1, linger_ms=0, concurrency=1) + + queue.enqueue(SpanEventType.START, _make_span("s0"), [proc]) + await asyncio.sleep(0.02) # let the drain pick up s0 and block + queue.enqueue(SpanEventType.START, _make_span("s1"), [proc]) + queue.enqueue(SpanEventType.START, _make_span("s2"), [proc]) + queue.enqueue(SpanEventType.START, _make_span("s3"), [proc]) + + assert queue.dropped_spans == 2, f"expected 2 dropped, got {queue.dropped_spans}" + + release.set() + await queue.shutdown() + + async def test_no_drops_under_normal_load(self): + proc = _make_processor() + queue = AsyncSpanQueue() + for i in range(5): + queue.enqueue(SpanEventType.START, _make_span(f"s{i}"), [proc]) + await queue.shutdown() + assert queue.dropped_spans == 0 + + +class TestAsyncSpanQueueRetry: + """Transient HTTP failures (429/5xx) should be re-enqueued up to a bounded + number of attempts; auth/other errors must be dropped (and counted), never + retried. + """ + + async def test_retryable_status_is_reenqueued_and_eventually_succeeds(self): + attempts = 0 + + async def fail_then_succeed(spans: list[Span]) -> None: + nonlocal attempts + attempts += 1 + if attempts == 1: + raise _FakeHTTPError(503) + # second attempt succeeds + + proc = AsyncMock() + proc.on_spans_start = AsyncMock(side_effect=fail_then_succeed) + proc.on_spans_end = AsyncMock() + + queue = AsyncSpanQueue(max_retries=3, linger_ms=0) + queue.enqueue(SpanEventType.START, _make_span("s0"), [proc]) + await queue.shutdown() + + assert attempts == 2, "503 should be retried once, then succeed" + assert queue.dropped_spans == 0, "successful retry must not count as a drop" + + async def test_non_retryable_status_is_dropped_not_retried(self): + attempts = 0 + + async def always_401(spans: list[Span]) -> None: + nonlocal attempts + attempts += 1 + raise _FakeHTTPError(401) + + proc = AsyncMock() + proc.on_spans_start = AsyncMock(side_effect=always_401) + proc.on_spans_end = AsyncMock() + + queue = AsyncSpanQueue(max_retries=3, linger_ms=0) + queue.enqueue(SpanEventType.START, _make_span("s0"), [proc]) + await queue.shutdown() + + assert attempts == 1, "401 is non-retryable — must be tried exactly once" + assert queue.dropped_spans == 1 + + async def test_non_http_exception_is_not_retried(self): + """A plain bug (no status_code) must not be retried into an infinite + loop — preserves the original drain-continues-on-error contract.""" + attempts = 0 + + async def boom(spans: list[Span]) -> None: + nonlocal attempts + attempts += 1 + raise RuntimeError("bug, not transient") + + proc = AsyncMock() + proc.on_spans_start = AsyncMock(side_effect=boom) + proc.on_spans_end = AsyncMock() + + queue = AsyncSpanQueue(max_retries=3, linger_ms=0) + queue.enqueue(SpanEventType.START, _make_span("s0"), [proc]) + await queue.shutdown() + + assert attempts == 1 + assert queue.dropped_spans == 1 + + async def test_retryable_exhausts_attempts_then_drops(self): + attempts = 0 + + async def always_503(spans: list[Span]) -> None: + nonlocal attempts + attempts += 1 + raise _FakeHTTPError(503) + + proc = AsyncMock() + proc.on_spans_start = AsyncMock(side_effect=always_503) + proc.on_spans_end = AsyncMock() + + queue = AsyncSpanQueue(max_retries=3, linger_ms=0) + queue.enqueue(SpanEventType.START, _make_span("s0"), [proc]) + await queue.shutdown() + + assert attempts == 3, "should try up to max_retries times" + assert queue.dropped_spans == 1 + + +class TestAsyncSpanQueueConcurrency: + """Span export should issue multiple batch requests concurrently (bounded), + so per-pod egress isn't capped at one in-flight request — while still + guaranteeing a span's START send completes before its END send. + """ + + async def test_batches_dispatched_concurrently_up_to_bound(self): + current = 0 + max_seen = 0 + lock = asyncio.Lock() + + async def slow_start(spans: list[Span]) -> None: + nonlocal current, max_seen + async with lock: + current += 1 + max_seen = max(max_seen, current) + await asyncio.sleep(0.05) + async with lock: + current -= 1 + + proc = AsyncMock() + proc.on_spans_start = AsyncMock(side_effect=slow_start) + proc.on_spans_end = AsyncMock() + + # batch_size=1 → each span is its own batch/send; concurrency=4 caps + # simultaneous in-flight sends. + queue = AsyncSpanQueue(batch_size=1, linger_ms=0, concurrency=4) + for i in range(8): + queue.enqueue(SpanEventType.START, _make_span(f"s{i}"), [proc]) + + await queue.shutdown() + + assert proc.on_spans_start.call_count == 8 + assert 2 <= max_seen <= 4, f"expected bounded concurrency (2..4), saw {max_seen}" + + async def test_concurrency_one_serializes(self): + current = 0 + max_seen = 0 + lock = asyncio.Lock() + + async def slow_start(spans: list[Span]) -> None: + nonlocal current, max_seen + async with lock: + current += 1 + max_seen = max(max_seen, current) + await asyncio.sleep(0.03) + async with lock: + current -= 1 + + proc = AsyncMock() + proc.on_spans_start = AsyncMock(side_effect=slow_start) + proc.on_spans_end = AsyncMock() + + queue = AsyncSpanQueue(batch_size=1, linger_ms=0, concurrency=1) + for i in range(4): + queue.enqueue(SpanEventType.START, _make_span(f"s{i}"), [proc]) + + await queue.shutdown() + + assert max_seen == 1, f"concurrency=1 must serialize sends, saw {max_seen}" + + async def test_concurrent_is_faster_than_serial(self): + async def slow_start(spans: list[Span]) -> None: + await asyncio.sleep(0.05) + + proc = AsyncMock() + proc.on_spans_start = AsyncMock(side_effect=slow_start) + proc.on_spans_end = AsyncMock() + + queue = AsyncSpanQueue(batch_size=1, linger_ms=0, concurrency=8) + for i in range(8): + queue.enqueue(SpanEventType.START, _make_span(f"s{i}"), [proc]) + + start = time.monotonic() + await queue.shutdown() + elapsed = time.monotonic() - start + + serial = 8 * 0.05 + assert elapsed < serial * 0.5, f"concurrent drain took {elapsed:.3f}s; serial would be {serial:.3f}s" + + async def test_end_waits_for_start_of_same_span(self): + """The per-span ordering invariant: a span's END upsert must not be sent + until its START upsert has completed, even with concurrency enabled.""" + log: list[tuple[str, str]] = [] + + async def on_start(spans: list[Span]) -> None: + log.append(("start_enter", spans[0].id)) + await asyncio.sleep(0.05) + log.append(("start_exit", spans[0].id)) + + async def on_end(spans: list[Span]) -> None: + log.append(("end_enter", spans[0].id)) + await asyncio.sleep(0.01) + log.append(("end_exit", spans[0].id)) + + proc = AsyncMock() + proc.on_spans_start = AsyncMock(side_effect=on_start) + proc.on_spans_end = AsyncMock(side_effect=on_end) + + queue = AsyncSpanQueue(batch_size=1, linger_ms=0, concurrency=4) + queue.enqueue(SpanEventType.START, _make_span("A"), [proc]) + await asyncio.sleep(0.01) # let the START send begin (and block on sleep) + queue.enqueue(SpanEventType.END, _make_span("A"), [proc]) + + await queue.shutdown() + + # END must not enter until START has exited for the same span. + start_exit = log.index(("start_exit", "A")) + end_enter = log.index(("end_enter", "A")) + assert start_exit < end_enter, f"END began before START completed: {log}" + + +class TestAsyncSpanQueueIntegration: + async def test_integration_with_async_trace(self): + call_log: list[tuple[str, str]] = [] + + async def record_start(span: Span) -> None: + call_log.append(("start", span.id)) + + async def record_end(span: Span) -> None: + call_log.append(("end", span.id)) + + proc = _make_processor( + on_span_start=AsyncMock(side_effect=record_start), + on_span_end=AsyncMock(side_effect=record_end), + ) + queue = AsyncSpanQueue() + + # Patch get_async_tracing_processors to return our mock + with patch( + "agentex.lib.core.tracing.trace.get_default_span_queue", + return_value=queue, + ): + from agentex.lib.core.tracing.trace import AsyncTrace + + mock_client = MagicMock() + trace = AsyncTrace( + processors=[proc], + client=mock_client, + trace_id="test-trace", + span_queue=queue, + ) + + async with trace.span("test-operation") as span: + output: dict[str, object] = {"result": "ok"} + span.output = output + + await queue.shutdown() + + assert len(call_log) == 2 + assert call_log[0][0] == "start" + assert call_log[1][0] == "end" + # Same span ID for both events + assert call_log[0][1] == call_log[1][1] + + async def test_end_event_preserves_modified_input(self): + """END event should carry span.input so modifications after start are preserved.""" + start_spans: list[Span] = [] + end_spans: list[Span] = [] + + async def capture_start(span: Span) -> None: + start_spans.append(span) + + async def capture_end(span: Span) -> None: + end_spans.append(span) + + proc = _make_processor( + on_span_start=AsyncMock(side_effect=capture_start), + on_span_end=AsyncMock(side_effect=capture_end), + ) + queue = AsyncSpanQueue() + + from agentex.lib.core.tracing.trace import AsyncTrace + + mock_client = MagicMock() + trace = AsyncTrace( + processors=[proc], + client=mock_client, + trace_id="test-trace", + span_queue=queue, + ) + + initial_input: dict[str, object] = {"messages": [{"role": "user", "content": "hello"}]} + async with trace.span("llm-call", input=initial_input) as span: + # Simulate modifying input after start (e.g. chatbot appending messages) + messages = cast(list[dict[str, str]], cast(dict[str, object], span.input)["messages"]) + messages.append({"role": "assistant", "content": "hi there"}) + messages.append({"role": "user", "content": "how are you?"}) + span.output = cast(dict[str, object], {"response": "I'm good!"}) + + await queue.shutdown() + + assert len(start_spans) == 1 + assert len(end_spans) == 1 + + # START should carry the original input (serialized at start time) + assert start_spans[0].input is not None + assert len(cast(dict[str, list[object]], start_spans[0].input)["messages"]) == 1 # only the original message + + # END should carry the modified input (re-serialized at end time) + assert end_spans[0].input is not None + assert len(cast(dict[str, list[object]], end_spans[0].input)["messages"]) == 3 # all three messages + + # END should still carry output and end_time + assert end_spans[0].output is not None + assert end_spans[0].end_time is not None + + +class TestAsyncSpanQueueMetrics: + async def test_batch_coalesced_records_depth_including_batch(self, monkeypatch): + monkeypatch.setenv("AGENTEX_TRACING_METRICS", "1") + import agentex.lib.core.observability.tracing_metrics_recording as recording + + recording._metrics_enabled = None + proc = _make_processor() + queue = AsyncSpanQueue(linger_ms=0) + recorded_depths: list[int] = [] + + def capture_coalesced(*, queue_depth: int, batch_items: object) -> None: + recorded_depths.append(queue_depth) + + with patch.object(recording, "record_batch_coalesced", side_effect=capture_coalesced): + for _ in range(3): + queue.enqueue(SpanEventType.START, _make_span(), [proc]) + await asyncio.sleep(0.05) + await queue.shutdown() + + assert recorded_depths, "expected at least one coalesced batch" + assert recorded_depths[0] >= 3, ( + f"queue_depth should include batch items removed from queue, got {recorded_depths[0]}" + ) + + async def test_enqueue_records_enqueued_metric(self, monkeypatch): + monkeypatch.setenv("AGENTEX_TRACING_METRICS", "1") + import agentex.lib.core.observability.tracing_metrics_recording as recording + + recording._metrics_enabled = None + recording._tracing = None + mock_metrics = MagicMock() + proc = _make_processor() + queue = AsyncSpanQueue() + + with patch( + "agentex.lib.core.observability.tracing_metrics.get_tracing_metrics", + return_value=mock_metrics, + ): + queue.enqueue(SpanEventType.START, _make_span(), [proc]) + await asyncio.sleep(0.05) + await queue.shutdown() + + mock_metrics.span_events_enqueued.add.assert_any_call(1, {"event_type": "start"}) + + async def test_enqueue_during_shutdown_records_dropped_metric(self, monkeypatch): + monkeypatch.setenv("AGENTEX_TRACING_METRICS", "1") + import agentex.lib.core.observability.tracing_metrics_recording as recording + + recording._metrics_enabled = None + recording._tracing = None + mock_metrics = MagicMock() + proc = _make_processor() + queue = AsyncSpanQueue(linger_ms=0) + + with patch( + "agentex.lib.core.observability.tracing_metrics.get_tracing_metrics", + return_value=mock_metrics, + ): + queue.enqueue(SpanEventType.START, _make_span(), [proc]) + await asyncio.sleep(0.05) + queue._stopping = True + queue.enqueue(SpanEventType.END, _make_span(), [proc]) + await queue.shutdown() + + mock_metrics.span_events_dropped.add.assert_any_call(1, {"reason": "shutdown"}) + + async def test_processor_failure_records_export_failure(self, monkeypatch): + monkeypatch.setenv("AGENTEX_TRACING_METRICS", "1") + import agentex.lib.core.observability.tracing_metrics_recording as recording + + recording._metrics_enabled = None + recording._tracing = None + mock_metrics = MagicMock() + + class ExportError(Exception): + pass + + proc = AsyncMock() + proc.on_spans_start = AsyncMock(side_effect=ExportError("Error code: 401 - denied")) + proc.on_spans_end = AsyncMock() + queue = AsyncSpanQueue() + + with patch( + "agentex.lib.core.observability.tracing_metrics.get_tracing_metrics", + return_value=mock_metrics, + ): + queue.enqueue(SpanEventType.START, _make_span(), [proc]) + await asyncio.sleep(0.05) + await queue.shutdown() + + mock_metrics.export_batch_failures.add.assert_called_once() + mock_metrics.export_span_failures.add.assert_called_once() + + async def test_enqueue_overhead_with_metrics_disabled(self, monkeypatch): + monkeypatch.setenv("AGENTEX_TRACING_METRICS", "0") + import agentex.lib.core.observability.tracing_metrics_recording as recording + + recording._metrics_enabled = None + recording._tracing = None + proc = _make_processor() + queue = AsyncSpanQueue() + + with patch( + "agentex.lib.core.observability.tracing_metrics.get_tracing_metrics" + ) as mock_get: + start = time.monotonic() + for _ in range(200): + queue.enqueue(SpanEventType.START, _make_span(), [proc]) + elapsed = time.monotonic() - start + await queue.shutdown() + + assert elapsed < 0.05, f"disabled metrics enqueue too slow: {elapsed:.3f}s" + mock_get.assert_not_called() + + +class TestAsyncSpanQueueBatchSizeConfig: + """batch_size resolution: explicit arg > AGENTEX_SPAN_QUEUE_BATCH_SIZE env > default.""" + + async def test_default_batch_size(self, monkeypatch): + monkeypatch.delenv("AGENTEX_SPAN_QUEUE_BATCH_SIZE", raising=False) + assert AsyncSpanQueue()._batch_size == _DEFAULT_BATCH_SIZE + + async def test_explicit_arg_overrides_default(self, monkeypatch): + monkeypatch.delenv("AGENTEX_SPAN_QUEUE_BATCH_SIZE", raising=False) + assert AsyncSpanQueue(batch_size=10)._batch_size == 10 + + async def test_explicit_arg_clamped_to_min_one(self, monkeypatch): + monkeypatch.delenv("AGENTEX_SPAN_QUEUE_BATCH_SIZE", raising=False) + assert AsyncSpanQueue(batch_size=0)._batch_size == 1 + + async def test_env_used_when_arg_is_none(self, monkeypatch): + monkeypatch.setenv("AGENTEX_SPAN_QUEUE_BATCH_SIZE", "500") + assert AsyncSpanQueue()._batch_size == 500 + + async def test_explicit_arg_beats_env(self, monkeypatch): + monkeypatch.setenv("AGENTEX_SPAN_QUEUE_BATCH_SIZE", "500") + assert AsyncSpanQueue(batch_size=7)._batch_size == 7 + + async def test_invalid_env_falls_back_to_default(self, monkeypatch): + monkeypatch.setenv("AGENTEX_SPAN_QUEUE_BATCH_SIZE", "not-an-int") + assert AsyncSpanQueue()._batch_size == _DEFAULT_BATCH_SIZE diff --git a/tests/lib/core/tracing/test_span_queue_load.py b/tests/lib/core/tracing/test_span_queue_load.py new file mode 100644 index 000000000..652589881 --- /dev/null +++ b/tests/lib/core/tracing/test_span_queue_load.py @@ -0,0 +1,306 @@ +""" +Manual load test for the tracing pipeline. + +Measures peak queue depth, drain time, and memory under sustained load with +large system prompts — the scenario that causes OOM in K8s. + +SKIPPED by default. Run explicitly with: + + RUN_LOAD_TESTS=1 PYTHONPATH=src python -m pytest \ + tests/lib/core/tracing/test_span_queue_load.py \ + -v -o "addopts=--tb=short" -s + +To compare before/after the fix: + + # 1) Baseline (before fix) — checkout the parent commit: + git stash # if you have uncommitted changes + git checkout ced40bb + RUN_LOAD_TESTS=1 PYTHONPATH=src python -m pytest \ + tests/lib/core/tracing/test_span_queue_load.py \ + -v -o "addopts=--tb=short" -s + + # 2) After fix — return to your branch: + git checkout - + git stash pop # if you stashed + RUN_LOAD_TESTS=1 PYTHONPATH=src python -m pytest \ + tests/lib/core/tracing/test_span_queue_load.py \ + -v -o "addopts=--tb=short" -s +""" + +from __future__ import annotations + +import gc +import os +import sys +import time +import uuid +import asyncio +import resource +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from agentex.types.span import Span +from agentex.lib.core.tracing.trace import AsyncTrace +from agentex.lib.core.tracing.span_queue import AsyncSpanQueue + +# --------------------------------------------------------------------------- +# Configuration — tune to match production load profile +# --------------------------------------------------------------------------- +N_SPANS = 10_000 +PROMPT_SIZE = 50_000 # 50 KB system prompt per span +PROCESSOR_DELAY_S = 0.005 # 5 ms per processor call (simulates API latency) +REQUEST_INTERVAL_S = 0.0002 # 0.2 ms between requests (~5000 req/s burst) +SAMPLE_INTERVAL = 200 # sample queue depth every N spans + + +def _make_span(span_id: str | None = None) -> Span: + return Span( + id=span_id or str(uuid.uuid4()), + name="test-span", + start_time=datetime.now(UTC), + trace_id="trace-1", + ) + + +@pytest.mark.skipif( + not os.environ.get("RUN_LOAD_TESTS"), + reason="Load test — run with RUN_LOAD_TESTS=1", +) +class TestSpanQueueLoad: + async def test_sustained_load(self): + """ + Push 10,000 spans with 50KB system prompts through the tracing pipeline + at a steady rate while the drain loop runs concurrently. + + Prints a full report with peak queue depth, timing, and memory. + Compare the output between old code (ced40bb) and the fix branch. + """ + peak_queue_size = 0 + queue_samples: list[tuple[int, int]] = [] + + async def slow_start(span: Span) -> None: + await asyncio.sleep(PROCESSOR_DELAY_S) + + async def slow_end(span: Span) -> None: + await asyncio.sleep(PROCESSOR_DELAY_S) + + proc = AsyncMock() + proc.on_span_start = AsyncMock(side_effect=slow_start) + proc.on_span_end = AsyncMock(side_effect=slow_end) + + queue = AsyncSpanQueue() + trace = AsyncTrace( + processors=[proc], + client=MagicMock(), + trace_id="load-test", + span_queue=queue, + ) + + gc.collect() + + if sys.platform == "darwin": + rss_to_mb = 1 / 1024 / 1024 # bytes + else: + rss_to_mb = 1 / 1024 # KB + + rss_before = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss * rss_to_mb + t_start = time.monotonic() + + # ---- Enqueue phase (steady stream) ---- + for i in range(N_SPANS): + input_data = { + "system_prompt": f"You are agent #{i}. " + "x" * PROMPT_SIZE, + "messages": [{"role": "user", "content": f"Request {i}"}], + } + span = await trace.start_span(f"llm-call-{i}", input=input_data) + span.output = { + "response": f"Reply {i}", + "usage": {"prompt_tokens": 500, "completion_tokens": 100}, + } + await trace.end_span(span) + + # Yield to event loop so the drain task can run between requests. + await asyncio.sleep(REQUEST_INTERVAL_S) + + qs = queue._queue.qsize() + if qs > peak_queue_size: + peak_queue_size = qs + if i % SAMPLE_INTERVAL == 0: + queue_samples.append((i, qs)) + + t_enqueue = time.monotonic() + + # ---- Drain phase (flush remaining) ---- + await queue.shutdown(timeout=300) + t_end = time.monotonic() + + rss_after = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss * rss_to_mb + + enqueue_s = t_enqueue - t_start + drain_s = t_end - t_enqueue + total_s = t_end - t_start + + # ---- Report ---- + print() + print(f"{'=' * 60}") + print(f" Load Test: {N_SPANS:,} spans x {PROMPT_SIZE // 1000}KB prompt") + print(f" Processor delay: {PROCESSOR_DELAY_S * 1000:.0f}ms" + f" | Request interval: {REQUEST_INTERVAL_S * 1000:.1f}ms") + print(f"{'=' * 60}") + print(f" Peak queue depth: {peak_queue_size:>10,} items") + print(f" Enqueue time: {enqueue_s:>10.2f} s") + print(f" Drain time: {drain_s:>10.2f} s") + print(f" Total time: {total_s:>10.2f} s") + print(f" RSS before: {rss_before:>10.1f} MB") + print(f" RSS after: {rss_after:>10.1f} MB") + print(f" RSS delta: {rss_after - rss_before:>10.1f} MB") + print(f"{'=' * 60}") + print() + print(" Queue depth over time:") + for idx, depth in queue_samples: + bar = "#" * (depth // 200) if depth > 0 else "." + print(f" span {idx:>6,}: {depth:>6,} items {bar}") + print() + + # Soft assertion — the test is informational, but flag extreme backup + assert peak_queue_size < N_SPANS * 2, ( + f"Queue never drained during load — peak was {peak_queue_size} " + f"(total items enqueued: {N_SPANS * 2})" + ) + + async def test_growing_context_chatbot(self): + """ + Simulate concurrent chatbot conversations where each turn adds to the + message history. Each LLM call span carries the FULL conversation + (system prompt + all prior messages), so input size grows linearly + per turn and total memory is O(N^2) across turns. + + This is the worst-case scenario for queue memory: later turns produce + spans with much larger inputs than early turns. + + Config below: 50 concurrent conversations × 40 turns each = 2,000 + total spans. By turn 40, each span carries ~50KB system prompt + + ~80KB of message history. + """ + N_CONVERSATIONS = 50 + TURNS_PER_CONV = 40 + SYS_PROMPT_SIZE = 50_000 # 50 KB system prompt + MSG_SIZE = 2_000 # 2 KB per user/assistant message + DELAY = 0.005 # 5 ms processor latency + INTERVAL = 0.0002 # 0.2 ms between turns + + peak_queue_size = 0 + total_spans = N_CONVERSATIONS * TURNS_PER_CONV + queue_samples: list[tuple[int, int, int]] = [] # (span_idx, queue_depth, input_kb) + span_count = 0 + + async def slow_start(span: Span) -> None: + await asyncio.sleep(DELAY) + + async def slow_end(span: Span) -> None: + await asyncio.sleep(DELAY) + + proc = AsyncMock() + proc.on_span_start = AsyncMock(side_effect=slow_start) + proc.on_span_end = AsyncMock(side_effect=slow_end) + + queue = AsyncSpanQueue() + trace = AsyncTrace( + processors=[proc], + client=MagicMock(), + trace_id="chatbot-load", + span_queue=queue, + ) + + gc.collect() + + if sys.platform == "darwin": + rss_to_mb = 1 / 1024 / 1024 + else: + rss_to_mb = 1 / 1024 + + rss_before = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss * rss_to_mb + t_start = time.monotonic() + + # Build N_CONVERSATIONS, each accumulating message history + conversations: list[list[dict]] = [[] for _ in range(N_CONVERSATIONS)] + system_prompt = "You are a helpful assistant. " + "x" * SYS_PROMPT_SIZE + + for turn in range(TURNS_PER_CONV): + for conv_id in range(N_CONVERSATIONS): + # User sends a message + conversations[conv_id].append({ + "role": "user", + "content": f"[conv={conv_id} turn={turn}] " + "u" * MSG_SIZE, + }) + + # LLM call span — carries full conversation history + input_data = { + "system_prompt": system_prompt, + "messages": list(conversations[conv_id]), # copy of full history + } + input_kb = len(str(input_data)) // 1024 + + span = await trace.start_span( + f"llm-conv{conv_id}-turn{turn}", + input=input_data, + ) + assistant_reply = f"[reply conv={conv_id} turn={turn}] " + "a" * MSG_SIZE + span.output = {"response": assistant_reply} + await trace.end_span(span) + + # Assistant reply added to history + conversations[conv_id].append({ + "role": "assistant", + "content": assistant_reply, + }) + + span_count += 1 + await asyncio.sleep(INTERVAL) + + qs = queue._queue.qsize() + if qs > peak_queue_size: + peak_queue_size = qs + if span_count % 100 == 0: + queue_samples.append((span_count, qs, input_kb)) + + t_enqueue = time.monotonic() + await queue.shutdown(timeout=300) + t_end = time.monotonic() + + rss_after = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss * rss_to_mb + enqueue_s = t_enqueue - t_start + drain_s = t_end - t_enqueue + total_s = t_end - t_start + + # ---- Report ---- + print() + print(f"{'=' * 60}") + print(f" Chatbot Load Test: {N_CONVERSATIONS} convos x" + f" {TURNS_PER_CONV} turns = {total_spans:,} spans") + print(f" System prompt: {SYS_PROMPT_SIZE // 1000}KB" + f" | Message size: {MSG_SIZE // 1000}KB" + f" | Processor delay: {DELAY * 1000:.0f}ms") + print(f"{'=' * 60}") + print(f" Peak queue depth: {peak_queue_size:>10,} items") + print(f" Enqueue time: {enqueue_s:>10.2f} s") + print(f" Drain time: {drain_s:>10.2f} s") + print(f" Total time: {total_s:>10.2f} s") + print(f" RSS before: {rss_before:>10.1f} MB") + print(f" RSS after: {rss_after:>10.1f} MB") + print(f" RSS delta: {rss_after - rss_before:>10.1f} MB") + print(f"{'=' * 60}") + print() + print(" Queue depth & per-span input size over time:") + print(f" {'span':>8} {'queue':>8} {'input':>8}") + for idx, depth, ikb in queue_samples: + q_bar = "#" * (depth // 100) if depth > 0 else "." + print(f" {idx:>7,} {depth:>7,} {ikb:>6}KB {q_bar}") + print() + + assert peak_queue_size < total_spans * 2, ( + f"Queue never drained — peak was {peak_queue_size} " + f"(total items enqueued: {total_spans * 2})" + ) diff --git a/tests/lib/core/tracing/test_temporal_interceptor.py b/tests/lib/core/tracing/test_temporal_interceptor.py new file mode 100644 index 000000000..83c0c3681 --- /dev/null +++ b/tests/lib/core/tracing/test_temporal_interceptor.py @@ -0,0 +1,40 @@ +"""Unit tests for the Temporal OTel trace-interceptor wiring. + +Verifies the interceptor is on by default, the opt-out env flag, and the safe +no-op fallback when temporalio's OpenTelemetry contrib isn't importable. +""" + +import sys + +import pytest + +from agentex.lib.core.tracing import temporal as temporal_tracing + + +class TestTemporalTraceInterceptor: + def test_enabled_by_default(self, monkeypatch): + monkeypatch.delenv("AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED", raising=False) + assert temporal_tracing.temporal_trace_interceptor_enabled() is True + + interceptors = temporal_tracing.temporal_tracing_interceptors() + assert len(interceptors) == 1 + # temporalio's first-party OTel interceptor + assert type(interceptors[0]).__name__ == "TracingInterceptor" + + @pytest.mark.parametrize("value", ["false", "0", "no", "off", "FALSE", "Off"]) + def test_disabled_via_env(self, monkeypatch, value): + monkeypatch.setenv("AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED", value) + assert temporal_tracing.temporal_trace_interceptor_enabled() is False + assert temporal_tracing.temporal_tracing_interceptors() == [] + + @pytest.mark.parametrize("value", ["true", "1", "yes", "TRUE", "anything"]) + def test_enabled_for_non_falsy_values(self, monkeypatch, value): + monkeypatch.setenv("AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED", value) + assert temporal_tracing.temporal_trace_interceptor_enabled() is True + + def test_no_op_when_contrib_unimportable(self, monkeypatch): + # Enabled, but temporalio's OTel contrib not importable -> [] (never raises), + # so default-on can't break a worker that lacks the contrib. + monkeypatch.delenv("AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED", raising=False) + monkeypatch.setitem(sys.modules, "temporalio.contrib.opentelemetry", None) + assert temporal_tracing.temporal_tracing_interceptors() == [] diff --git a/tests/lib/core/tracing/test_trace_task_id.py b/tests/lib/core/tracing/test_trace_task_id.py new file mode 100644 index 000000000..1a616cc94 --- /dev/null +++ b/tests/lib/core/tracing/test_trace_task_id.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +from agentex.lib.core.tracing.trace import Trace, AsyncTrace + + +def _make_sync_trace(trace_id: str = "trace-123") -> tuple[MagicMock, Trace]: + client = MagicMock() + trace = Trace(processors=[], client=client, trace_id=trace_id) + return client, trace + + +def _make_async_trace(trace_id: str = "trace-123") -> tuple[MagicMock, AsyncTrace]: + client = MagicMock() + trace = AsyncTrace(processors=[], client=client, trace_id=trace_id) + return client, trace + + +class TestSyncTraceTaskId: + def test_start_span_sets_task_id_on_span(self): + _client, trace = _make_sync_trace() + span = trace.start_span(name="foo", task_id="task-abc") + assert span.task_id == "task-abc" + assert span.trace_id == "trace-123" + + def test_start_span_defaults_task_id_to_none(self): + _client, trace = _make_sync_trace() + span = trace.start_span(name="foo") + assert span.task_id is None + + def test_end_span_preserves_task_id_from_span(self): + _client, trace = _make_sync_trace() + span = trace.start_span(name="foo", task_id="task-abc") + trace.end_span(span) + assert span.task_id == "task-abc" + + +class TestAsyncTraceTaskId: + async def test_start_span_sets_task_id_on_span(self): + _client, trace = _make_async_trace() + span = await trace.start_span(name="foo", task_id="task-abc") + assert span.task_id == "task-abc" + assert span.trace_id == "trace-123" + + async def test_start_span_defaults_task_id_to_none(self): + _client, trace = _make_async_trace() + span = await trace.start_span(name="foo") + assert span.task_id is None + + async def test_end_span_preserves_task_id_from_span(self): + _client, trace = _make_async_trace() + span = await trace.start_span(name="foo", task_id="task-abc") + await trace.end_span(span) + assert span.task_id == "task-abc" diff --git a/tests/lib/test_agent_card.py b/tests/lib/test_agent_card.py new file mode 100644 index 000000000..f9a99ffc5 --- /dev/null +++ b/tests/lib/test_agent_card.py @@ -0,0 +1,454 @@ +from __future__ import annotations + +from enum import Enum +from typing import Literal, override +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from pydantic import BaseModel + +from agentex.lib.types.agent_card import AgentCard, extract_literal_values +from agentex.lib.sdk.state_machine import State, StateMachine, StateWorkflow +from agentex.lib.utils.model_utils import BaseModel as AgentexBaseModel + +# --- Fixtures & helpers --- + +class SampleState(str, Enum): + WAITING = "waiting" + PROCESSING = "processing" + DONE = "done" + + +class WaitingWorkflow(StateWorkflow): + description = "Waiting for input" + waits_for_input = True + accepts = ["text", "doc_upload"] + transitions = [SampleState.PROCESSING] + + @override + async def execute(self, state_machine, state_machine_data=None): + return SampleState.PROCESSING + + +class ProcessingWorkflow(StateWorkflow): + description = "Processing data" + accepts = ["text"] + transitions = [SampleState.DONE, SampleState.WAITING] + + @override + async def execute(self, state_machine, state_machine_data=None): + return SampleState.DONE + + +class DoneWorkflow(StateWorkflow): + description = "Terminal state" + transitions = [] + + @override + async def execute(self, state_machine, state_machine_data=None): + return SampleState.DONE + + +class SampleData(AgentexBaseModel): + pass + + +class SampleStateMachine(StateMachine[SampleData]): + @override + async def terminal_condition(self): + return self.get_current_state() == SampleState.DONE + + +class SampleOutputEvent(BaseModel): + type: Literal["plan_update", "status_change", "report_done"] + data: dict = {} + + +@pytest.fixture +def sample_states(): + return [ + State(name=SampleState.WAITING, workflow=WaitingWorkflow()), + State(name=SampleState.PROCESSING, workflow=ProcessingWorkflow()), + State(name=SampleState.DONE, workflow=DoneWorkflow()), + ] + + +@pytest.fixture +def sample_sm(sample_states): + return SampleStateMachine(initial_state=SampleState.WAITING, states=sample_states) + + +# --- extract_literal_values --- + +class TestExtractLiteralValues: + def test_literal_field(self): + class M(BaseModel): + type: Literal["a", "b", "c"] + + assert extract_literal_values(M, "type") == ["a", "b", "c"] + + def test_optional_literal_field(self): + """typing.Optional[Literal[...]] should unwrap correctly.""" + class M(BaseModel): + type: Literal["x", "y"] | None = None + + result = extract_literal_values(M, "type") + assert result == ["x", "y"] + + def test_non_literal_field(self): + class M(BaseModel): + name: str + + assert extract_literal_values(M, "name") == [] + + def test_missing_field(self): + class M(BaseModel): + name: str + + assert extract_literal_values(M, "nonexistent") == [] + + def test_int_literal(self): + class M(BaseModel): + code: Literal[1, 2, 3] + + assert extract_literal_values(M, "code") == [1, 2, 3] + + +# --- StateWorkflow defaults --- + +class TestStateWorkflowDefaults: + def test_default_attrs(self): + assert StateWorkflow.description == "" + assert StateWorkflow.waits_for_input is False + assert StateWorkflow.accepts == [] + assert StateWorkflow.transitions == [] + + def test_subclass_overrides(self): + assert WaitingWorkflow.description == "Waiting for input" + assert WaitingWorkflow.waits_for_input is True + assert WaitingWorkflow.accepts == ["text", "doc_upload"] + assert WaitingWorkflow.transitions == [SampleState.PROCESSING] + + def test_subclass_defaults_not_shared(self): + """Each subclass's list attrs are independent objects.""" + assert WaitingWorkflow.accepts is not ProcessingWorkflow.accepts + assert WaitingWorkflow.transitions is not ProcessingWorkflow.transitions + + +# --- StateMachine.get_lifecycle --- + +class TestGetLifecycle: + def test_structure(self, sample_sm): + lifecycle = sample_sm.get_lifecycle() + + assert "states" in lifecycle + assert "initial_state" in lifecycle + assert lifecycle["initial_state"] == "waiting" + assert len(lifecycle["states"]) == 3 + + def test_state_fields(self, sample_sm): + lifecycle = sample_sm.get_lifecycle() + states_by_name = {s["name"]: s for s in lifecycle["states"]} + + waiting = states_by_name["waiting"] + assert waiting["description"] == "Waiting for input" + assert waiting["waits_for_input"] is True + assert waiting["accepts"] == ["text", "doc_upload"] + assert waiting["transitions"] == ["processing"] + + processing = states_by_name["processing"] + assert processing["description"] == "Processing data" + assert processing["waits_for_input"] is False + assert processing["accepts"] == ["text"] + assert set(processing["transitions"]) == {"done", "waiting"} + + def test_enum_values_resolved(self, sample_sm): + """Enum state names and transitions should be resolved to .value strings.""" + lifecycle = sample_sm.get_lifecycle() + for state in lifecycle["states"]: + assert isinstance(state["name"], str) + for t in state["transitions"]: + assert isinstance(t, str) + + +# --- AgentCard direct construction --- + +class TestAgentCardDirect: + def test_simple_agent(self): + card = AgentCard(input_types=["text"], data_events=["result"]) + assert card.protocol == "acp" + assert card.lifecycle is None + assert card.input_types == ["text"] + assert card.data_events == ["result"] + assert card.output_schema is None + + def test_defaults(self): + card = AgentCard() + assert card.protocol == "acp" + assert card.lifecycle is None + 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"]) + dumped = card.model_dump() + 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 --- + +class TestAgentCardFromStates: + def test_lifecycle_derivation(self, sample_states): + card = AgentCard.from_states(initial_state=SampleState.WAITING, states=sample_states) + + assert card.lifecycle is not None + assert card.lifecycle.initial_state == "waiting" + assert len(card.lifecycle.states) == 3 + + def test_initial_state_string(self, sample_states): + card = AgentCard.from_states(initial_state="waiting", states=sample_states) + assert card.lifecycle is not None + assert card.lifecycle.initial_state == "waiting" + + def test_input_types_union(self, sample_states): + card = AgentCard.from_states(initial_state=SampleState.WAITING, states=sample_states) + assert card.input_types == ["doc_upload", "text"] + + def test_extra_input_types(self, sample_states): + card = AgentCard.from_states( + initial_state=SampleState.WAITING, + states=sample_states, + extra_input_types=["admin_command"], + ) + assert card.input_types == ["admin_command", "doc_upload", "text"] + + def test_data_events_and_schema(self, sample_states): + card = AgentCard.from_states( + initial_state=SampleState.WAITING, + states=sample_states, + output_event_model=SampleOutputEvent, + queries=["get_current_state"], + ) + assert card.data_events == ["plan_update", "status_change", "report_done"] + assert card.output_schema is not None + assert card.lifecycle is not None + assert card.lifecycle.queries == ["get_current_state"] + + def test_state_fields(self, sample_states): + card = AgentCard.from_states(initial_state=SampleState.WAITING, states=sample_states) + assert card.lifecycle is not None + states_by_name = {s.name: s for s in card.lifecycle.states} + + waiting = states_by_name["waiting"] + assert waiting.description == "Waiting for input" + assert waiting.waits_for_input is True + 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( + initial_state=SampleState.WAITING, + states=sample_states, + output_event_model=SampleOutputEvent, + queries=["get_current_state"], + ) + card_sm = AgentCard.from_state_machine( + state_machine=sample_sm, + output_event_model=SampleOutputEvent, + queries=["get_current_state"], + ) + assert card_states == card_sm + + +# --- AgentCard.from_state_machine --- + +class TestAgentCardFromStateMachine: + def test_lifecycle_derivation(self, sample_sm): + card = AgentCard.from_state_machine(state_machine=sample_sm) + + assert card.lifecycle is not None + assert card.lifecycle.initial_state == "waiting" + assert len(card.lifecycle.states) == 3 + + def test_input_types_union(self, sample_sm): + """input_types should be the sorted union of all per-state accepts.""" + card = AgentCard.from_state_machine(state_machine=sample_sm) + assert card.input_types == ["doc_upload", "text"] + + def test_extra_input_types(self, sample_sm): + card = AgentCard.from_state_machine( + state_machine=sample_sm, + extra_input_types=["admin_command"], + ) + assert "admin_command" in card.input_types + assert card.input_types == ["admin_command", "doc_upload", "text"] + + def test_data_events_extraction(self, sample_sm): + card = AgentCard.from_state_machine( + state_machine=sample_sm, + output_event_model=SampleOutputEvent, + ) + assert card.data_events == ["plan_update", "status_change", "report_done"] + + def test_output_schema_generation(self, sample_sm): + card = AgentCard.from_state_machine( + state_machine=sample_sm, + output_event_model=SampleOutputEvent, + ) + assert card.output_schema is not None + assert "properties" in card.output_schema + assert "type" in card.output_schema["properties"] + + def test_queries(self, sample_sm): + card = AgentCard.from_state_machine( + state_machine=sample_sm, + queries=["get_current_state", "get_progress"], + ) + assert card.lifecycle is not None + assert card.lifecycle.queries == ["get_current_state", "get_progress"] + + def test_no_output_model(self, sample_sm): + card = AgentCard.from_state_machine(state_machine=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 --- + +class TestRegisterAgentCardMerge: + @pytest.fixture + def mock_env_vars(self): + """Minimal EnvironmentVariables mock for register_agent.""" + mock = type("EnvVars", (), { + "AGENTEX_BASE_URL": "http://localhost:5003", + "ACP_URL": "http://localhost", + "ACP_PORT": "8000", + "AGENT_NAME": "test-agent", + "AGENT_DESCRIPTION": "Test agent", + "ACP_TYPE": "sync", + "AUTH_PRINCIPAL_B64": None, + "AGENT_ID": None, + "AGENT_INPUT_TYPE": None, + "AGENT_API_KEY": None, + "AGENTEX_DEPLOYMENT_ID": None, + })() + return mock + + def _make_mock_client(self): + """Create a mock httpx.AsyncClient that returns a successful registration response.""" + mock_response = MagicMock() + mock_response.status_code = 200 + # httpx Response.json() is sync, not async + mock_response.json.return_value = { + "id": "agent-123", + "name": "test-agent", + "agent_api_key": "key-123", + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + return mock_client + + async def test_agent_card_merged_into_metadata(self, mock_env_vars): + card = AgentCard(input_types=["text"], data_events=["result"]) + 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 "agent_card" in metadata + 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() + + 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=None) + + sent_data = mock_client.post.call_args.kwargs["json"] + assert sent_data["registration_metadata"] is None + + async def test_card_creates_metadata(self, mock_env_vars): + card = AgentCard(input_types=["text"]) + 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 is not None + assert "agent_card" in metadata diff --git a/tests/lib/test_agentex_worker.py b/tests/lib/test_agentex_worker.py new file mode 100644 index 000000000..742ac3e74 --- /dev/null +++ b/tests/lib/test_agentex_worker.py @@ -0,0 +1,354 @@ +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +class TestAgentexWorker: + """Tests for AgentexWorker initialization and configuration.""" + + @pytest.fixture(autouse=True) + def cleanup_env(self): + """Cleanup environment variables after each test.""" + yield + # Clean up HEALTH_CHECK_PORT if it was set during test + os.environ.pop("HEALTH_CHECK_PORT", None) + + def test_worker_init_uses_default_health_check_port(self): + """Test that worker uses default health_check_port of 80 when not provided.""" + from agentex.lib.core.temporal.workers.worker import AgentexWorker + + # Ensure HEALTH_CHECK_PORT is not in environment + os.environ.pop("HEALTH_CHECK_PORT", None) + + # Mock EnvironmentVariables.refresh to avoid loading .env files + with patch("agentex.lib.core.temporal.workers.worker.EnvironmentVariables") as mock_env_vars: + mock_instance = mock_env_vars.refresh.return_value + mock_instance.HEALTH_CHECK_PORT = 80 + + worker = AgentexWorker(task_queue="test-queue") + + assert worker.health_check_port == 80, "Worker should use default health_check_port of 80" + + def test_worker_init_with_explicit_health_check_port(self): + """Test that worker uses explicit health_check_port parameter when provided.""" + from agentex.lib.core.temporal.workers.worker import AgentexWorker + + worker = AgentexWorker(task_queue="test-queue", health_check_port=8080) + + assert worker.health_check_port == 8080, "Worker should use explicitly provided health_check_port" + + def test_worker_init_explicit_port_overrides_environment(self): + """Test that explicit health_check_port parameter overrides environment variable.""" + from agentex.lib.core.temporal.workers.worker import AgentexWorker + + # Set environment variable + os.environ["HEALTH_CHECK_PORT"] = "9000" + + worker = AgentexWorker(task_queue="test-queue", health_check_port=8080) + + assert worker.health_check_port == 8080, "Explicit parameter should override environment variable" + + @pytest.mark.parametrize( + "env_port,expected_port", + [ + (None, 80), # No env var, should use default + ("8080", 8080), # Env var set, should use it + ("443", 443), # Different port + ], + ) + def test_worker_init_respects_environment_variable(self, env_port, expected_port): + """Test that worker respects HEALTH_CHECK_PORT from EnvironmentVariables.""" + from agentex.lib.core.temporal.workers.worker import AgentexWorker + + # Mock EnvironmentVariables.refresh to return expected port + with patch("agentex.lib.core.temporal.workers.worker.EnvironmentVariables") as mock_env_vars: + mock_instance = mock_env_vars.refresh.return_value + mock_instance.HEALTH_CHECK_PORT = expected_port + + worker = AgentexWorker(task_queue="test-queue") + + assert worker.health_check_port == expected_port, f"Worker should use health_check_port {expected_port}" + + def test_worker_init_basic_attributes(self): + """Test that worker initializes with correct basic attributes.""" + from agentex.lib.core.temporal.workers.worker import AgentexWorker + + worker = AgentexWorker( + task_queue="test-queue", + max_workers=20, + max_concurrent_activities=15, + health_check_port=8080, + ) + + assert worker.task_queue == "test-queue" + assert worker.max_workers == 20 + assert worker.max_concurrent_activities == 15 + assert worker.health_check_port == 8080 + assert worker.health_check_server_running is False + assert worker.healthy is False + assert worker.plugins == [] + + def test_worker_stores_metrics_params(self): + from agentex.lib.core.temporal.workers.worker import AgentexWorker + + worker = AgentexWorker( + task_queue="test-queue", + health_check_port=8080, + metrics_url="http://example.com/v1/metrics", + metrics_headers={"Authorization": "Api-Token tok"}, + metrics_use_http=True, + metrics_temporality_delta=True, + ) + + assert worker.metrics_url == "http://example.com/v1/metrics" + assert worker.metrics_headers == {"Authorization": "Api-Token tok"} + assert worker.metrics_use_http is True + assert worker.metrics_temporality_delta is True + + def test_worker_metrics_params_default_to_none_and_false(self): + from agentex.lib.core.temporal.workers.worker import AgentexWorker + + worker = AgentexWorker(task_queue="test-queue", health_check_port=8080) + + assert worker.metrics_url is None + assert worker.metrics_headers is None + assert worker.metrics_use_http is False + assert worker.metrics_temporality_delta is False + + +class TestAgentexWorkerAgentCard: + """Tests that AgentexWorker publishes an optional AgentCard through the + existing automatic registration lifecycle.""" + + @pytest.fixture(autouse=True) + def cleanup_env(self): + yield + for key in ("AGENT_ID", "AGENT_NAME", "AGENT_API_KEY"): + os.environ.pop(key, None) + + @staticmethod + def _env_vars_mock(): + env = MagicMock() + env.AGENTEX_BASE_URL = "http://agentex.test" + env.ACP_URL = "http://agent.test" + env.ACP_PORT = 8000 + env.AGENT_DESCRIPTION = "test description" + env.AGENT_NAME = "test-agent" + env.ACP_TYPE = "agentic" + env.AUTH_PRINCIPAL_B64 = None + env.AGENTEX_DEPLOYMENT_ID = None + env.AGENT_ID = None + env.AGENT_INPUT_TYPE = None + return env + + @staticmethod + def _httpx_client_mock(captured_payloads): + response = MagicMock() + response.status_code = 200 + response.json.return_value = { + "id": "agent-id", + "name": "test-agent", + "agent_api_key": "api-key", + } + + async def post(url, json=None, timeout=None): # noqa: ARG001 + captured_payloads.append(json) + return response + + client = MagicMock() + client.__aenter__ = AsyncMock(return_value=MagicMock(post=AsyncMock(side_effect=post))) + client.__aexit__ = AsyncMock(return_value=False) + return MagicMock(return_value=client) + + def test_worker_agent_card_defaults_to_none(self): + from agentex.lib.core.temporal.workers.worker import AgentexWorker + + worker = AgentexWorker(task_queue="test-queue", health_check_port=8080) + + assert worker.agent_card is None + + async def test_default_registration_calls_register_agent_without_card(self): + """The default worker still registers automatically and passes no card, + preserving existing callers and wire behavior.""" + from agentex.lib.core.temporal.workers.worker import AgentexWorker + + worker = AgentexWorker(task_queue="test-queue", health_check_port=8080) + + with patch( + "agentex.lib.core.temporal.workers.worker.register_agent", new=AsyncMock() + ) as mock_register, patch( + "agentex.lib.core.temporal.workers.worker.assert_backend_compatible", + new=AsyncMock(), + ), patch( + "agentex.lib.core.temporal.workers.worker.EnvironmentVariables" + ) as mock_env_cls: + env = self._env_vars_mock() + mock_env_cls.refresh.return_value = env + + await worker._register_agent() + + mock_register.assert_awaited_once_with(env, agent_card=None) + + async def test_supplied_card_forwarded_exactly_once_by_run_lifecycle(self): + """A card passed to the constructor reaches register_agent exactly once + through the existing automatic registration in run(); no second + registration call is introduced.""" + from agentex.lib.types.agent_card import AgentCard + from agentex.lib.core.temporal.workers.worker import AgentexWorker + + card = AgentCard(metadata={"permits_capable": True}) + worker = AgentexWorker( + task_queue="test-queue", health_check_port=8080, agent_card=card + ) + + with patch.object( + worker, "start_health_check_server", new=AsyncMock() + ), patch( + "agentex.lib.core.temporal.workers.worker.register_agent", new=AsyncMock() + ) as mock_register, patch( + "agentex.lib.core.temporal.workers.worker.assert_backend_compatible", + new=AsyncMock(), + ), patch( + "agentex.lib.core.temporal.workers.worker.EnvironmentVariables" + ) as mock_env_cls, patch( + "agentex.lib.core.temporal.workers.worker.get_temporal_client", + new=AsyncMock(return_value=MagicMock()), + ), patch( + "agentex.lib.core.temporal.workers.worker.Worker" + ) as mock_worker_cls: + env = self._env_vars_mock() + mock_env_cls.refresh.return_value = env + mock_worker_cls.return_value.run = AsyncMock() + + await worker.run(activities=[], workflows=[MagicMock()]) + + mock_register.assert_awaited_once_with(env, agent_card=card) + + async def test_worker_and_fastacp_paths_serialize_the_same_card_shape(self): + """The worker path and the FastACP/BaseACPServer lifespan path hand the + same card to register_agent, so the registration payload's + registration_metadata.agent_card is identical.""" + from agentex.lib.types.agent_card import AgentCard + from agentex.lib.core.temporal.workers.worker import AgentexWorker + from agentex.lib.sdk.fastacp.base.base_acp_server import BaseACPServer + + card = AgentCard(metadata={"permits_capable": True, "region": "us"}) + + worker_payloads = [] + worker = AgentexWorker( + task_queue="test-queue", health_check_port=8080, agent_card=card + ) + with patch( + "agentex.lib.core.temporal.workers.worker.assert_backend_compatible", + new=AsyncMock(), + ), patch( + "agentex.lib.core.temporal.workers.worker.EnvironmentVariables" + ) as mock_env_cls, patch( + "agentex.lib.utils.registration.httpx.AsyncClient", + new=self._httpx_client_mock(worker_payloads), + ): + mock_env_cls.refresh.return_value = self._env_vars_mock() + await worker._register_agent() + + acp_payloads = [] + server = BaseACPServer.create() + server._agent_card = card + lifespan = server.get_lifespan_function() + with patch( + "agentex.lib.sdk.fastacp.base.base_acp_server.assert_backend_compatible", + new=AsyncMock(), + ), patch( + "agentex.lib.sdk.fastacp.base.base_acp_server.EnvironmentVariables" + ) as mock_env_cls, patch( + "agentex.lib.sdk.fastacp.base.base_acp_server.shutdown_default_span_queue", + new=AsyncMock(), + ), patch( + "agentex.lib.utils.registration.httpx.AsyncClient", + new=self._httpx_client_mock(acp_payloads), + ): + mock_env_cls.refresh.return_value = self._env_vars_mock() + async with lifespan(MagicMock()): + pass + + assert len(worker_payloads) == 1 + assert len(acp_payloads) == 1 + worker_card = worker_payloads[0]["registration_metadata"]["agent_card"] + acp_card = acp_payloads[0]["registration_metadata"]["agent_card"] + assert worker_card == acp_card == card.model_dump() + + +class TestGetTemporalClientMetricsConfig: + """Tests that metrics params reach OpenTelemetryConfig correctly.""" + + async def test_metrics_params_reach_otel_config(self): + from temporalio.client import Client + from temporalio.runtime import OpenTelemetryMetricTemporality + + from agentex.lib.core.temporal.workers.worker import get_temporal_client + + with patch.object(Client, "connect", new=AsyncMock(return_value=MagicMock())), \ + patch("agentex.lib.core.temporal.workers.worker.Runtime"), \ + patch("agentex.lib.core.temporal.workers.worker.TelemetryConfig"), \ + patch("agentex.lib.core.temporal.workers.worker.OpenTelemetryConfig") as mock_otel: + await get_temporal_client( + "localhost:7233", + metrics_url="http://example.com/v1/metrics", + metrics_headers={"Authorization": "Api-Token tok"}, + metrics_use_http=True, + metrics_temporality_delta=True, + ) + + mock_otel.assert_called_once_with( + url="http://example.com/v1/metrics", + headers={"Authorization": "Api-Token tok"}, + http=True, + metric_temporality=OpenTelemetryMetricTemporality.DELTA, + ) + + async def test_delta_false_maps_to_cumulative(self): + from temporalio.client import Client + from temporalio.runtime import OpenTelemetryMetricTemporality + + from agentex.lib.core.temporal.workers.worker import get_temporal_client + + with patch.object(Client, "connect", new=AsyncMock(return_value=MagicMock())), \ + patch("agentex.lib.core.temporal.workers.worker.Runtime"), \ + patch("agentex.lib.core.temporal.workers.worker.TelemetryConfig"), \ + patch("agentex.lib.core.temporal.workers.worker.OpenTelemetryConfig") as mock_otel: + await get_temporal_client( + "localhost:7233", + metrics_url="http://example.com/v1/metrics", + metrics_temporality_delta=False, + ) + + _, kwargs = mock_otel.call_args + assert kwargs["metric_temporality"] == OpenTelemetryMetricTemporality.CUMULATIVE + + async def test_none_headers_defaults_to_empty_dict(self): + from temporalio.client import Client + + from agentex.lib.core.temporal.workers.worker import get_temporal_client + + with patch.object(Client, "connect", new=AsyncMock(return_value=MagicMock())), \ + patch("agentex.lib.core.temporal.workers.worker.Runtime"), \ + patch("agentex.lib.core.temporal.workers.worker.TelemetryConfig"), \ + patch("agentex.lib.core.temporal.workers.worker.OpenTelemetryConfig") as mock_otel: + await get_temporal_client( + "localhost:7233", + metrics_url="http://example.com/v1/metrics", + ) + + _, kwargs = mock_otel.call_args + assert kwargs["headers"] == {} + + async def test_no_metrics_url_skips_runtime(self): + from temporalio.client import Client + + from agentex.lib.core.temporal.workers.worker import get_temporal_client + + with patch.object(Client, "connect", new=AsyncMock(return_value=MagicMock())), \ + patch("agentex.lib.core.temporal.workers.worker.Runtime") as mock_runtime: + await get_temporal_client("localhost:7233") + + mock_runtime.assert_not_called() diff --git a/tests/lib/test_auto_send_params_created_at.py b/tests/lib/test_auto_send_params_created_at.py new file mode 100644 index 000000000..f13041bea --- /dev/null +++ b/tests/lib/test_auto_send_params_created_at.py @@ -0,0 +1,101 @@ +"""Smoke tests confirming the auto-send activity param models accept the new +`created_at` field added for workflow-driven monotonic message ordering. + +These don't exercise the workflow dispatch path (which requires a Temporal +test environment); they just verify the param surface so callers can rely on +it being available without runtime errors. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +_TS = datetime(2026, 5, 13, 18, 30, 0, tzinfo=timezone.utc) + + +def test_chat_completion_stream_auto_send_params_accepts_created_at() -> None: + from agentex.lib.types.llm_messages import LLMConfig + from agentex.lib.core.temporal.activities.adk.providers.litellm_activities import ( + ChatCompletionStreamAutoSendParams, + ) + + params = ChatCompletionStreamAutoSendParams( + task_id="t1", + llm_config=LLMConfig(model="gpt-4o", messages=[]), + created_at=_TS, + ) + assert params.created_at == _TS + + +def test_chat_completion_auto_send_params_accepts_created_at() -> None: + from agentex.lib.types.llm_messages import LLMConfig + from agentex.lib.core.temporal.activities.adk.providers.litellm_activities import ( + ChatCompletionAutoSendParams, + ) + + params = ChatCompletionAutoSendParams( + task_id="t1", + llm_config=LLMConfig(model="gpt-4o", messages=[]), + created_at=_TS, + ) + assert params.created_at == _TS + + +def test_run_agent_auto_send_params_accepts_created_at() -> None: + from agentex.lib.core.temporal.activities.adk.providers.openai_activities import ( + RunAgentAutoSendParams, + ) + + params = RunAgentAutoSendParams( + task_id="t1", + input_list=[{"role": "user", "content": "hi"}], + mcp_server_params=[], + agent_name="x", + agent_instructions="y", + created_at=_TS, + ) + assert params.created_at == _TS + + +def test_run_agent_streamed_auto_send_params_accepts_created_at() -> None: + from agentex.lib.core.temporal.activities.adk.providers.openai_activities import ( + RunAgentStreamedAutoSendParams, + ) + + params = RunAgentStreamedAutoSendParams( + task_id="t1", + input_list=[{"role": "user", "content": "hi"}], + mcp_server_params=[], + agent_name="x", + agent_instructions="y", + created_at=_TS, + ) + assert params.created_at == _TS + + +def test_create_message_params_accepts_created_at() -> None: + from agentex.types.text_content import TextContent + from agentex.lib.core.temporal.activities.adk.messages_activities import ( + CreateMessageParams, + ) + + params = CreateMessageParams( + task_id="t1", + content=TextContent(author="user", content="hi", format="markdown"), + created_at=_TS, + ) + assert params.created_at == _TS + + +def test_create_messages_batch_params_accepts_created_at() -> None: + from agentex.types.text_content import TextContent + from agentex.lib.core.temporal.activities.adk.messages_activities import ( + CreateMessagesBatchParams, + ) + + params = CreateMessagesBatchParams( + task_id="t1", + contents=[TextContent(author="user", content="hi", format="markdown")], + created_at=_TS, + ) + assert params.created_at == _TS diff --git a/tests/lib/test_build_provenance.py b/tests/lib/test_build_provenance.py new file mode 100644 index 000000000..9115e2804 --- /dev/null +++ b/tests/lib/test_build_provenance.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from agentex.lib.utils.build_provenance import ( + normalize_remote, + working_tree_hash, + iter_context_files, + capture_build_provenance, +) + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(("git", "-C", str(repo), *args), check=True, capture_output=True, text=True) + + +def _init_repo(path: Path, *, remote: str | None = "git@github.com:scaleapi/demo.git") -> Path: + path.mkdir(parents=True, exist_ok=True) + _git(path, "init", "-q") + _git(path, "config", "user.email", "dev@scale.com") + _git(path, "config", "user.name", "Dev") + _git(path, "config", "commit.gpgsign", "false") + if remote: + _git(path, "remote", "add", "origin", remote) + return path + + +def _commit_all(path: Path, message: str = "init") -> None: + _git(path, "add", "-A") + _git(path, "commit", "-q", "-m", message) + _git(path, "branch", "-M", "main") + + +def _write(root: Path, rel: str, content: str = "x") -> None: + target = root / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content) + + +# --- normalize_remote --------------------------------------------------------- + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("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"), + ("ssh://git@gitlab.com/group/sub/proj.git", "gitlab.com/group/sub/proj"), + ("", None), + (None, None), + ], +) +def test_normalize_remote(raw: str | None, expected: str | None) -> None: + assert normalize_remote(raw) == expected + + +# --- working_tree_hash -------------------------------------------------------- + + +def test_hash_is_order_independent(tmp_path: Path) -> None: + first = tmp_path / "a" + second = tmp_path / "b" + for rel in ("z.txt", "a/b.txt", "m.txt"): + _write(first, rel, rel) + # Same content, different creation order. + for rel in ("m.txt", "z.txt", "a/b.txt"): + _write(second, rel, rel) + assert working_tree_hash(first) == working_tree_hash(second) + + +def test_hash_changes_on_one_byte(tmp_path: Path) -> None: + root = tmp_path / "ctx" + _write(root, "f.txt", "hello") + before = working_tree_hash(root) + _write(root, "f.txt", "hellp") + assert working_tree_hash(root) != before + + +def test_hash_changes_when_file_added(tmp_path: Path) -> None: + root = tmp_path / "ctx" + _write(root, "f.txt", "hello") + before = working_tree_hash(root) + _write(root, "g.txt", "new") + assert working_tree_hash(root) != before + + +def test_hash_changes_on_executable_bit(tmp_path: Path) -> None: + root = tmp_path / "ctx" + script = root / "run.sh" + _write(root, "run.sh", "#!/bin/sh\n") + before = working_tree_hash(root) + script.chmod(0o755) + assert working_tree_hash(root) != before + + +def test_symlink_hashes_target_not_resolved_content(tmp_path: Path) -> None: + root = tmp_path / "ctx" + root.mkdir() + # Dangling symlinks: distinct hashes prove the target string is hashed, not + # resolved content (resolving would raise). + (root / "link").symlink_to("points/to/a") + hash_a = working_tree_hash(root) + (root / "link").unlink() + (root / "link").symlink_to("points/to/b") + assert working_tree_hash(root) != hash_a + + +def test_iter_context_files_skips_directories(tmp_path: Path) -> None: + root = tmp_path / "ctx" + _write(root, "pkg/mod.py", "x") + _write(root, "top.txt", "y") + rels = [path.relative_to(root).as_posix() for path in iter_context_files(root)] + assert rels == ["pkg/mod.py", "top.txt"] + + +# --- capture_build_provenance ------------------------------------------------- + + +def test_capture_clean_tree(tmp_path: Path) -> None: + repo = _init_repo(tmp_path / "repo") + _write(repo, "main.py", "print(1)") + _commit_all(repo) + + prov = capture_build_provenance(repo, repo) + + assert prov.repo == "github.com/scaleapi/demo" + assert prov.ref == "main" + assert prov.commit is not None and len(prov.commit) == 40 + assert prov.working_tree_hash is not None # always computed + assert prov.dirty is False + assert prov.subpath is None + assert prov.author_email == "dev@scale.com" + + +def test_capture_untracked_file_changes_hash(tmp_path: Path) -> None: + repo = _init_repo(tmp_path / "repo") + _write(repo, "main.py", "print(1)") + _commit_all(repo) + _write(repo, "scratch.py", "debug = True") # untracked + + prov = capture_build_provenance(repo, repo) + + # The stale-code guard: an untracked file is part of the build context, so it + # must move the hash (a `git diff` of tracked files alone would miss it). + assert prov.dirty is True + assert prov.working_tree_hash == working_tree_hash(repo) + assert working_tree_hash(repo) != _hash_without(repo, "scratch.py") + + +def _hash_without(repo: Path, rel: str) -> str: + removed = repo / rel + saved = removed.read_text() + removed.unlink() + try: + return working_tree_hash(repo) + finally: + removed.write_text(saved) + + +def test_capture_detached_head_has_no_ref(tmp_path: Path) -> None: + repo = _init_repo(tmp_path / "repo") + _write(repo, "main.py", "print(1)") + _commit_all(repo) + _write(repo, "main.py", "print(2)") + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", "second") + first = subprocess.run( + ("git", "-C", str(repo), "rev-list", "--max-parents=0", "HEAD"), + check=True, + capture_output=True, + text=True, + ).stdout.strip() + _git(repo, "checkout", "-q", first) + + prov = capture_build_provenance(repo, repo) + + assert prov.commit == first + assert prov.ref is None + + +def test_capture_detached_on_tag_uses_tag(tmp_path: Path) -> None: + repo = _init_repo(tmp_path / "repo") + _write(repo, "main.py", "print(1)") + _commit_all(repo) + _git(repo, "tag", "v1.2.3") + _git(repo, "checkout", "-q", "v1.2.3") + + assert capture_build_provenance(repo, repo).ref == "v1.2.3" + + +def test_capture_no_remote(tmp_path: Path) -> None: + repo = _init_repo(tmp_path / "repo", remote=None) + _write(repo, "main.py", "print(1)") + _commit_all(repo) + + prov = capture_build_provenance(repo, repo) + + assert prov.repo is None + assert prov.commit is not None + assert prov.working_tree_hash is not None # always computed + + +def test_capture_non_git_dir(tmp_path: Path) -> None: + plain = tmp_path / "plain" + _write(plain, "main.py", "print(1)") + + prov = capture_build_provenance(plain, plain) + + assert prov.repo is None + assert prov.commit is None + assert prov.ref is None + # No commit → the content hash is the identity; dirtiness is undefined (no VCS). + assert prov.working_tree_hash == working_tree_hash(plain) + assert prov.dirty is None + assert prov.build_timestamp is not None + + +def test_capture_never_raises_when_hash_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import agentex.lib.utils.build_provenance as bp + + plain = tmp_path / "plain" # non-git → would hash, which we force to fail + _write(plain, "main.py", "print(1)") + + def _boom(_root: Path) -> str: + raise OSError("permission denied") + + monkeypatch.setattr(bp, "working_tree_hash", _boom) + + prov = bp.capture_build_provenance(plain, plain) # must not raise + + assert prov.working_tree_hash is None + + +def test_capture_monorepo_subpath(tmp_path: Path) -> None: + repo = _init_repo(tmp_path / "repo") + _write(repo, "agents/foo/main.py", "print(1)") + _commit_all(repo) + + prov = capture_build_provenance(repo, repo / "agents" / "foo") + + assert prov.subpath == "agents/foo" + + +def test_capture_monorepo_ignores_changes_outside_context(tmp_path: Path) -> None: + repo = _init_repo(tmp_path / "repo") + _write(repo, "agents/foo/main.py", "print(1)") + _write(repo, "agents/bar/main.py", "print(2)") + _commit_all(repo) + _write(repo, "agents/bar/scratch.py", "debug = True") + + prov = capture_build_provenance(repo, repo / "agents" / "foo") + + assert prov.dirty is False diff --git a/tests/lib/test_claude_agents_activities.py b/tests/lib/test_claude_agents_activities.py new file mode 100644 index 000000000..99982d851 --- /dev/null +++ b/tests/lib/test_claude_agents_activities.py @@ -0,0 +1,737 @@ +"""Tests for Claude Agents SDK activity helpers. + +These tests validate the serialization helpers and activity behavior for the +Claude Agents SDK Temporal integration. The import chain for the activities +module transitively pulls in langchain_core and langgraph (via agentex.lib.adk), +which are optional deps not present in the base test venv. We mock the +problematic intermediate modules to break the chain. +""" + +from __future__ import annotations + +import sys + +# The activities module lives under agentex.lib.core.temporal.plugins.claude_agents. +# Importing it normally triggers plugins/__init__.py which imports the openai_agents +# plugin, which transitively imports langchain_core and langgraph (not installed in +# the base test environment). +# +# We use importlib.util to load *only* the activities module from its file path, +# bypassing all __init__.py chains. +import contextvars +import importlib.util +from types import ModuleType +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +from claude_agent_sdk import HookMatcher, AgentDefinition, ClaudeAgentOptions +from claude_agent_sdk.types import ( + TextBlock, + ToolUseBlock, + ResultMessage, + SystemMessage, + AssistantMessage, +) + +_SRC = Path(__file__).resolve().parents[2] / "src" +_ACTIVITIES_PATH = _SRC / "agentex" / "lib" / "core" / "temporal" / "plugins" / "claude_agents" / "activities.py" + +# Stub the modules that activities.py imports (hooks, message_handler, interceptor) +_hooks_mock = MagicMock() +_handler_mock = MagicMock() +_interceptor_mock = MagicMock() +_interceptor_mock.streaming_task_id = contextvars.ContextVar("streaming_task_id", default=None) +_interceptor_mock.streaming_trace_id = contextvars.ContextVar("streaming_trace_id", default=None) +_interceptor_mock.streaming_parent_span_id = contextvars.ContextVar("streaming_parent_span_id", default=None) + +# Register stubs for all imports that activities.py does +_adk_mock = MagicMock() +_hooks_hooks_mock = MagicMock() +_stubs = { + "agentex.lib.adk": _adk_mock, + "agentex.lib.utils.logging": MagicMock(), + "agentex.lib.core.temporal.plugins.claude_agents.hooks": _hooks_mock, + "agentex.lib.core.temporal.plugins.claude_agents.hooks.hooks": _hooks_hooks_mock, + "agentex.lib.core.temporal.plugins.claude_agents.message_handler": _handler_mock, + "agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor": _interceptor_mock, +} +for _name, _mock in _stubs.items(): + sys.modules.setdefault(_name, _mock) + +# Also ensure parent packages exist as stubs so Python resolves the dotted path +_created_pkg_stubs: list[str] = [] +for _pkg in [ + "agentex.lib.core.temporal.plugins", + "agentex.lib.core.temporal.plugins.claude_agents", + "agentex.lib.core.temporal.plugins.openai_agents", + "agentex.lib.core.temporal.plugins.openai_agents.interceptors", +]: + if _pkg not in sys.modules: + _mod = ModuleType(_pkg) + _mod.__path__ = [] # type: ignore[attr-defined] + _mod.__package__ = _pkg + sys.modules[_pkg] = _mod + _created_pkg_stubs.append(_pkg) + +# Load activities.py directly from its file path +_spec = importlib.util.spec_from_file_location( + "agentex.lib.core.temporal.plugins.claude_agents.activities", + _ACTIVITIES_PATH, +) +assert _spec is not None and _spec.loader is not None +_activities_mod = importlib.util.module_from_spec(_spec) +sys.modules[_spec.name] = _activities_mod +_spec.loader.exec_module(_activities_mod) + +# Drop the placeholder packages now that the module is loaded: their empty +# __path__ would otherwise block real imports of agentex.lib.core.temporal.* +# submodules in every test collected after this file. The loaded module keeps +# its bindings. +for _pkg in _created_pkg_stubs: + sys.modules.pop(_pkg, None) + +_reconstruct_agent_defs = _activities_mod._reconstruct_agent_defs # type: ignore[attr-defined] +claude_options_to_dict = _activities_mod.claude_options_to_dict # type: ignore[attr-defined] + + +class TestClaudeOptionsToDict: + """Tests for claude_options_to_dict serialization helper.""" + + def test_basic_fields(self): + options = ClaudeAgentOptions( + cwd="/workspace", + allowed_tools=["Read", "Write"], + permission_mode="acceptEdits", + system_prompt="Be helpful.", + ) + result = claude_options_to_dict(options) + assert result["cwd"] == "/workspace" + assert result["allowed_tools"] == ["Read", "Write"] + assert result["permission_mode"] == "acceptEdits" + assert result["system_prompt"] == "Be helpful." + + def test_excludes_defaults(self): + """Fields left at their default value should not appear in the dict.""" + options = ClaudeAgentOptions(cwd="/workspace") + result = claude_options_to_dict(options) + assert "cwd" in result + # These are all defaults and should be absent + assert "continue_conversation" not in result + assert "include_partial_messages" not in result + assert "fork_session" not in result + assert "disallowed_tools" not in result + + def test_excludes_non_serializable_fields(self): + """Callbacks and file objects should never appear in the dict.""" + options = ClaudeAgentOptions( + cwd="/workspace", + can_use_tool=lambda *_: True, + stderr=lambda msg: None, + ) + result = claude_options_to_dict(options) + assert "can_use_tool" not in result + assert "stderr" not in result + assert "debug_stderr" not in result + assert "hooks" not in result + + def test_mcp_servers_included(self): + options = ClaudeAgentOptions( + cwd="/workspace", + mcp_servers={"my-server": {"command": "npx", "args": ["server"]}}, + ) + result = claude_options_to_dict(options) + assert result["mcp_servers"] == {"my-server": {"command": "npx", "args": ["server"]}} + + def test_agents_included(self): + agents = { + "reviewer": AgentDefinition( + description="Code reviewer", + prompt="Review code.", + tools=["Read"], + model="sonnet", + ) + } + options = ClaudeAgentOptions(cwd="/workspace", agents=agents) + result = claude_options_to_dict(options) + assert "agents" in result + assert "reviewer" in result["agents"] + + def test_model_and_budget_fields(self): + options = ClaudeAgentOptions( + cwd="/workspace", + model="opus", + max_turns=5, + max_budget_usd=1.0, + max_thinking_tokens=8000, + ) + result = claude_options_to_dict(options) + assert result["model"] == "opus" + assert result["max_turns"] == 5 + assert result["max_budget_usd"] == 1.0 + assert result["max_thinking_tokens"] == 8000 + + def test_resume_session(self): + options = ClaudeAgentOptions( + cwd="/workspace", + resume="session-abc-123", + ) + result = claude_options_to_dict(options) + assert result["resume"] == "session-abc-123" + + def test_roundtrip_constructs_options(self): + """The dict produced by claude_options_to_dict can construct a new ClaudeAgentOptions.""" + original = ClaudeAgentOptions( + cwd="/workspace", + allowed_tools=["Read", "Bash"], + permission_mode="acceptEdits", + model="sonnet", + max_turns=3, + ) + d = claude_options_to_dict(original) + reconstructed = ClaudeAgentOptions(**d) + assert reconstructed.cwd == original.cwd + assert reconstructed.allowed_tools == original.allowed_tools + assert reconstructed.permission_mode == original.permission_mode + assert reconstructed.model == original.model + assert reconstructed.max_turns == original.max_turns + + +class TestReconstructAgentDefs: + """Tests for _reconstruct_agent_defs helper.""" + + def test_none_input(self): + assert _reconstruct_agent_defs(None) is None + + def test_empty_dict(self): + assert _reconstruct_agent_defs({}) is None + + def test_already_agent_definitions(self): + agent = AgentDefinition(description="test", prompt="test prompt") + result = _reconstruct_agent_defs({"a": agent}) + assert result is not None + assert result["a"] is agent + + def test_dict_input(self): + """Temporal serializes dataclasses to dicts - verify reconstruction.""" + raw = { + "reviewer": { + "description": "Code reviewer", + "prompt": "Review code.", + "tools": ["Read", "Grep"], + "model": "sonnet", + } + } + result = _reconstruct_agent_defs(raw) + assert result is not None + assert isinstance(result["reviewer"], AgentDefinition) + assert result["reviewer"].description == "Code reviewer" + assert result["reviewer"].prompt == "Review code." + assert result["reviewer"].tools == ["Read", "Grep"] + assert result["reviewer"].model == "sonnet" + + def test_mixed_input(self): + """Mix of already-constructed and dict-serialized agents.""" + existing = AgentDefinition(description="existing", prompt="p") + raw = {"description": "from_dict", "prompt": "p2", "tools": None, "model": None} + result = _reconstruct_agent_defs({"a": existing, "b": raw}) + assert result is not None + assert isinstance(result["a"], AgentDefinition) + assert isinstance(result["b"], AgentDefinition) + assert result["a"].description == "existing" + assert result["b"].description == "from_dict" + + +class TestRunClaudeAgentActivity: + """Tests for the run_claude_agent_activity Temporal activity.""" + + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_task_id", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_trace_id", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_parent_span_id", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.ClaudeSDKClient", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.create_streaming_hooks", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.adk", + ) + async def test_passes_claude_options_to_sdk( + self, + _mock_adk, + mock_create_hooks, + mock_client_cls, + mock_parent_span_id, + mock_trace_id, + mock_task_id, + ): + """Verify that claude_options extras are merged into ClaudeAgentOptions.""" + from agentex.lib.core.temporal.plugins.claude_agents.activities import ( + run_claude_agent_activity, + ) + + # Set up context vars + mock_task_id.get.return_value = "task-1" + mock_trace_id.get.return_value = "trace-1" + mock_parent_span_id.get.return_value = "span-1" + + # Set up hooks + mock_create_hooks.return_value = {"PreToolUse": [], "PostToolUse": []} + + # Set up client as async context manager + mock_client = AsyncMock() + mock_client.receive_response = MagicMock(return_value=AsyncIteratorMock([])) + mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) + + # Extra SDK options passed via claude_options + extra = { + "model": "sonnet", + "mcp_servers": {"my-server": {"command": "npx", "args": ["srv"]}}, + } + + # activity.defn decorates in-place (no __wrapped__), call directly + await run_claude_agent_activity( + prompt="Hello", + workspace_path="/workspace", + allowed_tools=["Read"], + permission_mode="acceptEdits", + claude_options=extra, + ) + + # Verify ClaudeAgentOptions was constructed with both explicit + extra fields + call_args = mock_client_cls.call_args + options = call_args.kwargs.get("options") or call_args[1].get("options") + assert options.cwd == "/workspace" + assert options.allowed_tools == ["Read"] + assert options.model == "sonnet" + assert options.mcp_servers == {"my-server": {"command": "npx", "args": ["srv"]}} + + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_task_id", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_trace_id", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_parent_span_id", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.ClaudeSDKClient", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.create_streaming_hooks", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.adk", + ) + async def test_claude_options_not_masked_by_none_explicit_params( + self, + _mock_adk, + mock_create_hooks, + mock_client_cls, + mock_parent_span_id, + mock_trace_id, + mock_task_id, + ): + """claude_options values should not be silently dropped when explicit params are None.""" + from agentex.lib.core.temporal.plugins.claude_agents.activities import ( + run_claude_agent_activity, + ) + + mock_task_id.get.return_value = "task-1" + mock_trace_id.get.return_value = "trace-1" + mock_parent_span_id.get.return_value = "span-1" + mock_create_hooks.return_value = {"PreToolUse": [], "PostToolUse": []} + + mock_client = AsyncMock() + mock_client.receive_response = MagicMock(return_value=AsyncIteratorMock([])) + mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) + + # system_prompt explicit param is None (default), but claude_options has a value + await run_claude_agent_activity( + prompt="Hello", + workspace_path="/workspace", + allowed_tools=["Read"], + claude_options={"system_prompt": "Be helpful"}, + ) + + call_args = mock_client_cls.call_args + options = call_args.kwargs.get("options") or call_args[1].get("options") + assert options.system_prompt == "Be helpful" + + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_task_id", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_trace_id", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_parent_span_id", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.ClaudeSDKClient", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.create_streaming_hooks", + ) + @patch( + "agentex.lib.core.temporal.plugins.claude_agents.activities.adk", + ) + async def test_merges_user_hooks_with_streaming_hooks( + self, + _mock_adk, + mock_create_hooks, + mock_client_cls, + mock_parent_span_id, + mock_trace_id, + mock_task_id, + ): + """User-provided hooks in claude_options should be merged with streaming hooks.""" + from agentex.lib.core.temporal.plugins.claude_agents.activities import ( + run_claude_agent_activity, + ) + + mock_task_id.get.return_value = "task-1" + mock_trace_id.get.return_value = "trace-1" + mock_parent_span_id.get.return_value = "span-1" + + # Streaming hooks + streaming_pre = HookMatcher(matcher=None, hooks=[AsyncMock()]) + mock_create_hooks.return_value = {"PreToolUse": [streaming_pre]} + + mock_client = AsyncMock() + mock_client.receive_response = MagicMock(return_value=AsyncIteratorMock([])) + mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) + + # User-provided hook via claude_options + user_pre = HookMatcher(matcher="Bash", hooks=[AsyncMock()]) + + await run_claude_agent_activity( + prompt="Hello", + workspace_path="/workspace", + allowed_tools=["Read"], + claude_options={"hooks": {"PreToolUse": [user_pre]}}, + ) + + call_args = mock_client_cls.call_args + options = call_args.kwargs.get("options") or call_args[1].get("options") + # Should have both streaming and user hooks merged + assert len(options.hooks["PreToolUse"]) == 2 + + +class _AsyncCtxManager: + """Simple async context manager that yields a given value.""" + + def __init__(self, value): + self.value = value + self.exited = False + + async def __aenter__(self): + return self.value + + async def __aexit__(self, *args): + self.exited = True + + +def _setup_activity_mocks( + mock_adk, mock_create_hooks, mock_client_cls, mock_task_id, mock_trace_id, mock_parent_span_id, messages +): + """Common setup for activity tests that send messages through the client.""" + mock_task_id.get.return_value = "task-1" + mock_trace_id.get.return_value = "trace-1" + mock_parent_span_id.get.return_value = "span-1" + mock_create_hooks.return_value = {"PreToolUse": [], "PostToolUse": [], "PostToolUseFailure": []} + + mock_client = AsyncMock() + mock_client.receive_response = MagicMock(return_value=AsyncIteratorMock(messages)) + mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) + + return mock_adk + + +_ACTIVITY_PATCHES = [ + "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_task_id", + "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_trace_id", + "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_parent_span_id", + "agentex.lib.core.temporal.plugins.claude_agents.activities.ClaudeSDKClient", + "agentex.lib.core.temporal.plugins.claude_agents.activities.create_streaming_hooks", + "agentex.lib.core.temporal.plugins.claude_agents.activities.adk", +] + + +class TestActivityMessageHandling: + """Tests for run_claude_agent_activity message streaming behavior.""" + + @patch(_ACTIVITY_PATCHES[0]) + @patch(_ACTIVITY_PATCHES[1]) + @patch(_ACTIVITY_PATCHES[2]) + @patch(_ACTIVITY_PATCHES[3]) + @patch(_ACTIVITY_PATCHES[4]) + @patch(_ACTIVITY_PATCHES[5]) + async def test_text_block_opens_and_closes_streaming_context( + self, + mock_adk, + mock_create_hooks, + mock_client_cls, + mock_parent_span_id, + mock_trace_id, + mock_task_id, + ): + """TextBlock should open a text streaming CM, use it for deltas, and close it via __aexit__.""" + from agentex.lib.core.temporal.plugins.claude_agents.activities import run_claude_agent_activity + + text_ctx = MagicMock() + text_ctx.task_message = MagicMock() + text_ctx.stream_update = AsyncMock() + text_cm = _AsyncCtxManager(text_ctx) + + mock_adk = _setup_activity_mocks( + mock_adk, + mock_create_hooks, + mock_client_cls, + mock_task_id, + mock_trace_id, + mock_parent_span_id, + messages=[AssistantMessage(content=[TextBlock(text="Hello world")], model="claude")], + ) + mock_adk.streaming.streaming_task_message_context.return_value = text_cm + + result = await run_claude_agent_activity(prompt="Hi", workspace_path="/ws", allowed_tools=["Read"]) + + # streaming_task_message_context was called to open the text stream + mock_adk.streaming.streaming_task_message_context.assert_called() + # The CM was entered and then exited via close_text_stream (uses __aexit__) + assert text_cm.exited is True + # Result includes the text in serialized_messages + assert len(result["messages"]) == 1 + assert result["messages"][0]["content"] == "Hello world" + + @patch(_ACTIVITY_PATCHES[0]) + @patch(_ACTIVITY_PATCHES[1]) + @patch(_ACTIVITY_PATCHES[2]) + @patch(_ACTIVITY_PATCHES[3]) + @patch(_ACTIVITY_PATCHES[4]) + @patch(_ACTIVITY_PATCHES[5]) + async def test_tool_use_block_closes_text_stream_first( + self, + mock_adk, + mock_create_hooks, + mock_client_cls, + mock_parent_span_id, + mock_trace_id, + mock_task_id, + ): + """ToolUseBlock should close any open text stream before streaming the tool request.""" + from agentex.lib.core.temporal.plugins.claude_agents.activities import run_claude_agent_activity + + text_ctx = MagicMock() + text_ctx.task_message = MagicMock() + text_ctx.stream_update = AsyncMock() + text_cm = _AsyncCtxManager(text_ctx) + + # Tool request CM + tool_ctx = MagicMock() + tool_ctx.task_message = MagicMock() + tool_ctx.stream_update = AsyncMock() + tool_cm = _AsyncCtxManager(tool_ctx) + + mock_adk = _setup_activity_mocks( + mock_adk, + mock_create_hooks, + mock_client_cls, + mock_task_id, + mock_trace_id, + mock_parent_span_id, + messages=[ + AssistantMessage( + content=[ + TextBlock(text="Let me check"), + ToolUseBlock(id="tu-1", name="Read", input={"file": "foo.py"}), + ], + model="claude", + ), + ], + ) + # First call returns text CM, second returns tool CM + mock_adk.streaming.streaming_task_message_context.side_effect = [text_cm, tool_cm] + + await run_claude_agent_activity(prompt="Hi", workspace_path="/ws", allowed_tools=["Read"]) + + # Text CM was closed (via __aexit__) before tool streaming started + assert text_cm.exited is True + assert mock_adk.streaming.streaming_task_message_context.call_count == 2 + + @patch(_ACTIVITY_PATCHES[0]) + @patch(_ACTIVITY_PATCHES[1]) + @patch(_ACTIVITY_PATCHES[2]) + @patch(_ACTIVITY_PATCHES[3]) + @patch(_ACTIVITY_PATCHES[4]) + @patch(_ACTIVITY_PATCHES[5]) + async def test_result_message_captures_session_and_cost( + self, + mock_adk, + mock_create_hooks, + mock_client_cls, + mock_parent_span_id, + mock_trace_id, + mock_task_id, + ): + """ResultMessage should capture session_id, usage, and cost.""" + from agentex.lib.core.temporal.plugins.claude_agents.activities import run_claude_agent_activity + + _setup_activity_mocks( + mock_adk, + mock_create_hooks, + mock_client_cls, + mock_task_id, + mock_trace_id, + mock_parent_span_id, + messages=[ + ResultMessage( + subtype="result", + duration_ms=1500, + duration_api_ms=1200, + is_error=False, + num_turns=3, + session_id="sess-abc", + total_cost_usd=0.05, + usage={"input_tokens": 100, "output_tokens": 50}, + ), + ], + ) + + result = await run_claude_agent_activity(prompt="Hi", workspace_path="/ws", allowed_tools=["Read"]) + + assert result["session_id"] == "sess-abc" + assert result["cost_usd"] == 0.05 + assert result["usage"] == {"input_tokens": 100, "output_tokens": 50} + + @patch(_ACTIVITY_PATCHES[0]) + @patch(_ACTIVITY_PATCHES[1]) + @patch(_ACTIVITY_PATCHES[2]) + @patch(_ACTIVITY_PATCHES[3]) + @patch(_ACTIVITY_PATCHES[4]) + @patch(_ACTIVITY_PATCHES[5]) + async def test_system_init_message_captures_session_id( + self, + mock_adk, + mock_create_hooks, + mock_client_cls, + mock_parent_span_id, + mock_trace_id, + mock_task_id, + ): + """SystemMessage with subtype 'init' should capture session_id.""" + from agentex.lib.core.temporal.plugins.claude_agents.activities import run_claude_agent_activity + + _setup_activity_mocks( + mock_adk, + mock_create_hooks, + mock_client_cls, + mock_task_id, + mock_trace_id, + mock_parent_span_id, + messages=[ + SystemMessage(subtype="init", data={"session_id": "sess-init-123"}), + ], + ) + + result = await run_claude_agent_activity(prompt="Hi", workspace_path="/ws", allowed_tools=["Read"]) + + assert result["session_id"] == "sess-init-123" + + @patch(_ACTIVITY_PATCHES[0]) + @patch(_ACTIVITY_PATCHES[1]) + @patch(_ACTIVITY_PATCHES[2]) + @patch(_ACTIVITY_PATCHES[3]) + @patch(_ACTIVITY_PATCHES[4]) + @patch(_ACTIVITY_PATCHES[5]) + async def test_exception_cleans_up_text_stream_and_subagent_spans( + self, + mock_adk, + mock_create_hooks, + mock_client_cls, + mock_parent_span_id, + mock_trace_id, + mock_task_id, + ): + """On exception, both text stream and subagent spans should be cleaned up.""" + from agentex.lib.core.temporal.plugins.claude_agents.activities import run_claude_agent_activity + + mock_adk = _setup_activity_mocks( + mock_adk, + mock_create_hooks, + mock_client_cls, + mock_task_id, + mock_trace_id, + mock_parent_span_id, + messages=[], + ) + + # Make the client raise after entering + mock_client = AsyncMock() + mock_client.receive_response = MagicMock(side_effect=RuntimeError("connection lost")) + mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) + + import pytest + + with pytest.raises(RuntimeError, match="connection lost"): + await run_claude_agent_activity(prompt="Hi", workspace_path="/ws", allowed_tools=["Read"]) + + @patch(_ACTIVITY_PATCHES[0]) + @patch(_ACTIVITY_PATCHES[1]) + @patch(_ACTIVITY_PATCHES[2]) + @patch(_ACTIVITY_PATCHES[3]) + @patch(_ACTIVITY_PATCHES[4]) + @patch(_ACTIVITY_PATCHES[5]) + async def test_empty_text_block_not_serialized( + self, + mock_adk, + mock_create_hooks, + mock_client_cls, + mock_parent_span_id, + mock_trace_id, + mock_task_id, + ): + """TextBlock with empty text should not appear in serialized messages.""" + from agentex.lib.core.temporal.plugins.claude_agents.activities import run_claude_agent_activity + + _setup_activity_mocks( + mock_adk, + mock_create_hooks, + mock_client_cls, + mock_task_id, + mock_trace_id, + mock_parent_span_id, + messages=[AssistantMessage(content=[TextBlock(text="")], model="claude")], + ) + + result = await run_claude_agent_activity(prompt="Hi", workspace_path="/ws", allowed_tools=["Read"]) + + assert result["messages"] == [] + + +class AsyncIteratorMock: + """Helper to mock an async iterator (for client.receive_response()).""" + + def __init__(self, items): + self._items = iter(items) + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self._items) + except StopIteration: + raise StopAsyncIteration from None diff --git a/tests/lib/test_claude_agents_hooks.py b/tests/lib/test_claude_agents_hooks.py new file mode 100644 index 000000000..ec82b16db --- /dev/null +++ b/tests/lib/test_claude_agents_hooks.py @@ -0,0 +1,398 @@ +"""Tests for Claude Agents SDK streaming hooks. + +These tests validate the TemporalStreamingHooks class and +create_streaming_hooks factory from the hooks module. +""" + +from __future__ import annotations + +import sys +import importlib.util +from types import ModuleType +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from claude_agent_sdk.types import HookMatcher + +# --------------------------------------------------------------------------- +# Module loading: same technique as test_claude_agents_activities.py +# We load hooks.py directly to avoid triggering the plugins __init__.py chain +# which pulls in langchain_core/langgraph (optional deps). +# --------------------------------------------------------------------------- + +_SRC = Path(__file__).resolve().parents[2] / "src" +_HOOKS_PATH = _SRC / "agentex" / "lib" / "core" / "temporal" / "plugins" / "claude_agents" / "hooks" / "hooks.py" + +# Stub external modules that hooks.py imports so the module can load. +for _name in ["agentex.lib.adk", "agentex.lib.utils.logging"]: + sys.modules.setdefault(_name, MagicMock()) + +# Ensure parent packages exist so the dotted path resolves +_created_pkg_stubs: list[str] = [] +for _pkg in [ + "agentex", + "agentex.lib", + "agentex.lib.core", + "agentex.lib.core.temporal", + "agentex.lib.core.temporal.plugins", + "agentex.lib.core.temporal.plugins.claude_agents", + "agentex.lib.core.temporal.plugins.claude_agents.hooks", +]: + if _pkg not in sys.modules: + _mod = ModuleType(_pkg) + _mod.__path__ = [] # type: ignore[attr-defined] + _mod.__package__ = _pkg + sys.modules[_pkg] = _mod + _created_pkg_stubs.append(_pkg) + +# Real pydantic types — importable without triggering the problematic chain. +from agentex.types.tool_response_content import ToolResponseContent # noqa: E402 + +# Load the hooks module directly +_spec = importlib.util.spec_from_file_location( + "agentex.lib.core.temporal.plugins.claude_agents.hooks.hooks", + _HOOKS_PATH, +) +assert _spec is not None and _spec.loader is not None +_hooks_mod = importlib.util.module_from_spec(_spec) +sys.modules[_spec.name] = _hooks_mod +_spec.loader.exec_module(_hooks_mod) + +# Drop the placeholder packages now that the module is loaded: their empty +# __path__ would otherwise block real imports of agentex.* submodules in every +# test collected after this file. The loaded module keeps its bindings. +for _pkg in _created_pkg_stubs: + sys.modules.pop(_pkg, None) + +TemporalStreamingHooks = _hooks_mod.TemporalStreamingHooks +create_streaming_hooks = _hooks_mod.create_streaming_hooks + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class _AsyncCtxManager: + """Simple async context manager that yields a given value.""" + + def __init__(self, value): + self.value = value + + async def __aenter__(self): + return self.value + + async def __aexit__(self, *args): + pass + + +def _make_adk_mock(): + """Create a fresh adk mock with streaming context manager wired up. + + Returns (adk_mock, tool_ctx) where tool_ctx is the mock yielded by + `async with adk.streaming.streaming_task_message_context(...)`. + """ + tool_ctx = AsyncMock() + tool_ctx.task_message = MagicMock(name="task_message") + tool_ctx.stream_update = AsyncMock() + + adk_mock = MagicMock() + adk_mock.streaming.streaming_task_message_context.return_value = _AsyncCtxManager(tool_ctx) + return adk_mock, tool_ctx + + +def _make_post_tool_input(tool_name: str, tool_use_id: str, tool_response: str = "") -> dict: + """Build a HookInput dict for PostToolUse.""" + return { + "hook_event_name": "PostToolUse", + "tool_name": tool_name, + "tool_use_id": tool_use_id, + "tool_response": tool_response, + } + + +def _make_failure_input(tool_name: str, tool_use_id: str, error: str) -> dict: + """Build a HookInput dict for PostToolUseFailure.""" + return { + "hook_event_name": "PostToolUseFailure", + "tool_name": tool_name, + "tool_use_id": tool_use_id, + "error": error, + } + + +# --------------------------------------------------------------------------- +# Tests: TemporalStreamingHooks.__init__ +# --------------------------------------------------------------------------- + + +class TestTemporalStreamingHooksInit: + def test_stores_task_id(self): + hooks = TemporalStreamingHooks(task_id="task-1") + assert hooks.task_id == "task-1" + + def test_stores_trace_and_parent_span(self): + hooks = TemporalStreamingHooks(task_id="task-1", trace_id="trace-1", parent_span_id="span-1") + assert hooks.trace_id == "trace-1" + assert hooks.parent_span_id == "span-1" + + def test_defaults_to_none(self): + hooks = TemporalStreamingHooks(task_id=None) + assert hooks.task_id is None + assert hooks.trace_id is None + assert hooks.parent_span_id is None + + def test_subagent_spans_initialized_empty(self): + hooks = TemporalStreamingHooks(task_id="task-1") + assert hooks.subagent_spans == {} + + def test_accepts_shared_subagent_spans(self): + shared = {"tu-1": ("ctx", "span")} + hooks = TemporalStreamingHooks(task_id="task-1", subagent_spans=shared) + assert hooks.subagent_spans is shared + + +# --------------------------------------------------------------------------- +# Tests: auto_allow_hook (PreToolUse) +# --------------------------------------------------------------------------- + + +class TestAutoAllowHook: + @pytest.mark.asyncio + async def test_returns_allow_decision(self): + hooks = TemporalStreamingHooks(task_id="task-1") + result = await hooks.auto_allow_hook( + _input_data={}, + _tool_use_id="tu-1", + _context=None, + ) + assert result["continue_"] is True + assert result["hookSpecificOutput"]["permissionDecision"] == "allow" + + @pytest.mark.asyncio + async def test_works_without_task_id(self): + hooks = TemporalStreamingHooks(task_id=None) + result = await hooks.auto_allow_hook( + _input_data={}, + _tool_use_id=None, + _context=None, + ) + assert result["continue_"] is True + + +# --------------------------------------------------------------------------- +# Tests: post_tool_use_hook (PostToolUse) +# --------------------------------------------------------------------------- + + +class TestPostToolUseHook: + @pytest.mark.asyncio + async def test_skips_wrong_event_name(self): + hooks = TemporalStreamingHooks(task_id="task-1") + result = await hooks.post_tool_use_hook( + input_data={"hook_event_name": "PreToolUse", "tool_name": "Read", "tool_use_id": "tu-1"}, + _tool_use_id="tu-1", + _context=None, + ) + assert result["continue_"] is True + + @pytest.mark.asyncio + async def test_skips_when_no_task_id(self): + hooks = TemporalStreamingHooks(task_id=None) + result = await hooks.post_tool_use_hook( + input_data=_make_post_tool_input("Read", "tu-1", "contents"), + _tool_use_id="tu-1", + _context=None, + ) + assert result["continue_"] is True + + @pytest.mark.asyncio + async def test_streams_tool_response(self): + adk_mock, _ = _make_adk_mock() + + hooks = TemporalStreamingHooks(task_id="task-1") + with patch.object(_hooks_mod, "adk", adk_mock): + result = await hooks.post_tool_use_hook( + input_data=_make_post_tool_input("Read", "tu-1", "file contents"), + _tool_use_id="tu-1", + _context=None, + ) + + assert result["continue_"] is True + adk_mock.streaming.streaming_task_message_context.assert_called_once() + call_kwargs = adk_mock.streaming.streaming_task_message_context.call_args.kwargs + assert call_kwargs["task_id"] == "task-1" + assert isinstance(call_kwargs["initial_content"], ToolResponseContent) + assert call_kwargs["initial_content"].name == "Read" + assert call_kwargs["initial_content"].content == "file contents" + assert call_kwargs["initial_content"].tool_call_id == "tu-1" + + @pytest.mark.asyncio + async def test_closes_subagent_span(self): + adk_mock, _ = _make_adk_mock() + + span_mock = MagicMock() + span_ctx = AsyncMock() + span_ctx.__aexit__ = AsyncMock(return_value=False) + + subagent_spans = {"tu-sub-1": (span_ctx, span_mock)} + hooks = TemporalStreamingHooks(task_id="task-1", subagent_spans=subagent_spans) + + with patch.object(_hooks_mod, "adk", adk_mock): + await hooks.post_tool_use_hook( + input_data=_make_post_tool_input("Task", "tu-sub-1", "result"), + _tool_use_id="tu-sub-1", + _context=None, + ) + + assert span_mock.output == {"result": "result"} + span_ctx.__aexit__.assert_awaited_once_with(None, None, None) + assert "tu-sub-1" not in subagent_spans + + @pytest.mark.asyncio + async def test_streaming_failure_does_not_raise(self): + adk_mock = MagicMock() + adk_mock.streaming.streaming_task_message_context.side_effect = RuntimeError("down") + + hooks = TemporalStreamingHooks(task_id="task-1") + with patch.object(_hooks_mod, "adk", adk_mock): + result = await hooks.post_tool_use_hook( + input_data=_make_post_tool_input("Bash", "tu-1", "output"), + _tool_use_id="tu-1", + _context=None, + ) + assert result["continue_"] is True + + +# --------------------------------------------------------------------------- +# Tests: post_tool_use_failure_hook (PostToolUseFailure) +# --------------------------------------------------------------------------- + + +class TestPostToolUseFailureHook: + @pytest.mark.asyncio + async def test_skips_wrong_event_name(self): + hooks = TemporalStreamingHooks(task_id="task-1") + result = await hooks.post_tool_use_failure_hook( + input_data={"hook_event_name": "PostToolUse", "tool_name": "Read", "tool_use_id": "tu-1"}, + _tool_use_id="tu-1", + _context=None, + ) + assert result["continue_"] is True + + @pytest.mark.asyncio + async def test_skips_when_no_task_id(self): + hooks = TemporalStreamingHooks(task_id=None) + result = await hooks.post_tool_use_failure_hook( + input_data=_make_failure_input("Read", "tu-1", "permission denied"), + _tool_use_id="tu-1", + _context=None, + ) + assert result["continue_"] is True + + @pytest.mark.asyncio + async def test_streams_error_response(self): + adk_mock, _ = _make_adk_mock() + + hooks = TemporalStreamingHooks(task_id="task-1") + with patch.object(_hooks_mod, "adk", adk_mock): + result = await hooks.post_tool_use_failure_hook( + input_data=_make_failure_input("Bash", "tu-1", "command not found"), + _tool_use_id="tu-1", + _context=None, + ) + + assert result["continue_"] is True + adk_mock.streaming.streaming_task_message_context.assert_called_once() + call_kwargs = adk_mock.streaming.streaming_task_message_context.call_args.kwargs + assert call_kwargs["task_id"] == "task-1" + assert isinstance(call_kwargs["initial_content"], ToolResponseContent) + assert call_kwargs["initial_content"].name == "Bash" + assert call_kwargs["initial_content"].content == "Error: command not found" + assert call_kwargs["initial_content"].tool_call_id == "tu-1" + + @pytest.mark.asyncio + async def test_closes_subagent_span_on_failure(self): + adk_mock, _ = _make_adk_mock() + + span_mock = MagicMock() + span_ctx = AsyncMock() + span_ctx.__aexit__ = AsyncMock(return_value=False) + + subagent_spans = {"tu-sub-1": (span_ctx, span_mock)} + hooks = TemporalStreamingHooks(task_id="task-1", subagent_spans=subagent_spans) + + with patch.object(_hooks_mod, "adk", adk_mock): + await hooks.post_tool_use_failure_hook( + input_data=_make_failure_input("Task", "tu-sub-1", "timeout"), + _tool_use_id="tu-sub-1", + _context=None, + ) + + assert span_mock.output == {"error": "timeout"} + span_ctx.__aexit__.assert_awaited_once_with(None, None, None) + assert "tu-sub-1" not in subagent_spans + + @pytest.mark.asyncio + async def test_streaming_failure_does_not_raise(self): + adk_mock = MagicMock() + adk_mock.streaming.streaming_task_message_context.side_effect = RuntimeError("down") + + hooks = TemporalStreamingHooks(task_id="task-1") + with patch.object(_hooks_mod, "adk", adk_mock): + result = await hooks.post_tool_use_failure_hook( + input_data=_make_failure_input("Bash", "tu-1", "oops"), + _tool_use_id="tu-1", + _context=None, + ) + assert result["continue_"] is True + + +# --------------------------------------------------------------------------- +# Tests: create_streaming_hooks factory +# --------------------------------------------------------------------------- + + +class TestCreateStreamingHooks: + def test_returns_all_three_hook_events(self): + result = create_streaming_hooks(task_id="task-1") + assert "PreToolUse" in result + assert "PostToolUse" in result + assert "PostToolUseFailure" in result + + def test_each_key_has_one_hook_matcher(self): + result = create_streaming_hooks(task_id="task-1") + for key in ("PreToolUse", "PostToolUse", "PostToolUseFailure"): + assert len(result[key]) == 1 + assert isinstance(result[key][0], HookMatcher) + + def test_matchers_match_all_tools(self): + result = create_streaming_hooks(task_id="task-1") + for key in ("PreToolUse", "PostToolUse", "PostToolUseFailure"): + assert result[key][0].matcher is None + + def test_hooks_are_callable(self): + result = create_streaming_hooks(task_id="task-1") + for key in ("PreToolUse", "PostToolUse", "PostToolUseFailure"): + assert len(result[key][0].hooks) == 1 + assert callable(result[key][0].hooks[0]) + + def test_all_hooks_share_same_instance(self): + result = create_streaming_hooks(task_id="task-1", trace_id="trace-1", parent_span_id="span-1") + instances = {result[key][0].hooks[0].__self__ for key in ("PreToolUse", "PostToolUse", "PostToolUseFailure")} + assert len(instances) == 1 + instance = instances.pop() + assert instance.task_id == "task-1" + assert instance.trace_id == "trace-1" + assert instance.parent_span_id == "span-1" + + def test_passes_shared_subagent_spans(self): + shared = {} + result = create_streaming_hooks(task_id="task-1", subagent_spans=shared) + instance = result["PreToolUse"][0].hooks[0].__self__ + assert instance.subagent_spans is shared + + def test_none_task_id_still_creates_hooks(self): + result = create_streaming_hooks(task_id=None) + assert "PreToolUse" in result diff --git a/tests/lib/test_metadata_filters.py b/tests/lib/test_metadata_filters.py new file mode 100644 index 000000000..34398187f --- /dev/null +++ b/tests/lib/test_metadata_filters.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import json + +import httpx +import respx +import pytest + +from agentex import Agentex, AsyncAgentex +from agentex.lib.utils.metadata_filters import encode_metadata_filter + +BASE_URL = "http://127.0.0.1:4010" +API_KEY = "My API Key" + + +class TestEncodeMetadataFilter: + def test_encodes_a_json_object(self) -> None: + assert encode_metadata_filter({"permits_capable": True}) == '{"permits_capable":true}' + + def test_empty_mapping_encodes_to_an_empty_object(self) -> None: + assert encode_metadata_filter({}) == "{}" + + def test_key_order_is_stable(self) -> None: + assert ( + encode_metadata_filter({"region": "us", "permits_capable": True}) + == encode_metadata_filter({"permits_capable": True, "region": "us"}) + == '{"permits_capable":true,"region":"us"}' + ) + + def test_preserves_json_types_and_nesting(self) -> None: + encoded = encode_metadata_filter({"flag": True, "count": 3, "ratio": 1.5, "nested": {"a": [1, "two", None]}}) + assert json.loads(encoded) == { + "flag": True, + "count": 3, + "ratio": 1.5, + "nested": {"a": [1, "two", None]}, + } + + @pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")]) + def test_rejects_non_finite_floats(self, value: float) -> None: + # The server rejects these with a 400; fail locally with a clearer message. + with pytest.raises(ValueError, match="not encodable as JSON"): + encode_metadata_filter({"x": value}) + + def test_rejects_a_non_mapping(self) -> None: + with pytest.raises(TypeError, match="must be a mapping"): + encode_metadata_filter([("permits_capable", True)]) # type: ignore[arg-type] + + def test_rejects_a_non_serializable_value(self) -> None: + with pytest.raises(TypeError): + encode_metadata_filter({"x": object()}) + + +class TestAgentCardMetadataOnTheWire: + """The encoded filter has to survive the client's query-string serialization. + + The generated `agents.list` parameter is a plain `str` (the platform spec + declares a JSON-encoded string, matching the shipped `task_metadata` + filter), so these assert the exact query value the server will parse. + """ + + @respx.mock(base_url=BASE_URL) + def test_sync_client_sends_the_encoded_object(self, respx_mock: respx.MockRouter) -> None: + route = respx_mock.get("/agents").mock(return_value=httpx.Response(200, json=[])) + + with Agentex(base_url=BASE_URL, api_key=API_KEY, _strict_response_validation=True) as client: + client.agents.list( + agent_card_metadata=encode_metadata_filter({"permits_capable": True, "region": "us"}), + limit=5, + ) + + params = route.calls.last.request.url.params + raw = params["agent_card_metadata"] + assert raw == '{"permits_capable":true,"region":"us"}' + assert json.loads(raw) == {"permits_capable": True, "region": "us"} + assert params["limit"] == "5" + + @respx.mock(base_url=BASE_URL) + async def test_async_client_sends_the_encoded_object(self, respx_mock: respx.MockRouter) -> None: + route = respx_mock.get("/agents").mock(return_value=httpx.Response(200, json=[])) + + async with AsyncAgentex(base_url=BASE_URL, api_key=API_KEY, _strict_response_validation=True) as client: + await client.agents.list( + agent_card_metadata=encode_metadata_filter({"permits_capable": True, "region": "us"}), + limit=5, + ) + + params = route.calls.last.request.url.params + raw = params["agent_card_metadata"] + assert raw == '{"permits_capable":true,"region":"us"}' + assert json.loads(raw) == {"permits_capable": True, "region": "us"} + assert params["limit"] == "5" + + @respx.mock(base_url=BASE_URL) + def test_omitted_filter_is_absent_from_the_query(self, respx_mock: respx.MockRouter) -> None: + route = respx_mock.get("/agents").mock(return_value=httpx.Response(200, json=[])) + + with Agentex(base_url=BASE_URL, api_key=API_KEY, _strict_response_validation=True) as client: + client.agents.list() + + assert "agent_card_metadata" not in route.calls.last.request.url.params + + @respx.mock(base_url=BASE_URL) + def test_empty_object_filter_is_sent_verbatim(self, respx_mock: respx.MockRouter) -> None: + """`{}` is a meaningful filter server-side (agent must have card metadata), + so it must reach the wire rather than being dropped as falsy.""" + route = respx_mock.get("/agents").mock(return_value=httpx.Response(200, json=[])) + + with Agentex(base_url=BASE_URL, api_key=API_KEY, _strict_response_validation=True) as client: + client.agents.list(agent_card_metadata=encode_metadata_filter({})) + + assert route.calls.last.request.url.params["agent_card_metadata"] == "{}" diff --git a/tests/lib/test_payload_codec.py b/tests/lib/test_payload_codec.py new file mode 100644 index 000000000..59736dc21 --- /dev/null +++ b/tests/lib/test_payload_codec.py @@ -0,0 +1,385 @@ +from __future__ import annotations + +from typing import Any, override +from unittest.mock import AsyncMock, patch + +import pytest +from temporalio.client import Client, Plugin as ClientPlugin +from temporalio.converter import ( + PayloadCodec, + DataConverter, + DefaultPayloadConverter, +) +from temporalio.contrib.pydantic import pydantic_data_converter + + +class _NoopCodec(PayloadCodec): + @override + async def encode(self, payloads): + return list(payloads) + + @override + async def decode(self, payloads): + return list(payloads) + + +class _FakeOpenAIPlugin(ClientPlugin): + @override + def configure_client(self, config): + return config + + @override + async def connect_service_client(self, config, next): + return await next(config) + + +def _mock_connect(): + return patch.object(Client, "connect", new=AsyncMock(return_value=object())) + + +def _patch_openai_plugin(): + return patch("temporalio.contrib.openai_agents.OpenAIAgentsPlugin", _FakeOpenAIPlugin) + + +class TestTemporalClient: + def test_init_stores_payload_codec(self): + from agentex.lib.core.clients.temporal.temporal_client import TemporalClient + + codec = _NoopCodec() + client = TemporalClient(payload_codec=codec) + assert client._payload_codec is codec + + def test_init_default_payload_codec_is_none(self): + from agentex.lib.core.clients.temporal.temporal_client import TemporalClient + + assert TemporalClient()._payload_codec is None + + async def test_create_with_disabled_address_stores_codec(self): + from agentex.lib.core.clients.temporal.temporal_client import TemporalClient + + codec = _NoopCodec() + client = await TemporalClient.create(temporal_address="false", payload_codec=codec) + assert client._client is None + assert client._payload_codec is codec + + async def test_create_propagates_codec_to_get_temporal_client(self): + import agentex.lib.core.clients.temporal.temporal_client as module + + codec = _NoopCodec() + with patch.object(module, "get_temporal_client", new=AsyncMock(return_value=object())) as mock_get: + await module.TemporalClient.create(temporal_address="localhost:7233", plugins=[], payload_codec=codec) + + mock_get.assert_awaited_once() + assert mock_get.await_args.kwargs["payload_codec"] is codec + + def test_init_stores_data_converter(self): + from agentex.lib.core.clients.temporal.temporal_client import TemporalClient + + dc = DataConverter(payload_codec=_NoopCodec()) + client = TemporalClient(data_converter=dc) + assert client._data_converter is dc + + def test_init_default_data_converter_is_none(self): + from agentex.lib.core.clients.temporal.temporal_client import TemporalClient + + assert TemporalClient()._data_converter is None + + async def test_create_propagates_data_converter_to_get_temporal_client(self): + import agentex.lib.core.clients.temporal.temporal_client as module + + dc = DataConverter(payload_codec=_NoopCodec()) + with patch.object(module, "get_temporal_client", new=AsyncMock(return_value=object())) as mock_get: + await module.TemporalClient.create(temporal_address="localhost:7233", plugins=[], data_converter=dc) + + mock_get.assert_awaited_once() + assert mock_get.await_args.kwargs["data_converter"] is dc + + +class TestGetTemporalClientUtils: + async def test_no_codec_uses_pydantic_data_converter_unchanged(self): + from agentex.lib.core.clients.temporal.utils import get_temporal_client + + with _mock_connect() as mock_connect: + await get_temporal_client(temporal_address="localhost:7233") + + kwargs = mock_connect.await_args.kwargs + assert kwargs["data_converter"] is pydantic_data_converter + assert kwargs["data_converter"].payload_codec is None + + async def test_codec_is_attached_to_pydantic_data_converter(self): + from agentex.lib.core.clients.temporal.utils import get_temporal_client + + codec = _NoopCodec() + with _mock_connect() as mock_connect: + await get_temporal_client(temporal_address="localhost:7233", payload_codec=codec) + + data_converter = mock_connect.await_args.kwargs["data_converter"] + assert data_converter.payload_codec is codec + assert data_converter.payload_converter_class is pydantic_data_converter.payload_converter_class + + async def test_codec_with_openai_plugin_raises(self): + from agentex.lib.core.clients.temporal.utils import get_temporal_client + + codec = _NoopCodec() + with _patch_openai_plugin(), _mock_connect() as mock_connect: + with pytest.raises(ValueError, match="silently dropped by the plugin's data-converter transformer"): + await get_temporal_client( + temporal_address="localhost:7233", + plugins=[_FakeOpenAIPlugin()], + payload_codec=codec, + ) + mock_connect.assert_not_awaited() + + async def test_openai_plugin_without_codec_omits_data_converter(self): + from agentex.lib.core.clients.temporal.utils import get_temporal_client + + with _patch_openai_plugin(), _mock_connect() as mock_connect: + await get_temporal_client(temporal_address="localhost:7233", plugins=[_FakeOpenAIPlugin()]) + + assert "data_converter" not in mock_connect.await_args.kwargs + + async def test_data_converter_passthrough_with_openai_plugin(self): + from agentex.lib.core.clients.temporal.utils import get_temporal_client + + dc = DataConverter(payload_codec=_NoopCodec()) + with _patch_openai_plugin(), _mock_connect() as mock_connect: + await get_temporal_client( + temporal_address="localhost:7233", + plugins=[_FakeOpenAIPlugin()], + data_converter=dc, + ) + + assert mock_connect.await_args.kwargs["data_converter"] is dc + + async def test_data_converter_passthrough_without_openai_plugin(self): + from agentex.lib.core.clients.temporal.utils import get_temporal_client + + dc = DataConverter(payload_converter_class=DefaultPayloadConverter) + with _mock_connect() as mock_connect: + await get_temporal_client(temporal_address="localhost:7233", data_converter=dc) + + assert mock_connect.await_args.kwargs["data_converter"] is dc + + async def test_codec_and_data_converter_together_raises(self): + from agentex.lib.core.clients.temporal.utils import get_temporal_client + + codec = _NoopCodec() + dc = DataConverter(payload_codec=codec) + with _mock_connect() as mock_connect: + with pytest.raises(ValueError, match="Pass payload_codec inside `data_converter`"): + await get_temporal_client( + temporal_address="localhost:7233", + payload_codec=codec, + data_converter=dc, + ) + mock_connect.assert_not_awaited() + + +class TestGetTemporalClientWorker: + async def test_no_codec_uses_custom_data_converter_unchanged(self): + from agentex.lib.core.temporal.workers.worker import get_temporal_client, custom_data_converter + + with _mock_connect() as mock_connect: + await get_temporal_client(temporal_address="localhost:7233") + + kwargs = mock_connect.await_args.kwargs + assert kwargs["data_converter"] is custom_data_converter + assert kwargs["data_converter"].payload_codec is None + + async def test_codec_is_attached_to_custom_data_converter(self): + from agentex.lib.core.temporal.workers.worker import get_temporal_client, custom_data_converter + + codec = _NoopCodec() + with _mock_connect() as mock_connect: + await get_temporal_client(temporal_address="localhost:7233", payload_codec=codec) + + data_converter = mock_connect.await_args.kwargs["data_converter"] + assert data_converter.payload_codec is codec + assert data_converter.payload_converter_class is custom_data_converter.payload_converter_class + + async def test_codec_with_openai_plugin_raises(self): + from agentex.lib.core.temporal.workers.worker import get_temporal_client + + codec = _NoopCodec() + with _patch_openai_plugin(), _mock_connect() as mock_connect: + with pytest.raises(ValueError, match="silently dropped by the plugin's data-converter transformer"): + await get_temporal_client( + temporal_address="localhost:7233", + plugins=[_FakeOpenAIPlugin()], + payload_codec=codec, + ) + mock_connect.assert_not_awaited() + + async def test_openai_plugin_without_codec_omits_data_converter(self): + from agentex.lib.core.temporal.workers.worker import get_temporal_client + + with _patch_openai_plugin(), _mock_connect() as mock_connect: + await get_temporal_client(temporal_address="localhost:7233", plugins=[_FakeOpenAIPlugin()]) + + assert "data_converter" not in mock_connect.await_args.kwargs + + async def test_data_converter_passthrough_with_openai_plugin(self): + from agentex.lib.core.temporal.workers.worker import get_temporal_client + + dc = DataConverter(payload_codec=_NoopCodec()) + with _patch_openai_plugin(), _mock_connect() as mock_connect: + await get_temporal_client( + temporal_address="localhost:7233", + plugins=[_FakeOpenAIPlugin()], + data_converter=dc, + ) + + assert mock_connect.await_args.kwargs["data_converter"] is dc + + async def test_data_converter_passthrough_without_openai_plugin(self): + from agentex.lib.core.temporal.workers.worker import get_temporal_client + + dc = DataConverter(payload_converter_class=DefaultPayloadConverter) + with _mock_connect() as mock_connect: + await get_temporal_client(temporal_address="localhost:7233", data_converter=dc) + + assert mock_connect.await_args.kwargs["data_converter"] is dc + + async def test_codec_and_data_converter_together_raises(self): + from agentex.lib.core.temporal.workers.worker import get_temporal_client + + codec = _NoopCodec() + dc = DataConverter(payload_codec=codec) + with _mock_connect() as mock_connect: + with pytest.raises(ValueError, match="Pass payload_codec inside `data_converter`"): + await get_temporal_client( + temporal_address="localhost:7233", + payload_codec=codec, + data_converter=dc, + ) + mock_connect.assert_not_awaited() + + +class TestAgentexWorkerCodec: + def test_worker_stores_payload_codec(self): + from agentex.lib.core.temporal.workers.worker import AgentexWorker + + codec = _NoopCodec() + worker = AgentexWorker(task_queue="test-queue", health_check_port=80, payload_codec=codec) + assert worker.payload_codec is codec + + def test_worker_default_payload_codec_is_none(self): + from agentex.lib.core.temporal.workers.worker import AgentexWorker + + worker = AgentexWorker(task_queue="test-queue", health_check_port=80) + assert worker.payload_codec is None + + def test_worker_stores_data_converter(self): + from agentex.lib.core.temporal.workers.worker import AgentexWorker + + dc = DataConverter(payload_codec=_NoopCodec()) + worker = AgentexWorker(task_queue="test-queue", health_check_port=80, data_converter=dc) + assert worker.data_converter is dc + + def test_worker_default_data_converter_is_none(self): + from agentex.lib.core.temporal.workers.worker import AgentexWorker + + worker = AgentexWorker(task_queue="test-queue", health_check_port=80) + assert worker.data_converter is None + + +class TestTemporalACPCodec: + def test_create_stores_payload_codec(self): + from agentex.lib.sdk.fastacp.impl.temporal_acp import TemporalACP + + codec = _NoopCodec() + acp = TemporalACP.create(temporal_address="localhost:7233", payload_codec=codec) + assert acp._payload_codec is codec + + def test_create_default_payload_codec_is_none(self): + from agentex.lib.sdk.fastacp.impl.temporal_acp import TemporalACP + + acp = TemporalACP.create(temporal_address="localhost:7233") + assert acp._payload_codec is None + + def test_create_stores_data_converter(self): + from agentex.lib.sdk.fastacp.impl.temporal_acp import TemporalACP + + dc = DataConverter(payload_codec=_NoopCodec()) + acp = TemporalACP.create(temporal_address="localhost:7233", data_converter=dc) + assert acp._data_converter is dc + + def test_create_default_data_converter_is_none(self): + from agentex.lib.sdk.fastacp.impl.temporal_acp import TemporalACP + + acp = TemporalACP.create(temporal_address="localhost:7233") + assert acp._data_converter is None + + +class TestFastACPConfigCodec: + def test_config_default_codec_is_none(self): + from agentex.lib.types.fastacp import TemporalACPConfig + + assert TemporalACPConfig().payload_codec is None + + def test_config_accepts_codec(self): + from agentex.lib.types.fastacp import TemporalACPConfig + + codec = _NoopCodec() + assert TemporalACPConfig(payload_codec=codec).payload_codec is codec + + def test_fastacp_forwards_codec_from_config(self): + from agentex.lib.types.fastacp import TemporalACPConfig + from agentex.lib.sdk.fastacp.fastacp import FastACP + + codec = _NoopCodec() + config = TemporalACPConfig(payload_codec=codec) + captured: dict[str, Any] = {} + + def fake_create(**kwargs): + captured.update(kwargs) + return object() + + with patch( + "agentex.lib.sdk.fastacp.impl.temporal_acp.TemporalACP.create", + side_effect=fake_create, + ): + FastACP.create("async", config=config) + + assert captured.get("payload_codec") is codec + + def test_config_default_data_converter_is_none(self): + from agentex.lib.types.fastacp import TemporalACPConfig + + assert TemporalACPConfig().data_converter is None + + def test_config_accepts_data_converter(self): + from agentex.lib.types.fastacp import TemporalACPConfig + + dc = DataConverter(payload_codec=_NoopCodec()) + assert TemporalACPConfig(data_converter=dc).data_converter is dc + + def test_config_rejects_codec_and_data_converter_together(self): + from pydantic import ValidationError + + from agentex.lib.types.fastacp import TemporalACPConfig + + codec = _NoopCodec() + dc = DataConverter(payload_codec=codec) + with pytest.raises(ValidationError, match="Pass payload_codec inside `data_converter`"): + TemporalACPConfig(payload_codec=codec, data_converter=dc) + + def test_fastacp_forwards_data_converter_from_config(self): + from agentex.lib.types.fastacp import TemporalACPConfig + from agentex.lib.sdk.fastacp.fastacp import FastACP + + dc = DataConverter(payload_codec=_NoopCodec()) + config = TemporalACPConfig(data_converter=dc) + captured: dict[str, Any] = {} + + def fake_create(**kwargs): + captured.update(kwargs) + return object() + + with patch( + "agentex.lib.sdk.fastacp.impl.temporal_acp.TemporalACP.create", + side_effect=fake_create, + ): + FastACP.create("async", config=config) + + assert captured.get("data_converter") is dc diff --git a/tests/lib/test_state_machine.py b/tests/lib/test_state_machine.py new file mode 100644 index 000000000..ce32ba9f0 --- /dev/null +++ b/tests/lib/test_state_machine.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from typing import override +from unittest.mock import AsyncMock, patch + +from agentex.lib.sdk.state_machine import State, StateMachine, StateWorkflow +from agentex.lib.utils.model_utils import BaseModel + + +class ExampleData(BaseModel): + value: int = 0 + + +class InitialWorkflow(StateWorkflow): + transitions = ["next"] + + @override + async def execute(self, state_machine, state_machine_data=None): + return "next" + + +class NextWorkflow(StateWorkflow): + transitions = ["initial"] + + @override + async def execute(self, state_machine, state_machine_data=None): + return "initial" + + +class ExampleStateMachine(StateMachine[ExampleData]): + @override + async def terminal_condition(self): + return False + + +def _make_state_machine() -> ExampleStateMachine: + return ExampleStateMachine( + initial_state="initial", + states=[ + State(name="initial", workflow=InitialWorkflow()), + State(name="next", workflow=NextWorkflow()), + ], + task_id="task-123", + state_machine_data=ExampleData(value=1), + trace_transitions=True, + ) + + +async def test_reset_to_initial_state_skips_end_span_when_start_span_fails_open(): + state_machine = _make_state_machine() + await state_machine.transition("next") + + with patch( + "agentex.lib.sdk.state_machine.state_machine.adk.tracing.start_span", + new=AsyncMock(return_value=None), + ) as start_span, patch( + "agentex.lib.sdk.state_machine.state_machine.adk.tracing.end_span", + new=AsyncMock(), + ) as end_span: + await state_machine.reset_to_initial_state() + + assert state_machine.get_current_state() == "initial" + start_span.assert_awaited_once_with( + trace_id="task-123", + name="state_transition_reset", + input={"input_state": "next"}, + ) + end_span.assert_not_awaited() diff --git a/tests/lib/test_temporal_utils.py b/tests/lib/test_temporal_utils.py new file mode 100644 index 000000000..b06bdfb3c --- /dev/null +++ b/tests/lib/test_temporal_utils.py @@ -0,0 +1,30 @@ +"""Tests for agentex.lib.utils.temporal helpers.""" + +from __future__ import annotations + +from datetime import datetime +from unittest.mock import patch + +from agentex.lib.utils import temporal as _temporal_mod +from agentex.lib.utils.temporal import ( + in_temporal_workflow, + workflow_now_if_in_workflow, +) + + +def test_in_temporal_workflow_returns_false_outside_workflow() -> None: + # Calling outside a workflow context raises RuntimeError internally, which + # the helper swallows. + assert in_temporal_workflow() is False + + +def test_workflow_now_if_in_workflow_returns_none_outside_workflow() -> None: + assert workflow_now_if_in_workflow() is None + + +def test_workflow_now_if_in_workflow_returns_workflow_now_when_inside() -> None: + fixed = datetime(2026, 5, 13, 18, 30, 0) + with patch.object(_temporal_mod, "in_temporal_workflow", return_value=True), patch.object( + _temporal_mod.workflow, "now", return_value=fixed + ): + assert workflow_now_if_in_workflow() == fixed diff --git a/tests/lib/test_version_guard.py b/tests/lib/test_version_guard.py new file mode 100644 index 000000000..45bd2be7a --- /dev/null +++ b/tests/lib/test_version_guard.py @@ -0,0 +1,26 @@ +"""Tests for the agentex-client compatibility guard (0.13.0 split regression).""" + +from __future__ import annotations + +import pytest + +import agentex.lib._version_guard as guard + + +def test_passes_when_surface_present() -> None: + guard.verify_client_compatibility() # full client surface installed + + +def test_newer_client_not_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + # Version is not a gate: a newer client (additive) with the full surface passes. + monkeypatch.setattr(guard, "version", lambda pkg: "0.14.0" if pkg == "agentex-client" else "0.13.0") + guard.verify_client_compatibility() + + +def test_raises_when_client_surface_incomplete(monkeypatch: pytest.MonkeyPatch) -> None: + import agentex.types + + # A partial install missing a needed symbol fails with an actionable error. + monkeypatch.delattr(agentex.types, "Event", raising=False) + with pytest.raises(ImportError, match="could not import the agentex-client REST surface"): + guard.verify_client_compatibility() diff --git a/tests/lib/test_webhooks.py b/tests/lib/test_webhooks.py new file mode 100644 index 000000000..e42fac9dd --- /dev/null +++ b/tests/lib/test_webhooks.py @@ -0,0 +1,267 @@ +"""Unit tests for the SDK webhook helper (agentex.lib.sdk.utils.webhooks).""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from agentex.lib import adk +from agentex.lib.sdk.utils.webhooks import ( + WebhookError, + session_key, + handle_webhook, + render_generic, + shape_github_pr, + resolve_remote_params, +) + + +def _pr_payload(**pr_overrides) -> dict: + pr = { + "number": 42, + "title": "Add retry to uploader", + "body": "Adds backoff on 503.", + "html_url": "https://example.com/acme/widgets/pull/42", + } + pr.update(pr_overrides) + return { + "action": "opened", + "repository": {"full_name": "acme/widgets"}, + "sender": {"login": "octocat"}, + "pull_request": pr, + } + + +class TestSessionKey: + def test_stable_and_folds_same_conversation(self): + a = session_key("agent-1", "github_pr", "acme/widgets#42") + b = session_key("agent-1", "github_pr", "acme/widgets#42") + assert a == b and a.startswith("wh-github_pr-") + + def test_differs_by_peer(self): + assert session_key("a", "github_pr", "r#1") != session_key("a", "github_pr", "r#2") + + +class TestShaping: + def test_render_generic_prefers_text_field(self): + assert render_generic({"text": "hello"}) == "hello" + + def test_render_generic_falls_back_to_json(self): + assert "zen" in render_generic({"zen": "be awesome"}) + + def test_render_generic_matches_keys_case_insensitively(self): + assert render_generic({"Message": "hi there"}) == "hi there" + + def test_render_generic_supports_broadened_keys(self): + assert render_generic({"description": "do the thing"}) == "do the thing" + + def test_github_pr_shape(self): + text, peer, sender = shape_github_pr(_pr_payload()) + assert "Pull request acme/widgets#42: Add retry to uploader" in text + assert "Action: opened" in text + assert "Adds backoff on 503." in text + assert peer == "acme/widgets#42" + assert sender == "octocat" + + def test_github_pr_includes_diff(self): + body = _pr_payload() + body["pull_request"]["diff"] = "diff --git a/x b/x\n+line" + text, _, _ = shape_github_pr(body) + assert "Diff:" in text and "+line" in text + + def test_non_pr_payload_falls_back_to_generic(self): + text, peer, _ = shape_github_pr({"zen": "be awesome", "hook_id": 1}) + assert "Pull request" not in text + assert "be awesome" in text + assert peer is None + + +class TestResolveRemoteParams: + async def test_envelope_with_params_and_metadata(self): + async def fetch(_url): + return {"params": {"system_prompt": "x", "model": "m"}, "task_metadata": {"cfg": "1"}} + + params, md = await resolve_remote_params("https://h/resolve", fetch=fetch) + assert params == {"system_prompt": "x", "model": "m"} + assert md == {"cfg": "1"} + + async def test_bare_object_is_params_minus_task_metadata(self): + async def fetch(_url): + return {"system_prompt": "x", "task_metadata": {"cfg": "1"}} + + params, md = await resolve_remote_params("https://h/resolve", fetch=fetch) + assert params == {"system_prompt": "x"} # task_metadata stripped from params + assert md == {"cfg": "1"} + + async def test_non_object_raises(self): + async def fetch(_url): + return ["nope"] + + with pytest.raises(WebhookError): + await resolve_remote_params("https://h/resolve", fetch=fetch) + + +def _agent_msg(text: str): + return SimpleNamespace(content=SimpleNamespace(author="agent", type="text", content=text)) + + +class TestHandleWebhook: + @pytest.fixture(autouse=True) + def _mock_adk(self, monkeypatch): + self.created = {} + self.sent = {} + self.stamped = {} + self.created_task_metadata = {} + + async def create_task(*, name, agent_name, params=None, request=None, **_): + self.created = {"name": name, "agent_name": agent_name, "params": params, "request": request} + return SimpleNamespace(id="task-1", task_metadata=self.created_task_metadata) + + async def send_message(*, task_id, agent_name, content, **_): + self.sent = {"task_id": task_id, "content": content} + return [_agent_msg("Looks good — ship it.")] + + async def update_task(*, task_id, task_metadata=None, **_): + self.stamped = {"task_id": task_id, "task_metadata": task_metadata} + return SimpleNamespace(id=task_id) + + send_event = AsyncMock() + monkeypatch.setattr(adk.acp, "create_task", create_task) + monkeypatch.setattr(adk.acp, "send_message", send_message) + monkeypatch.setattr(adk.acp, "send_event", send_event) + monkeypatch.setattr(adk.tasks, "update", update_task) + self.send_event = send_event + yield + + async def test_sync_github_pr_with_config_by_id(self): + async def fake_resolve(_url): + return {"params": {"system_prompt": "review"}, "task_metadata": {"agent_config_id": "cfg-9"}} + + result = await handle_webhook( + agent_name="golden-agent", + payload=_pr_payload(), + acp_type="sync", + shaper="github_pr", + params_source="https://h/v5/agent_configs/cfg-9/resolve", + fetch=fake_resolve, + ) + + assert result.reply == "Looks good — ship it." + assert self.created["params"] == {"system_prompt": "review"} + # metadata is returned on the result (SDK task/create can't carry it) + md = result.task_metadata + assert md["channel"] == "github_pr" + assert md["peer_id"] == "acme/widgets#42" + assert md["agent_config_id"] == "cfg-9" + # task folded on a stable session key + assert self.created["name"].startswith("wh-github_pr-") + # metadata is also stamped onto the task (best-effort) so it's labeled in the UI + assert self.stamped["task_id"] == "task-1" + assert self.stamped["task_metadata"]["peer_id"] == "acme/widgets#42" + assert self.stamped["task_metadata"]["agent_config_id"] == "cfg-9" + + async def test_inline_params_no_fetch(self): + result = await handle_webhook( + agent_name="a", + payload={"text": "hi"}, + acp_type="sync", + params={"system_prompt": "inline"}, + ) + assert result.reply == "Looks good — ship it." + assert self.created["params"] == {"system_prompt": "inline"} + + async def test_source_metadata_cannot_override_canonical(self): + async def fake_resolve(_url): + return {"params": {}, "task_metadata": {"channel": "spoofed"}} + + result = await handle_webhook( + agent_name="a", + payload=_pr_payload(), + shaper="github_pr", + params_source="https://h/resolve", + fetch=fake_resolve, + ) + assert result.task_metadata["channel"] == "github_pr" + + async def test_task_metadata_preserves_existing_keys_on_reused_task(self): + self.created_task_metadata = { + "labels": ["customer-facing"], + "agent_config_id": "old-cfg", + "channel": "old-channel", + } + + async def fake_resolve(_url): + return {"params": {}, "task_metadata": {"agent_config_id": "cfg-9"}} + + await handle_webhook( + agent_name="a", + payload=_pr_payload(), + shaper="github_pr", + params_source="https://h/resolve", + fetch=fake_resolve, + ) + + stamped_metadata = self.stamped["task_metadata"] + assert stamped_metadata["labels"] == ["customer-facing"] + assert stamped_metadata["agent_config_id"] == "cfg-9" + assert stamped_metadata["channel"] == "github_pr" + + async def test_async_without_wait_sends_event_and_returns_no_reply(self): + result = await handle_webhook(agent_name="a", payload={"text": "go"}, acp_type="async", wait=False) + assert result.reply is None + self.send_event.assert_awaited_once() + + +class TestAwaitReplyIgnoresStalePriorReply: + async def test_returns_only_new_agent_text_on_reused_task(self, monkeypatch): + from agentex.lib.sdk.utils.webhooks import _await_reply + + old = _agent_msg("OLD reply") + old.id = "m1" + new = _agent_msg("NEW reply") + new.id = "m2" + calls = {"n": 0} + + async def fake_list(*, task_id, **_): + calls["n"] += 1 + return [old] if calls["n"] < 2 else [old, new] # new appears on 2nd poll + + async def no_sleep(_seconds): + return None + + monkeypatch.setattr(adk.messages, "list", fake_list) + monkeypatch.setattr("asyncio.sleep", no_sleep) + + # baseline = the pre-existing old message; only m2 (NEW) should be returned + reply = await _await_reply("task-1", {"m1"}, interval_s=0.0, quiescence_s=0.0) + assert reply == "NEW reply" + + async def test_returns_idless_agent_text_after_snapshot(self, monkeypatch): + from agentex.lib.sdk.utils.webhooks import _await_reply + + old = _agent_msg("OLD reply") + old.id = None + new = _agent_msg("NEW reply") + new.id = None + calls = {"n": 0} + + async def fake_list(*, task_id, **_): + calls["n"] += 1 + return [old] if calls["n"] < 2 else [old, new] + + async def no_sleep(_seconds): + return None + + monkeypatch.setattr(adk.messages, "list", fake_list) + monkeypatch.setattr("asyncio.sleep", no_sleep) + + reply = await _await_reply( + "task-1", + set(), + seen_count=1, + interval_s=0.0, + quiescence_s=0.0, + ) + assert reply == "NEW reply" diff --git a/tests/lib/utils/__init__.py b/tests/lib/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/lib/utils/test_completions.py b/tests/lib/utils/test_completions.py new file mode 100644 index 000000000..3aa5f9120 --- /dev/null +++ b/tests/lib/utils/test_completions.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from agentex.lib.utils.completions import concat_completion_chunks +from agentex.lib.types.llm_messages import Delta, Usage, Choice, Completion + + +def _delta_chunk(content: str, role: str | None = None) -> Completion: + return Completion(choices=[Choice(index=0, delta=Delta(content=content, role=role))]) + + +def _usage_only_chunk(prompt: int, completion: int) -> Completion: + # stream_options.include_usage: litellm/OpenAI send a final chunk that has + # usage but an empty choices list + return Completion( + choices=[], + usage=Usage(prompt_tokens=prompt, completion_tokens=completion, total_tokens=prompt + completion), + ) + + +class TestConcatCompletionChunks: + def test_concatenates_delta_content(self): + result = concat_completion_chunks([_delta_chunk("Hel", role="assistant"), _delta_chunk("lo!")]) + + assert result.choices[0].message.content == "Hello!" + + def test_trailing_usage_only_chunk_keeps_choices_and_usage(self): + result = concat_completion_chunks( + [_delta_chunk("Hel", role="assistant"), _delta_chunk("lo!"), _usage_only_chunk(10, 5)] + ) + + assert result.choices[0].message.content == "Hello!" + assert result.usage is not None + assert result.usage.prompt_tokens == 10 + assert result.usage.completion_tokens == 5 + assert result.usage.total_tokens == 15 + + def test_usage_summed_across_chunks(self): + chunk_a = _delta_chunk("a", role="assistant") + chunk_a.usage = Usage(prompt_tokens=1, completion_tokens=2, total_tokens=3) + chunk_b = _delta_chunk("b") + chunk_b.usage = Usage(prompt_tokens=4, completion_tokens=5, total_tokens=9) + + result = concat_completion_chunks([chunk_a, chunk_b]) + + assert result.usage.prompt_tokens == 5 + assert result.usage.completion_tokens == 7 + assert result.usage.total_tokens == 12 + + def test_no_usage_chunks_leave_usage_none(self): + result = concat_completion_chunks([_delta_chunk("x", role="assistant")]) + + assert result.usage is None diff --git a/tests/lib/utils/test_logging_level.py b/tests/lib/utils/test_logging_level.py new file mode 100644 index 000000000..16b171e33 --- /dev/null +++ b/tests/lib/utils/test_logging_level.py @@ -0,0 +1,66 @@ +"""Tests for log level resolution in agentex.lib.utils.logging. + +The level used to be pinned to INFO with no override, so a debug() call could +never be emitted on any configuration. That is not just a missing feature: it +made diagnostics that were already written into the SDK unreachable. +""" + +from __future__ import annotations + +import logging + +import pytest + +from agentex.lib.utils.logging import ( + DEFAULT_LOG_LEVEL, + make_logger, + resolve_log_level, +) + + +def test_defaults_to_info_when_unset(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("LOG_LEVEL", raising=False) + + assert resolve_log_level() == DEFAULT_LOG_LEVEL == logging.INFO + + +@pytest.mark.parametrize( + ("configured", "expected"), + [ + ("DEBUG", logging.DEBUG), + ("debug", logging.DEBUG), + (" WaRnInG ", logging.WARNING), + ("ERROR", logging.ERROR), + ("CRITICAL", logging.CRITICAL), + ], +) +def test_reads_level_from_env( + monkeypatch: pytest.MonkeyPatch, configured: str, expected: int +) -> None: + monkeypatch.setenv("LOG_LEVEL", configured) + + assert resolve_log_level() == expected + + +@pytest.mark.parametrize("configured", ["", " ", "VERBOSE", "10x", "TRUE"]) +def test_falls_back_to_info_on_an_unusable_value( + monkeypatch: pytest.MonkeyPatch, configured: str +) -> None: + """A typo must not silently disable logging. + + logging.getLevelName returns the string "Level FOO" for anything it does not + recognise, which would otherwise be handed straight to setLevel. + """ + monkeypatch.setenv("LOG_LEVEL", configured) + + assert resolve_log_level() == logging.INFO + + +def test_make_logger_applies_the_configured_level(monkeypatch: pytest.MonkeyPatch) -> None: + """The regression that mattered: a debug() call must be able to emit.""" + monkeypatch.setenv("LOG_LEVEL", "DEBUG") + + logger = make_logger("agentex.tests.level_from_env") + + assert logger.level == logging.DEBUG + assert logger.isEnabledFor(logging.DEBUG) diff --git a/tests/sample_file.txt b/tests/sample_file.txt new file mode 100644 index 000000000..af5626b4a --- /dev/null +++ b/tests/sample_file.txt @@ -0,0 +1 @@ +Hello, world! diff --git a/tests/test_acp_interrupt.py b/tests/test_acp_interrupt.py new file mode 100644 index 000000000..f53cfa6b4 --- /dev/null +++ b/tests/test_acp_interrupt.py @@ -0,0 +1,178 @@ +"""Unit tests for the hand-editable task/interrupt additions. + +Covers the regeneration-safe surfaces added for the interrupt-and-queue design +(design doc sections 6, 7, 9.2): + +1. Protocol (``agentex.protocol.acp``): ``RPCMethod.TASK_INTERRUPT``, the + ``InterruptTaskParams`` model (mirror of ``CancelTaskParams``), and the + ``PARAMS_MODEL_BY_METHOD`` entry, plus the back-compat shim re-export. +2. ACP server routing: ``BaseACPServer.on_task_interrupt`` registers a handler + under ``RPCMethod.TASK_INTERRUPT``. +3. Temporal transport: ``BaseWorkflow.on_interrupt`` is a ``@workflow.signal`` + named ``interrupt_turn`` (and is NOT abstract, so existing agents keep + working), and ``TemporalTaskService.interrupt`` forwards that signal without + tearing the workflow down. +""" + +from __future__ import annotations + +from unittest.mock import Mock, AsyncMock + +import pytest + +from agentex.types.task import Task +from agentex.types.agent import Agent +from agentex.protocol.acp import ( + PARAMS_MODEL_BY_METHOD, + RPCMethod, + CancelTaskParams, + InterruptTaskParams, +) + + +def _agent() -> Agent: + return Agent( + id="test-agent-456", + name="test-agent", + description="test-agent", + acp_type="async", + created_at="2023-01-01T00:00:00Z", + updated_at="2023-01-01T00:00:00Z", + ) + + +def _task() -> Task: + return Task(id="test-task-123", status="RUNNING") + + +# --------------------------------------------------------------------------- +# Protocol additions +# --------------------------------------------------------------------------- + + +class TestInterruptProtocol: + def test_rpc_method_value(self) -> None: + assert RPCMethod.TASK_INTERRUPT.value == "task/interrupt" + # Constructible from the wire string (the ACP server does RPCMethod(str)). + assert RPCMethod("task/interrupt") is RPCMethod.TASK_INTERRUPT + + def test_params_model_registered(self) -> None: + assert PARAMS_MODEL_BY_METHOD[RPCMethod.TASK_INTERRUPT] is InterruptTaskParams + + def test_params_mirror_cancel_shape(self) -> None: + """InterruptTaskParams mirrors CancelTaskParams field-for-field.""" + assert set(InterruptTaskParams.model_fields) == set(CancelTaskParams.model_fields) + assert set(InterruptTaskParams.model_fields) == {"agent", "task", "request"} + + def test_params_validate_round_trip(self) -> None: + params = InterruptTaskParams(agent=_agent(), task=_task()) + assert params.task.id == "test-task-123" + assert params.request is None + # Header forwarding path (BaseACPServer populates params.request). + with_headers = InterruptTaskParams.model_validate( + { + "agent": _agent().model_dump(), + "task": _task().model_dump(), + "request": {"headers": {"x-foo": "bar"}}, + } + ) + assert with_headers.request == {"headers": {"x-foo": "bar"}} + + def test_shim_reexports_interrupt_params(self) -> None: + """The back-compat shim must re-export the new model as the same object.""" + from agentex.protocol import acp as canon + from agentex.lib.types import acp as shim + + assert shim.InterruptTaskParams is canon.InterruptTaskParams + + +# --------------------------------------------------------------------------- +# ACP server routing +# --------------------------------------------------------------------------- + + +class TestACPServerInterruptRouting: + def test_on_task_interrupt_registers_handler(self) -> None: + from unittest.mock import patch + + from agentex.lib.sdk.fastacp.base.base_acp_server import BaseACPServer + + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + server = BaseACPServer() + + assert RPCMethod.TASK_INTERRUPT not in server._handlers + + @server.on_task_interrupt + async def _handle(params: InterruptTaskParams) -> None: # noqa: ARG001 + return None + + assert RPCMethod.TASK_INTERRUPT in server._handlers + assert server._handlers[RPCMethod.TASK_INTERRUPT] is not None + + def test_temporal_acp_wires_interrupt_handler(self) -> None: + from unittest.mock import patch + + from agentex.lib.sdk.fastacp.impl.temporal_acp import TemporalACP + + with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): + server = TemporalACP.create(temporal_address="localhost:7233") + + assert RPCMethod.TASK_INTERRUPT in server._handlers + + +# --------------------------------------------------------------------------- +# Temporal transport: signal + service forwarding +# --------------------------------------------------------------------------- + + +class TestWorkflowInterruptSignal: + def test_on_interrupt_is_signal_named_interrupt_turn(self) -> None: + from agentex.lib.core.temporal.types.workflow import SignalName + from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow + + # getattr avoids the dunder name-mangling that would otherwise rewrite + # this to _TestWorkflowInterruptSignal__temporal_signal_definition. + sd = getattr(BaseWorkflow.on_interrupt, "__temporal_signal_definition") + assert sd is not None + # str-enum: equal by value to the wire string "interrupt_turn". + assert sd.name == SignalName.INTERRUPT_TURN + assert sd.name == "interrupt_turn" + + def test_on_interrupt_not_abstract(self) -> None: + """A default no-op keeps existing (non-interruptible) workflows valid.""" + from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow + + assert "on_interrupt" not in BaseWorkflow.__abstractmethods__ + + +class TestTemporalTaskServiceInterrupt: + async def test_interrupt_sends_signal_not_cancel(self) -> None: + from agentex.lib.core.temporal.types.workflow import SignalName + from agentex.lib.core.temporal.services.temporal_task_service import ( + TemporalTaskService, + ) + + temporal_client = Mock() + temporal_client.send_signal = AsyncMock() + temporal_client.cancel_workflow = AsyncMock() + temporal_client.terminate_workflow = AsyncMock() + + service = TemporalTaskService(temporal_client=temporal_client, env_vars=Mock()) + + await service.interrupt(agent=_agent(), task=_task(), request={"headers": {"x-a": "b"}}) + + # Non-terminal: it signals, it does NOT cancel or terminate the workflow. + temporal_client.cancel_workflow.assert_not_called() + temporal_client.terminate_workflow.assert_not_called() + temporal_client.send_signal.assert_awaited_once() + + kwargs = temporal_client.send_signal.await_args.kwargs + assert kwargs["workflow_id"] == "test-task-123" + assert kwargs["signal"] == SignalName.INTERRUPT_TURN.value == "interrupt_turn" + # Payload is a serialized InterruptTaskParams (task/agent/request). + assert kwargs["payload"]["task"]["id"] == "test-task-123" + assert kwargs["payload"]["request"] == {"headers": {"x-a": "b"}} + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(pytest.main([__file__, "-v"])) diff --git a/tests/test_adk_tracing_span_error.py b/tests/test_adk_tracing_span_error.py new file mode 100644 index 000000000..c81015142 --- /dev/null +++ b/tests/test_adk_tracing_span_error.py @@ -0,0 +1,120 @@ +"""Tests for the ADK ``TracingModule.span`` / ``turn_span`` error-status behavior. + +Regression coverage for the "false green" bug: agents open spans through the ADK +context manager (``adk.tracing.span`` / ``turn_span``), which is the *only* span +path they use. Before the fix, a failing step still closed its span green because +the CM never recorded the exception. These tests assert that: + + - a body exception is recorded on the span (``set_span_error`` -> ``data["__error__"]``), + - the ORIGINAL app exception always propagates unchanged, + - ``end_span`` sees the span *with* the error already set (except-before-finally), + - obs bookkeeping never breaks the app path (if ``set_span_error`` itself raises, + the app exception still propagates), + - the success path records no error, + - a falsy ``trace_id`` is a pure no-op (no start/end, yields ``None``), + - ``turn_span`` inherits all of the above since it delegates to ``span``. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from agentex.types.span import Span +from agentex.lib.adk._modules.tracing import TracingModule +from agentex.lib.core.tracing.span_error import get_span_error + + +def _make_module() -> tuple[TracingModule, Span, AsyncMock]: + """A TracingModule with start_span/end_span stubbed to avoid any network. + + start_span returns a fresh Span; end_span is an AsyncMock so tests can + inspect the span (and its recorded error) as end_span actually saw it. + """ + module = TracingModule() + span = Span(id="span-1", name="step", start_time=1.0, trace_id="trace-1") + module.start_span = AsyncMock(return_value=span) # type: ignore[method-assign] + module.end_span = AsyncMock(return_value=span) # type: ignore[method-assign] + return module, span, module.end_span # type: ignore[return-value] + + +async def test_span_records_error_and_reraises() -> None: + module, span, end_span = _make_module() + + with pytest.raises(ValueError, match="boom"): + async with module.span(trace_id="trace-1", name="step") as yielded: + assert yielded is span + raise ValueError("boom") + + error = get_span_error(span) + assert error == { + "type": "ValueError", + "message": "boom", + "category": "unknown", + } + + # end_span still ran (finally) and saw the span with the error already set, + # so the failure is what gets persisted -- not a false green. + end_span.assert_awaited_once() + persisted_span = end_span.await_args.kwargs["span"] + assert get_span_error(persisted_span) == { + "type": "ValueError", + "message": "boom", + "category": "unknown", + } + + +async def test_span_success_records_no_error() -> None: + module, span, end_span = _make_module() + + async with module.span(trace_id="trace-1", name="step") as yielded: + assert yielded is span + + assert get_span_error(span) is None + end_span.assert_awaited_once() + + +async def test_span_obs_failure_does_not_shadow_app_exception(monkeypatch: pytest.MonkeyPatch) -> None: + """If set_span_error itself blows up, the app's exception must still surface.""" + module, span, end_span = _make_module() + + def _boom(*_args: object, **_kwargs: object) -> None: + raise RuntimeError("set_span_error is broken") + + monkeypatch.setattr("agentex.lib.adk._modules.tracing.set_span_error", _boom) + + # The ORIGINAL ValueError propagates, not the RuntimeError from obs code. + with pytest.raises(ValueError, match="boom"): + async with module.span(trace_id="trace-1", name="step"): + raise ValueError("boom") + + # The span still gets closed despite the obs hiccup. + end_span.assert_awaited_once() + + +async def test_span_noop_when_trace_id_falsy() -> None: + module, _span, end_span = _make_module() + + async with module.span(trace_id="", name="step") as yielded: + assert yielded is None + + module.start_span.assert_not_awaited() # type: ignore[attr-defined] + end_span.assert_not_awaited() + + +async def test_turn_span_records_error_and_reraises() -> None: + """turn_span delegates to span(), so it must record errors too.""" + module, span, end_span = _make_module() + + with pytest.raises(ValueError, match="boom"): + async with module.turn_span(trace_id="trace-1", name="turn") as turn: + assert turn.span is span + raise ValueError("boom") + + assert get_span_error(span) == { + "type": "ValueError", + "message": "boom", + "category": "unknown", + } + end_span.assert_awaited_once() diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 000000000..131d32fee --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,2023 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import gc +import os +import sys +import json +import time +import asyncio +import inspect +import subprocess +import dataclasses +import tracemalloc +from typing import Any, Union, TypeVar, Callable, Iterable, Iterator, Optional, Coroutine, cast +from textwrap import dedent +from unittest import mock +from typing_extensions import Literal, AsyncIterator, override + +import httpx +import pytest +from respx import MockRouter +from pydantic import ValidationError + +from agentex import Agentex, AsyncAgentex, APIResponseValidationError +from agentex._types import Omit +from agentex._models import BaseModel, FinalRequestOptions +from agentex._exceptions import APIStatusError, APITimeoutError, APIResponseValidationError +from agentex._base_client import ( + DEFAULT_TIMEOUT, + HTTPX_DEFAULT_TIMEOUT, + BaseClient, + DefaultHttpxClient, + DefaultAsyncHttpxClient, + make_request_options, +) + +from .utils import update_env + +T = TypeVar("T") +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") +api_key = "My API Key" + + +def _get_params(client: BaseClient[Any, Any]) -> dict[str, str]: + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + url = httpx.URL(request.url) + return dict(url.params) + + +def _low_retry_timeout(*_args: Any, **_kwargs: Any) -> float: + return 0.1 + + +def mirror_request_content(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=request.content) + + +# note: we can't use the httpx.MockTransport class as it consumes the request +# body itself, which means we can't test that the body is read lazily +class MockTransport(httpx.BaseTransport, httpx.AsyncBaseTransport): + def __init__( + self, + handler: Callable[[httpx.Request], httpx.Response] + | Callable[[httpx.Request], Coroutine[Any, Any, httpx.Response]], + ) -> None: + self.handler = handler + + @override + def handle_request( + self, + request: httpx.Request, + ) -> httpx.Response: + assert not inspect.iscoroutinefunction(self.handler), "handler must not be a coroutine function" + assert inspect.isfunction(self.handler), "handler must be a function" + return self.handler(request) + + @override + async def handle_async_request( + self, + request: httpx.Request, + ) -> httpx.Response: + assert inspect.iscoroutinefunction(self.handler), "handler must be a coroutine function" + return await self.handler(request) + + +@dataclasses.dataclass +class Counter: + value: int = 0 + + +def _make_sync_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> Iterator[T]: + for item in iterable: + if counter: + counter.value += 1 + yield item + + +async def _make_async_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> AsyncIterator[T]: + for item in iterable: + if counter: + counter.value += 1 + yield item + + +def _get_open_connections(client: Agentex | AsyncAgentex) -> int: + transport = client._client._transport + assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport) + + pool = transport._pool + return len(pool._requests) + + +class TestAgentex: + @pytest.mark.respx(base_url=base_url) + def test_raw_response(self, respx_mock: MockRouter, client: Agentex) -> None: + respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = client.post("/foo", cast_to=httpx.Response) + assert response.status_code == 200 + assert isinstance(response, httpx.Response) + assert response.json() == {"foo": "bar"} + + @pytest.mark.respx(base_url=base_url) + def test_raw_response_for_binary(self, respx_mock: MockRouter, client: Agentex) -> None: + respx_mock.post("/foo").mock( + return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') + ) + + response = client.post("/foo", cast_to=httpx.Response) + assert response.status_code == 200 + assert isinstance(response, httpx.Response) + assert response.json() == {"foo": "bar"} + + def test_copy(self, client: Agentex) -> None: + copied = client.copy() + assert id(copied) != id(client) + + copied = client.copy(api_key="another My API Key") + assert copied.api_key == "another My API Key" + assert client.api_key == "My API Key" + + def test_copy_default_options(self, client: Agentex) -> None: + # options that have a default are overridden correctly + copied = client.copy(max_retries=7) + assert copied.max_retries == 7 + assert client.max_retries == 2 + + copied2 = copied.copy(max_retries=6) + assert copied2.max_retries == 6 + assert copied.max_retries == 7 + + # timeout + assert isinstance(client.timeout, httpx.Timeout) + copied = client.copy(timeout=None) + assert copied.timeout is None + assert isinstance(client.timeout, httpx.Timeout) + + def test_copy_default_headers(self) -> None: + client = Agentex( + base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"} + ) + assert client.default_headers["X-Foo"] == "bar" + + # does not override the already given value when not specified + copied = client.copy() + assert copied.default_headers["X-Foo"] == "bar" + + # merges already given headers + copied = client.copy(default_headers={"X-Bar": "stainless"}) + assert copied.default_headers["X-Foo"] == "bar" + assert copied.default_headers["X-Bar"] == "stainless" + + # uses new values for any already given headers + copied = client.copy(default_headers={"X-Foo": "stainless"}) + assert copied.default_headers["X-Foo"] == "stainless" + + # set_default_headers + + # completely overrides already set values + copied = client.copy(set_default_headers={}) + assert copied.default_headers.get("X-Foo") is None + + copied = client.copy(set_default_headers={"X-Bar": "Robert"}) + assert copied.default_headers["X-Bar"] == "Robert" + + with pytest.raises( + ValueError, + match="`default_headers` and `set_default_headers` arguments are mutually exclusive", + ): + client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + client.close() + + def test_copy_default_query(self) -> None: + client = Agentex( + base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"foo": "bar"} + ) + assert _get_params(client)["foo"] == "bar" + + # does not override the already given value when not specified + copied = client.copy() + assert _get_params(copied)["foo"] == "bar" + + # merges already given params + copied = client.copy(default_query={"bar": "stainless"}) + params = _get_params(copied) + assert params["foo"] == "bar" + assert params["bar"] == "stainless" + + # uses new values for any already given headers + copied = client.copy(default_query={"foo": "stainless"}) + assert _get_params(copied)["foo"] == "stainless" + + # set_default_query + + # completely overrides already set values + copied = client.copy(set_default_query={}) + assert _get_params(copied) == {} + + copied = client.copy(set_default_query={"bar": "Robert"}) + assert _get_params(copied)["bar"] == "Robert" + + with pytest.raises( + ValueError, + # TODO: update + match="`default_query` and `set_default_query` arguments are mutually exclusive", + ): + client.copy(set_default_query={}, default_query={"foo": "Bar"}) + + client.close() + + def test_copy_signature(self, client: Agentex) -> None: + # ensure the same parameters that can be passed to the client are defined in the `.copy()` method + init_signature = inspect.signature( + # mypy doesn't like that we access the `__init__` property. + client.__init__, # type: ignore[misc] + ) + copy_signature = inspect.signature(client.copy) + exclude_params = {"transport", "proxies", "_strict_response_validation"} + + for name in init_signature.parameters.keys(): + if name in exclude_params: + continue + + copy_param = copy_signature.parameters.get(name) + assert copy_param is not None, f"copy() signature is missing the {name} param" + + @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") + def test_copy_build_request(self, client: Agentex) -> None: + options = FinalRequestOptions(method="get", url="/foo") + + def build_request(options: FinalRequestOptions) -> None: + client_copy = client.copy() + client_copy._build_request(options) + + # ensure that the machinery is warmed up before tracing starts. + build_request(options) + gc.collect() + + tracemalloc.start(1000) + + snapshot_before = tracemalloc.take_snapshot() + + ITERATIONS = 10 + for _ in range(ITERATIONS): + build_request(options) + + gc.collect() + snapshot_after = tracemalloc.take_snapshot() + + tracemalloc.stop() + + def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None: + if diff.count == 0: + # Avoid false positives by considering only leaks (i.e. allocations that persist). + return + + if diff.count % ITERATIONS != 0: + # Avoid false positives by considering only leaks that appear per iteration. + return + + for frame in diff.traceback: + if any( + frame.filename.endswith(fragment) + for fragment in [ + # to_raw_response_wrapper leaks through the @functools.wraps() decorator. + # + # removing the decorator fixes the leak for reasons we don't understand. + "agentex/_legacy_response.py", + "agentex/_response.py", + # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason. + "agentex/_compat.py", + # Standard library leaks we don't care about. + "/logging/__init__.py", + ] + ): + return + + leaks.append(diff) + + leaks: list[tracemalloc.StatisticDiff] = [] + for diff in snapshot_after.compare_to(snapshot_before, "traceback"): + add_leak(leaks, diff) + if leaks: + for leak in leaks: + print("MEMORY LEAK:", leak) + for frame in leak.traceback: + print(frame) + raise AssertionError() + + def test_request_timeout(self, client: Agentex) -> None: + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + request = client._build_request(FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(100.0) + + def test_client_timeout_option(self) -> None: + client = Agentex(base_url=base_url, api_key=api_key, _strict_response_validation=True, timeout=httpx.Timeout(0)) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(0) + + client.close() + + def test_http_client_timeout_option(self) -> None: + # custom timeout given to the httpx client should be used + with httpx.Client(timeout=None) as http_client: + client = Agentex( + base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(None) + + client.close() + + # no timeout given to the httpx client should not use the httpx default + with httpx.Client() as http_client: + client = Agentex( + base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + client.close() + + # explicitly passing the default timeout currently results in it being ignored + with httpx.Client(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: + client = Agentex( + base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT # our default + + client.close() + + async def test_invalid_http_client(self) -> None: + with pytest.raises(TypeError, match="Invalid `http_client` arg"): + async with httpx.AsyncClient() as http_client: + Agentex( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + http_client=cast(Any, http_client), + ) + + def test_default_headers_option(self) -> None: + test_client = Agentex( + base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"} + ) + request = test_client._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("x-foo") == "bar" + assert request.headers.get("x-stainless-lang") == "python" + + test_client2 = Agentex( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + default_headers={ + "X-Foo": "stainless", + "X-Stainless-Lang": "my-overriding-header", + }, + ) + request = test_client2._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("x-foo") == "stainless" + assert request.headers.get("x-stainless-lang") == "my-overriding-header" + + test_client.close() + test_client2.close() + + def test_validate_headers(self) -> None: + client = Agentex(base_url=base_url, api_key=api_key, _strict_response_validation=True) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("Authorization") == f"Bearer {api_key}" + + with update_env(**{"AGENTEX_SDK_API_KEY": Omit()}): + client2 = Agentex(base_url=base_url, api_key=None, _strict_response_validation=True) + + client2._build_request(FinalRequestOptions(method="get", url="/foo")) + + def test_default_query_option(self) -> None: + client = Agentex( + base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"} + ) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + url = httpx.URL(request.url) + assert dict(url.params) == {"query_param": "bar"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/foo", + params={"foo": "baz", "query_param": "overridden"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} + + client.close() + + def test_hardcoded_query_params_in_url(self, client: Agentex) -> None: + request = client._build_request(FinalRequestOptions(method="get", url="/foo?beta=true")) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/foo?beta=true", + params={"limit": "10", "page": "abc"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true", "limit": "10", "page": "abc"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/files/a%2Fb?beta=true", + params={"limit": "10"}, + ) + ) + assert request.url.raw_path == b"/files/a%2Fb?beta=true&limit=10" + + def test_request_extra_json(self, client: Agentex) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + extra_json={"baz": False}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"foo": "bar", "baz": False} + + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + extra_json={"baz": False}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"baz": False} + + # `extra_json` takes priority over `json_data` when keys clash + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar", "baz": True}, + extra_json={"baz": None}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"foo": "bar", "baz": None} + + def test_request_extra_headers(self, client: Agentex) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options(extra_headers={"X-Foo": "Foo"}), + ), + ) + assert request.headers.get("X-Foo") == "Foo" + + # `extra_headers` takes priority over `default_headers` when keys clash + request = client.with_options(default_headers={"X-Bar": "true"})._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + extra_headers={"X-Bar": "false"}, + ), + ), + ) + assert request.headers.get("X-Bar") == "false" + + def test_request_extra_query(self, client: Agentex) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + extra_query={"my_query_param": "Foo"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"my_query_param": "Foo"} + + # if both `query` and `extra_query` are given, they are merged + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + query={"bar": "1"}, + extra_query={"foo": "2"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"bar": "1", "foo": "2"} + + # `extra_query` takes priority over `query` when keys clash + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + query={"foo": "1"}, + extra_query={"foo": "2"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"foo": "2"} + + def test_multipart_repeating_array(self, client: Agentex) -> None: + request = client._build_request( + FinalRequestOptions.construct( + method="post", + url="/foo", + headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"}, + json_data={"array": ["foo", "bar"]}, + files=[("foo.txt", b"hello world")], + ) + ) + + assert request.read().split(b"\r\n") == [ + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="array[]"', + b"", + b"foo", + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="array[]"', + b"", + b"bar", + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="foo.txt"; filename="upload"', + b"Content-Type: application/octet-stream", + b"", + b"hello world", + b"--6b7ba517decee4a450543ea6ae821c82--", + b"", + ] + + @pytest.mark.respx(base_url=base_url) + def test_binary_content_upload(self, respx_mock: MockRouter, client: Agentex) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + response = client.post( + "/upload", + content=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + + def test_binary_content_upload_with_iterator(self) -> None: + file_content = b"Hello, this is a test file." + counter = Counter() + iterator = _make_sync_iterator([file_content], counter=counter) + + def mock_handler(request: httpx.Request) -> httpx.Response: + assert counter.value == 0, "the request body should not have been read" + return httpx.Response(200, content=request.read()) + + with Agentex( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.Client(transport=MockTransport(handler=mock_handler)), + ) as client: + response = client.post( + "/upload", + content=iterator, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + assert counter.value == 1 + + @pytest.mark.respx(base_url=base_url) + def test_binary_content_upload_with_body_is_deprecated(self, respx_mock: MockRouter, client: Agentex) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + with pytest.deprecated_call( + match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead." + ): + response = client.post( + "/upload", + body=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + + @pytest.mark.respx(base_url=base_url) + def test_basic_union_response(self, respx_mock: MockRouter, client: Agentex) -> None: + class Model1(BaseModel): + name: str + + class Model2(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model2) + assert response.foo == "bar" + + @pytest.mark.respx(base_url=base_url) + def test_union_response_different_types(self, respx_mock: MockRouter, client: Agentex) -> None: + """Union of objects with the same field name using a different type""" + + class Model1(BaseModel): + foo: int + + class Model2(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model2) + assert response.foo == "bar" + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) + + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model1) + assert response.foo == 1 + + @pytest.mark.respx(base_url=base_url) + def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter, client: Agentex) -> None: + """ + Response that sets Content-Type to something other than application/json but returns json data + """ + + class Model(BaseModel): + foo: int + + respx_mock.get("/foo").mock( + return_value=httpx.Response( + 200, + content=json.dumps({"foo": 2}), + headers={"Content-Type": "application/text"}, + ) + ) + + response = client.get("/foo", cast_to=Model) + assert isinstance(response, Model) + assert response.foo == 2 + + def test_base_url_setter(self) -> None: + client = Agentex(base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True) + assert client.base_url == "https://example.com/from_init/" + + client.base_url = "https://example.com/from_setter" # type: ignore[assignment] + + assert client.base_url == "https://example.com/from_setter/" + + client.close() + + def test_base_url_env(self) -> None: + with update_env(AGENTEX_BASE_URL="http://localhost:5000/from/env"): + client = Agentex(api_key=api_key, _strict_response_validation=True) + assert client.base_url == "http://localhost:5000/from/env/" + + # explicit environment arg requires explicitness + with update_env(AGENTEX_BASE_URL="http://localhost:5000/from/env"): + with pytest.raises(ValueError, match=r"you must pass base_url=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") + + client.close() + + @pytest.mark.parametrize( + "client", + [ + Agentex(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True), + Agentex( + base_url="http://localhost:5000/custom/path/", + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.Client(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_base_url_trailing_slash(self, client: Agentex) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + client.close() + + @pytest.mark.parametrize( + "client", + [ + Agentex(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True), + Agentex( + base_url="http://localhost:5000/custom/path/", + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.Client(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_base_url_no_trailing_slash(self, client: Agentex) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + client.close() + + @pytest.mark.parametrize( + "client", + [ + Agentex(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True), + Agentex( + base_url="http://localhost:5000/custom/path/", + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.Client(), + ), + ], + ids=["standard", "custom http client"], + ) + def test_absolute_request_url(self, client: Agentex) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="https://myapi.com/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "https://myapi.com/foo" + client.close() + + def test_copied_client_does_not_close_http(self) -> None: + test_client = Agentex(base_url=base_url, api_key=api_key, _strict_response_validation=True) + assert not test_client.is_closed() + + copied = test_client.copy() + assert copied is not test_client + + del copied + + assert not test_client.is_closed() + + def test_client_context_manager(self) -> None: + test_client = Agentex(base_url=base_url, api_key=api_key, _strict_response_validation=True) + with test_client as c2: + assert c2 is test_client + assert not c2.is_closed() + assert not test_client.is_closed() + assert test_client.is_closed() + + @pytest.mark.respx(base_url=base_url) + def test_client_response_validation_error(self, respx_mock: MockRouter, client: Agentex) -> None: + class Model(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) + + with pytest.raises(APIResponseValidationError) as exc: + client.get("/foo", cast_to=Model) + + assert isinstance(exc.value.__cause__, ValidationError) + + def test_client_max_retries_validation(self) -> None: + with pytest.raises(TypeError, match=r"max_retries cannot be None"): + Agentex(base_url=base_url, api_key=api_key, _strict_response_validation=True, max_retries=cast(Any, None)) + + @pytest.mark.respx(base_url=base_url) + def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: + class Model(BaseModel): + name: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format")) + + strict_client = Agentex(base_url=base_url, api_key=api_key, _strict_response_validation=True) + + with pytest.raises(APIResponseValidationError): + strict_client.get("/foo", cast_to=Model) + + non_strict_client = Agentex(base_url=base_url, api_key=api_key, _strict_response_validation=False) + + response = non_strict_client.get("/foo", cast_to=Model) + assert isinstance(response, str) # type: ignore[unreachable] + + strict_client.close() + non_strict_client.close() + + @pytest.mark.parametrize( + "remaining_retries,retry_after,timeout", + [ + [3, "20", 20], + [3, "0", 0.5], + [3, "-10", 0.5], + [3, "60", 60], + [3, "61", 0.5], + [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20], + [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5], + [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5], + [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60], + [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5], + [3, "99999999999999999999999999999999999", 0.5], + [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5], + [3, "", 0.5], + [2, "", 0.5 * 2.0], + [1, "", 0.5 * 4.0], + [-1100, "", 8], # test large number potentially overflowing + ], + ) + @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) + def test_parse_retry_after_header( + self, remaining_retries: int, retry_after: str, timeout: float, client: Agentex + ) -> None: + headers = httpx.Headers({"retry-after": retry_after}) + options = FinalRequestOptions(method="get", url="/foo", max_retries=3) + calculated = client._calculate_retry_timeout(remaining_retries, options, headers) + assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] + + @mock.patch("agentex._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, client: Agentex) -> None: + respx_mock.get("/tasks").mock(side_effect=httpx.TimeoutException("Test timeout error")) + + with pytest.raises(APITimeoutError): + client.tasks.with_streaming_response.list().__enter__() + + assert _get_open_connections(client) == 0 + + @mock.patch("agentex._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, client: Agentex) -> None: + respx_mock.get("/tasks").mock(return_value=httpx.Response(500)) + + with pytest.raises(APIStatusError): + client.tasks.with_streaming_response.list().__enter__() + assert _get_open_connections(client) == 0 + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("agentex._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + @pytest.mark.parametrize("failure_mode", ["status", "exception"]) + def test_retries_taken( + self, + client: Agentex, + failures_before_success: int, + failure_mode: Literal["status", "exception"], + respx_mock: MockRouter, + ) -> None: + client = client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + if failure_mode == "exception": + raise RuntimeError("oops") + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/tasks").mock(side_effect=retry_handler) + + response = client.tasks.with_raw_response.list() + + assert response.retries_taken == failures_before_success + assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("agentex._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_omit_retry_count_header( + self, client: Agentex, failures_before_success: int, respx_mock: MockRouter + ) -> None: + client = client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/tasks").mock(side_effect=retry_handler) + + response = client.tasks.with_raw_response.list(extra_headers={"x-stainless-retry-count": Omit()}) + + assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0 + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("agentex._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + def test_overwrite_retry_count_header( + self, client: Agentex, failures_before_success: int, respx_mock: MockRouter + ) -> None: + client = client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/tasks").mock(side_effect=retry_handler) + + response = client.tasks.with_raw_response.list(extra_headers={"x-stainless-retry-count": "42"}) + + assert response.http_request.headers.get("x-stainless-retry-count") == "42" + + def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Test that the proxy environment variables are set correctly + monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + # Delete in case our environment has any proxy env vars set + monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("ALL_PROXY", raising=False) + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.delenv("http_proxy", raising=False) + monkeypatch.delenv("https_proxy", raising=False) + monkeypatch.delenv("all_proxy", raising=False) + monkeypatch.delenv("no_proxy", raising=False) + + client = DefaultHttpxClient() + + mounts = tuple(client._mounts.items()) + assert len(mounts) == 1 + assert mounts[0][0].pattern == "https://" + + @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning") + def test_default_client_creation(self) -> None: + # Ensure that the client can be initialized without any exceptions + DefaultHttpxClient( + verify=True, + cert=None, + trust_env=True, + http1=True, + http2=False, + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), + ) + + @pytest.mark.respx(base_url=base_url) + def test_follow_redirects(self, respx_mock: MockRouter, client: Agentex) -> None: + # Test that the default follow_redirects=True allows following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) + + response = client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + @pytest.mark.respx(base_url=base_url) + def test_follow_redirects_disabled(self, respx_mock: MockRouter, client: Agentex) -> None: + # Test that follow_redirects=False prevents following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + + with pytest.raises(APIStatusError) as exc_info: + client.post("/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response) + + assert exc_info.value.response.status_code == 302 + assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" + + +class TestAsyncAgentex: + @pytest.mark.respx(base_url=base_url) + async def test_raw_response(self, respx_mock: MockRouter, async_client: AsyncAgentex) -> None: + respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = await async_client.post("/foo", cast_to=httpx.Response) + assert response.status_code == 200 + assert isinstance(response, httpx.Response) + assert response.json() == {"foo": "bar"} + + @pytest.mark.respx(base_url=base_url) + async def test_raw_response_for_binary(self, respx_mock: MockRouter, async_client: AsyncAgentex) -> None: + respx_mock.post("/foo").mock( + return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') + ) + + response = await async_client.post("/foo", cast_to=httpx.Response) + assert response.status_code == 200 + assert isinstance(response, httpx.Response) + assert response.json() == {"foo": "bar"} + + def test_copy(self, async_client: AsyncAgentex) -> None: + copied = async_client.copy() + assert id(copied) != id(async_client) + + copied = async_client.copy(api_key="another My API Key") + assert copied.api_key == "another My API Key" + assert async_client.api_key == "My API Key" + + def test_copy_default_options(self, async_client: AsyncAgentex) -> None: + # options that have a default are overridden correctly + copied = async_client.copy(max_retries=7) + assert copied.max_retries == 7 + assert async_client.max_retries == 2 + + copied2 = copied.copy(max_retries=6) + assert copied2.max_retries == 6 + assert copied.max_retries == 7 + + # timeout + assert isinstance(async_client.timeout, httpx.Timeout) + copied = async_client.copy(timeout=None) + assert copied.timeout is None + assert isinstance(async_client.timeout, httpx.Timeout) + + async def test_copy_default_headers(self) -> None: + client = AsyncAgentex( + base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"} + ) + assert client.default_headers["X-Foo"] == "bar" + + # does not override the already given value when not specified + copied = client.copy() + assert copied.default_headers["X-Foo"] == "bar" + + # merges already given headers + copied = client.copy(default_headers={"X-Bar": "stainless"}) + assert copied.default_headers["X-Foo"] == "bar" + assert copied.default_headers["X-Bar"] == "stainless" + + # uses new values for any already given headers + copied = client.copy(default_headers={"X-Foo": "stainless"}) + assert copied.default_headers["X-Foo"] == "stainless" + + # set_default_headers + + # completely overrides already set values + copied = client.copy(set_default_headers={}) + assert copied.default_headers.get("X-Foo") is None + + copied = client.copy(set_default_headers={"X-Bar": "Robert"}) + assert copied.default_headers["X-Bar"] == "Robert" + + with pytest.raises( + ValueError, + match="`default_headers` and `set_default_headers` arguments are mutually exclusive", + ): + client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + await client.close() + + async def test_copy_default_query(self) -> None: + client = AsyncAgentex( + base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"foo": "bar"} + ) + assert _get_params(client)["foo"] == "bar" + + # does not override the already given value when not specified + copied = client.copy() + assert _get_params(copied)["foo"] == "bar" + + # merges already given params + copied = client.copy(default_query={"bar": "stainless"}) + params = _get_params(copied) + assert params["foo"] == "bar" + assert params["bar"] == "stainless" + + # uses new values for any already given headers + copied = client.copy(default_query={"foo": "stainless"}) + assert _get_params(copied)["foo"] == "stainless" + + # set_default_query + + # completely overrides already set values + copied = client.copy(set_default_query={}) + assert _get_params(copied) == {} + + copied = client.copy(set_default_query={"bar": "Robert"}) + assert _get_params(copied)["bar"] == "Robert" + + with pytest.raises( + ValueError, + # TODO: update + match="`default_query` and `set_default_query` arguments are mutually exclusive", + ): + client.copy(set_default_query={}, default_query={"foo": "Bar"}) + + await client.close() + + def test_copy_signature(self, async_client: AsyncAgentex) -> None: + # ensure the same parameters that can be passed to the client are defined in the `.copy()` method + init_signature = inspect.signature( + # mypy doesn't like that we access the `__init__` property. + async_client.__init__, # type: ignore[misc] + ) + copy_signature = inspect.signature(async_client.copy) + exclude_params = {"transport", "proxies", "_strict_response_validation"} + + for name in init_signature.parameters.keys(): + if name in exclude_params: + continue + + copy_param = copy_signature.parameters.get(name) + assert copy_param is not None, f"copy() signature is missing the {name} param" + + @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") + def test_copy_build_request(self, async_client: AsyncAgentex) -> None: + options = FinalRequestOptions(method="get", url="/foo") + + def build_request(options: FinalRequestOptions) -> None: + client_copy = async_client.copy() + client_copy._build_request(options) + + # ensure that the machinery is warmed up before tracing starts. + build_request(options) + gc.collect() + + tracemalloc.start(1000) + + snapshot_before = tracemalloc.take_snapshot() + + ITERATIONS = 10 + for _ in range(ITERATIONS): + build_request(options) + + gc.collect() + snapshot_after = tracemalloc.take_snapshot() + + tracemalloc.stop() + + def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None: + if diff.count == 0: + # Avoid false positives by considering only leaks (i.e. allocations that persist). + return + + if diff.count % ITERATIONS != 0: + # Avoid false positives by considering only leaks that appear per iteration. + return + + for frame in diff.traceback: + if any( + frame.filename.endswith(fragment) + for fragment in [ + # to_raw_response_wrapper leaks through the @functools.wraps() decorator. + # + # removing the decorator fixes the leak for reasons we don't understand. + "agentex/_legacy_response.py", + "agentex/_response.py", + # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason. + "agentex/_compat.py", + # Standard library leaks we don't care about. + "/logging/__init__.py", + ] + ): + return + + leaks.append(diff) + + leaks: list[tracemalloc.StatisticDiff] = [] + for diff in snapshot_after.compare_to(snapshot_before, "traceback"): + add_leak(leaks, diff) + if leaks: + for leak in leaks: + print("MEMORY LEAK:", leak) + for frame in leak.traceback: + print(frame) + raise AssertionError() + + async def test_request_timeout(self, async_client: AsyncAgentex) -> None: + request = async_client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + request = async_client._build_request( + FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) + ) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(100.0) + + async def test_client_timeout_option(self) -> None: + client = AsyncAgentex( + base_url=base_url, api_key=api_key, _strict_response_validation=True, timeout=httpx.Timeout(0) + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(0) + + await client.close() + + async def test_http_client_timeout_option(self) -> None: + # custom timeout given to the httpx client should be used + async with httpx.AsyncClient(timeout=None) as http_client: + client = AsyncAgentex( + base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == httpx.Timeout(None) + + await client.close() + + # no timeout given to the httpx client should not use the httpx default + async with httpx.AsyncClient() as http_client: + client = AsyncAgentex( + base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT + + await client.close() + + # explicitly passing the default timeout currently results in it being ignored + async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: + client = AsyncAgentex( + base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client + ) + + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore + assert timeout == DEFAULT_TIMEOUT # our default + + await client.close() + + def test_invalid_http_client(self) -> None: + with pytest.raises(TypeError, match="Invalid `http_client` arg"): + with httpx.Client() as http_client: + AsyncAgentex( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + http_client=cast(Any, http_client), + ) + + async def test_default_headers_option(self) -> None: + test_client = AsyncAgentex( + base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"} + ) + request = test_client._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("x-foo") == "bar" + assert request.headers.get("x-stainless-lang") == "python" + + test_client2 = AsyncAgentex( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + default_headers={ + "X-Foo": "stainless", + "X-Stainless-Lang": "my-overriding-header", + }, + ) + request = test_client2._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("x-foo") == "stainless" + assert request.headers.get("x-stainless-lang") == "my-overriding-header" + + await test_client.close() + await test_client2.close() + + def test_validate_headers(self) -> None: + client = AsyncAgentex(base_url=base_url, api_key=api_key, _strict_response_validation=True) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("Authorization") == f"Bearer {api_key}" + + with update_env(**{"AGENTEX_SDK_API_KEY": Omit()}): + client2 = AsyncAgentex(base_url=base_url, api_key=None, _strict_response_validation=True) + + client2._build_request(FinalRequestOptions(method="get", url="/foo")) + + async def test_default_query_option(self) -> None: + client = AsyncAgentex( + base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"} + ) + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + url = httpx.URL(request.url) + assert dict(url.params) == {"query_param": "bar"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/foo", + params={"foo": "baz", "query_param": "overridden"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} + + await client.close() + + async def test_hardcoded_query_params_in_url(self, async_client: AsyncAgentex) -> None: + request = async_client._build_request(FinalRequestOptions(method="get", url="/foo?beta=true")) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true"} + + request = async_client._build_request( + FinalRequestOptions( + method="get", + url="/foo?beta=true", + params={"limit": "10", "page": "abc"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true", "limit": "10", "page": "abc"} + + request = async_client._build_request( + FinalRequestOptions( + method="get", + url="/files/a%2Fb?beta=true", + params={"limit": "10"}, + ) + ) + assert request.url.raw_path == b"/files/a%2Fb?beta=true&limit=10" + + def test_request_extra_json(self, client: Agentex) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + extra_json={"baz": False}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"foo": "bar", "baz": False} + + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + extra_json={"baz": False}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"baz": False} + + # `extra_json` takes priority over `json_data` when keys clash + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar", "baz": True}, + extra_json={"baz": None}, + ), + ) + data = json.loads(request.content.decode("utf-8")) + assert data == {"foo": "bar", "baz": None} + + def test_request_extra_headers(self, client: Agentex) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options(extra_headers={"X-Foo": "Foo"}), + ), + ) + assert request.headers.get("X-Foo") == "Foo" + + # `extra_headers` takes priority over `default_headers` when keys clash + request = client.with_options(default_headers={"X-Bar": "true"})._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + extra_headers={"X-Bar": "false"}, + ), + ), + ) + assert request.headers.get("X-Bar") == "false" + + def test_request_extra_query(self, client: Agentex) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + extra_query={"my_query_param": "Foo"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"my_query_param": "Foo"} + + # if both `query` and `extra_query` are given, they are merged + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + query={"bar": "1"}, + extra_query={"foo": "2"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"bar": "1", "foo": "2"} + + # `extra_query` takes priority over `query` when keys clash + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + **make_request_options( + query={"foo": "1"}, + extra_query={"foo": "2"}, + ), + ), + ) + params = dict(request.url.params) + assert params == {"foo": "2"} + + def test_multipart_repeating_array(self, async_client: AsyncAgentex) -> None: + request = async_client._build_request( + FinalRequestOptions.construct( + method="post", + url="/foo", + headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"}, + json_data={"array": ["foo", "bar"]}, + files=[("foo.txt", b"hello world")], + ) + ) + + assert request.read().split(b"\r\n") == [ + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="array[]"', + b"", + b"foo", + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="array[]"', + b"", + b"bar", + b"--6b7ba517decee4a450543ea6ae821c82", + b'Content-Disposition: form-data; name="foo.txt"; filename="upload"', + b"Content-Type: application/octet-stream", + b"", + b"hello world", + b"--6b7ba517decee4a450543ea6ae821c82--", + b"", + ] + + @pytest.mark.respx(base_url=base_url) + async def test_binary_content_upload(self, respx_mock: MockRouter, async_client: AsyncAgentex) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + response = await async_client.post( + "/upload", + content=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + + async def test_binary_content_upload_with_asynciterator(self) -> None: + file_content = b"Hello, this is a test file." + counter = Counter() + iterator = _make_async_iterator([file_content], counter=counter) + + async def mock_handler(request: httpx.Request) -> httpx.Response: + assert counter.value == 0, "the request body should not have been read" + return httpx.Response(200, content=await request.aread()) + + async with AsyncAgentex( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.AsyncClient(transport=MockTransport(handler=mock_handler)), + ) as client: + response = await client.post( + "/upload", + content=iterator, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + assert counter.value == 1 + + @pytest.mark.respx(base_url=base_url) + async def test_binary_content_upload_with_body_is_deprecated( + self, respx_mock: MockRouter, async_client: AsyncAgentex + ) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + with pytest.deprecated_call( + match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead." + ): + response = await async_client.post( + "/upload", + body=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + + @pytest.mark.respx(base_url=base_url) + async def test_basic_union_response(self, respx_mock: MockRouter, async_client: AsyncAgentex) -> None: + class Model1(BaseModel): + name: str + + class Model2(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model2) + assert response.foo == "bar" + + @pytest.mark.respx(base_url=base_url) + async def test_union_response_different_types(self, respx_mock: MockRouter, async_client: AsyncAgentex) -> None: + """Union of objects with the same field name using a different type""" + + class Model1(BaseModel): + foo: int + + class Model2(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model2) + assert response.foo == "bar" + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) + + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + assert isinstance(response, Model1) + assert response.foo == 1 + + @pytest.mark.respx(base_url=base_url) + async def test_non_application_json_content_type_for_json_data( + self, respx_mock: MockRouter, async_client: AsyncAgentex + ) -> None: + """ + Response that sets Content-Type to something other than application/json but returns json data + """ + + class Model(BaseModel): + foo: int + + respx_mock.get("/foo").mock( + return_value=httpx.Response( + 200, + content=json.dumps({"foo": 2}), + headers={"Content-Type": "application/text"}, + ) + ) + + response = await async_client.get("/foo", cast_to=Model) + assert isinstance(response, Model) + assert response.foo == 2 + + async def test_base_url_setter(self) -> None: + client = AsyncAgentex( + base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True + ) + assert client.base_url == "https://example.com/from_init/" + + client.base_url = "https://example.com/from_setter" # type: ignore[assignment] + + assert client.base_url == "https://example.com/from_setter/" + + await client.close() + + async def test_base_url_env(self) -> None: + with update_env(AGENTEX_BASE_URL="http://localhost:5000/from/env"): + client = AsyncAgentex(api_key=api_key, _strict_response_validation=True) + assert client.base_url == "http://localhost:5000/from/env/" + + # explicit environment arg requires explicitness + with update_env(AGENTEX_BASE_URL="http://localhost:5000/from/env"): + with pytest.raises(ValueError, match=r"you must pass base_url=None"): + AsyncAgentex(api_key=api_key, _strict_response_validation=True, environment="production") + + client = AsyncAgentex( + base_url=None, api_key=api_key, _strict_response_validation=True, environment="production" + ) + assert str(client.base_url).startswith("http://localhost:5003") + + await client.close() + + @pytest.mark.parametrize( + "client", + [ + AsyncAgentex( + base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True + ), + AsyncAgentex( + base_url="http://localhost:5000/custom/path/", + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.AsyncClient(), + ), + ], + ids=["standard", "custom http client"], + ) + async def test_base_url_trailing_slash(self, client: AsyncAgentex) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + await client.close() + + @pytest.mark.parametrize( + "client", + [ + AsyncAgentex( + base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True + ), + AsyncAgentex( + base_url="http://localhost:5000/custom/path/", + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.AsyncClient(), + ), + ], + ids=["standard", "custom http client"], + ) + async def test_base_url_no_trailing_slash(self, client: AsyncAgentex) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + await client.close() + + @pytest.mark.parametrize( + "client", + [ + AsyncAgentex( + base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True + ), + AsyncAgentex( + base_url="http://localhost:5000/custom/path/", + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.AsyncClient(), + ), + ], + ids=["standard", "custom http client"], + ) + async def test_absolute_request_url(self, client: AsyncAgentex) -> None: + request = client._build_request( + FinalRequestOptions( + method="post", + url="https://myapi.com/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "https://myapi.com/foo" + await client.close() + + async def test_copied_client_does_not_close_http(self) -> None: + test_client = AsyncAgentex(base_url=base_url, api_key=api_key, _strict_response_validation=True) + assert not test_client.is_closed() + + copied = test_client.copy() + assert copied is not test_client + + del copied + + await asyncio.sleep(0.2) + assert not test_client.is_closed() + + async def test_client_context_manager(self) -> None: + test_client = AsyncAgentex(base_url=base_url, api_key=api_key, _strict_response_validation=True) + async with test_client as c2: + assert c2 is test_client + assert not c2.is_closed() + assert not test_client.is_closed() + assert test_client.is_closed() + + @pytest.mark.respx(base_url=base_url) + async def test_client_response_validation_error(self, respx_mock: MockRouter, async_client: AsyncAgentex) -> None: + class Model(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) + + with pytest.raises(APIResponseValidationError) as exc: + await async_client.get("/foo", cast_to=Model) + + assert isinstance(exc.value.__cause__, ValidationError) + + async def test_client_max_retries_validation(self) -> None: + with pytest.raises(TypeError, match=r"max_retries cannot be None"): + AsyncAgentex( + base_url=base_url, api_key=api_key, _strict_response_validation=True, max_retries=cast(Any, None) + ) + + @pytest.mark.respx(base_url=base_url) + async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: + class Model(BaseModel): + name: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format")) + + strict_client = AsyncAgentex(base_url=base_url, api_key=api_key, _strict_response_validation=True) + + with pytest.raises(APIResponseValidationError): + await strict_client.get("/foo", cast_to=Model) + + non_strict_client = AsyncAgentex(base_url=base_url, api_key=api_key, _strict_response_validation=False) + + response = await non_strict_client.get("/foo", cast_to=Model) + assert isinstance(response, str) # type: ignore[unreachable] + + await strict_client.close() + await non_strict_client.close() + + @pytest.mark.parametrize( + "remaining_retries,retry_after,timeout", + [ + [3, "20", 20], + [3, "0", 0.5], + [3, "-10", 0.5], + [3, "60", 60], + [3, "61", 0.5], + [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20], + [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5], + [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5], + [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60], + [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5], + [3, "99999999999999999999999999999999999", 0.5], + [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5], + [3, "", 0.5], + [2, "", 0.5 * 2.0], + [1, "", 0.5 * 4.0], + [-1100, "", 8], # test large number potentially overflowing + ], + ) + @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) + async def test_parse_retry_after_header( + self, remaining_retries: int, retry_after: str, timeout: float, async_client: AsyncAgentex + ) -> None: + headers = httpx.Headers({"retry-after": retry_after}) + options = FinalRequestOptions(method="get", url="/foo", max_retries=3) + calculated = async_client._calculate_retry_timeout(remaining_retries, options, headers) + assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] + + @mock.patch("agentex._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + async def test_retrying_timeout_errors_doesnt_leak( + self, respx_mock: MockRouter, async_client: AsyncAgentex + ) -> None: + respx_mock.get("/tasks").mock(side_effect=httpx.TimeoutException("Test timeout error")) + + with pytest.raises(APITimeoutError): + await async_client.tasks.with_streaming_response.list().__aenter__() + + assert _get_open_connections(async_client) == 0 + + @mock.patch("agentex._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + async def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, async_client: AsyncAgentex) -> None: + respx_mock.get("/tasks").mock(return_value=httpx.Response(500)) + + with pytest.raises(APIStatusError): + await async_client.tasks.with_streaming_response.list().__aenter__() + assert _get_open_connections(async_client) == 0 + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("agentex._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + @pytest.mark.parametrize("failure_mode", ["status", "exception"]) + async def test_retries_taken( + self, + async_client: AsyncAgentex, + failures_before_success: int, + failure_mode: Literal["status", "exception"], + respx_mock: MockRouter, + ) -> None: + client = async_client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + if failure_mode == "exception": + raise RuntimeError("oops") + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/tasks").mock(side_effect=retry_handler) + + response = await client.tasks.with_raw_response.list() + + assert response.retries_taken == failures_before_success + assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("agentex._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + async def test_omit_retry_count_header( + self, async_client: AsyncAgentex, failures_before_success: int, respx_mock: MockRouter + ) -> None: + client = async_client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/tasks").mock(side_effect=retry_handler) + + response = await client.tasks.with_raw_response.list(extra_headers={"x-stainless-retry-count": Omit()}) + + assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0 + + @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) + @mock.patch("agentex._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + @pytest.mark.respx(base_url=base_url) + async def test_overwrite_retry_count_header( + self, async_client: AsyncAgentex, failures_before_success: int, respx_mock: MockRouter + ) -> None: + client = async_client.with_options(max_retries=4) + + nb_retries = 0 + + def retry_handler(_request: httpx.Request) -> httpx.Response: + nonlocal nb_retries + if nb_retries < failures_before_success: + nb_retries += 1 + return httpx.Response(500) + return httpx.Response(200) + + respx_mock.get("/tasks").mock(side_effect=retry_handler) + + response = await client.tasks.with_raw_response.list(extra_headers={"x-stainless-retry-count": "42"}) + + assert response.http_request.headers.get("x-stainless-retry-count") == "42" + + def test_get_platform(self) -> None: + # A previous implementation of asyncify could leave threads unterminated when + # used with nest_asyncio. + # + # Since nest_asyncio.apply() is global and cannot be un-applied, this + # test is run in a separate process to avoid affecting other tests. + test_code = dedent(""" + import asyncio + import nest_asyncio + import threading + + from agentex._utils import asyncify + from agentex._base_client import get_platform + + async def test_main() -> None: + result = await asyncify(get_platform)() + print(result) + for thread in threading.enumerate(): + print(thread.name) + + nest_asyncio.apply() + asyncio.run(test_main()) + """) + with subprocess.Popen( + [sys.executable, "-c", test_code], + text=True, + ) as process: + timeout = 10 # seconds + + start_time = time.monotonic() + while True: + return_code = process.poll() + if return_code is not None: + if return_code != 0: + raise AssertionError("calling get_platform using asyncify resulted in a non-zero exit code") + + # success + break + + if time.monotonic() - start_time > timeout: + process.kill() + raise AssertionError("calling get_platform using asyncify resulted in a hung process") + + time.sleep(0.1) + + async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Test that the proxy environment variables are set correctly + monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + # Delete in case our environment has any proxy env vars set + monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("ALL_PROXY", raising=False) + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.delenv("http_proxy", raising=False) + monkeypatch.delenv("https_proxy", raising=False) + monkeypatch.delenv("all_proxy", raising=False) + monkeypatch.delenv("no_proxy", raising=False) + + client = DefaultAsyncHttpxClient() + + mounts = tuple(client._mounts.items()) + assert len(mounts) == 1 + assert mounts[0][0].pattern == "https://" + + @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning") + async def test_default_client_creation(self) -> None: + # Ensure that the client can be initialized without any exceptions + DefaultAsyncHttpxClient( + verify=True, + cert=None, + trust_env=True, + http1=True, + http2=False, + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), + ) + + @pytest.mark.respx(base_url=base_url) + async def test_follow_redirects(self, respx_mock: MockRouter, async_client: AsyncAgentex) -> None: + # Test that the default follow_redirects=True allows following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) + + response = await async_client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + @pytest.mark.respx(base_url=base_url) + async def test_follow_redirects_disabled(self, respx_mock: MockRouter, async_client: AsyncAgentex) -> None: + # Test that follow_redirects=False prevents following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + + with pytest.raises(APIStatusError) as exc_info: + await async_client.post( + "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response + ) + + assert exc_info.value.response.status_code == 302 + assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" diff --git a/tests/test_config_shims.py b/tests/test_config_shims.py new file mode 100644 index 000000000..846e173b1 --- /dev/null +++ b/tests/test_config_shims.py @@ -0,0 +1,117 @@ +"""Tests that pin the back-compat contract for config-model shims. + +The canonical location for deployment/agent configuration models is +:mod:`agentex.config` (mirroring PR scaleapi/scale-agentex-python#371, which +did this for protocol types). The historical locations under +:mod:`agentex.lib.sdk.config.*` and :mod:`agentex.lib.types.*` are preserved as +re-export shims so external consumers' existing imports continue to work. + +These tests enforce: + +1. **Symbol parity** — every public name the original modules exported is + still importable from the old path. +2. **Identity** — the class objects at the shim path are the *same* objects as + the canonical path, so ``isinstance`` stays correct across import styles. +3. **Config preservation** — the swap from ``model_utils.BaseModel`` to plain + pydantic kept ``populate_by_name`` (``DeploymentConfig``'s ``global`` alias + relies on it; it previously also carried a now-removed + ``class Config: validate_by_name``). +""" + +from __future__ import annotations + + +def test_config_shims_re_export_all_original_symbols() -> None: + """Every name historically exported from the old paths must still be + importable from those paths via the back-compat shims.""" + from agentex.lib.types.credentials import CredentialMapping # noqa: F401 + from agentex.lib.types.agent_configs import ( # noqa: F401 + TemporalConfig, + TemporalWorkerConfig, + TemporalWorkflowConfig, + ) + from agentex.lib.sdk.config.agent_config import AgentConfig # noqa: F401 + from agentex.lib.sdk.config.build_config import ( # noqa: F401 + BuildConfig, + BuildContext, + ) + from agentex.lib.sdk.config.agent_manifest import ( # noqa: F401 + AgentManifest, + BuildContextManager, + load_agent_manifest, + build_context_manager, + ) + from agentex.lib.sdk.config.deployment_config import ( # noqa: F401 + ImageConfig, + ClusterConfig, + ResourceConfig, + DeploymentConfig, + AuthenticationConfig, + ResourceRequirements, + ImagePullSecretConfig, + InjectedSecretsValues, + GlobalDeploymentConfig, + InjectedImagePullSecretValues, + ) + from agentex.lib.sdk.config.environment_config import ( # noqa: F401 + AgentAuthConfig, + OciRegistryConfig, + AgentKubernetesConfig, + AgentEnvironmentConfig, + AgentEnvironmentsConfig, + load_environments_config, + load_environments_config_from_manifest_dir, + ) + from agentex.lib.sdk.config.local_development_config import ( # noqa: F401 + LocalAgentConfig, + LocalPathsConfig, + LocalDevelopmentConfig, + ) + + +def test_config_shim_classes_are_identical_to_canonical() -> None: + """Shim re-exports must be the *same* class objects as the canonical path. + Different objects would break ``isinstance`` for code that mixes import + styles.""" + from agentex.config import ( + credentials, + agent_config, + build_config, + agent_configs as canon_agent_configs, + agent_manifest as canon_manifest, + deployment_config as canon_deploy, + environment_config as canon_env, + local_development_config as canon_local, + ) + from agentex.lib.types import credentials as shim_creds, agent_configs as shim_agent_configs + from agentex.lib.sdk.config import ( + agent_config as shim_agent_config, + build_config as shim_build, + agent_manifest as shim_manifest, + deployment_config as shim_deploy, + environment_config as shim_env, + local_development_config as shim_local, + ) + + assert shim_agent_config.AgentConfig is agent_config.AgentConfig + assert shim_build.BuildConfig is build_config.BuildConfig + assert shim_deploy.DeploymentConfig is canon_deploy.DeploymentConfig + assert shim_local.LocalDevelopmentConfig is canon_local.LocalDevelopmentConfig + assert shim_env.AgentEnvironmentsConfig is canon_env.AgentEnvironmentsConfig + assert shim_env.AgentEnvironmentConfig is canon_env.AgentEnvironmentConfig + assert shim_agent_configs.TemporalConfig is canon_agent_configs.TemporalConfig + assert shim_creds.CredentialMapping is credentials.CredentialMapping + assert shim_manifest.AgentManifest is canon_manifest.AgentManifest + + +def test_deployment_config_populates_global_by_name_and_alias() -> None: + """``populate_by_name`` (inherited via ConfigBaseModel) must let the + ``global``-aliased field be set by either its field name or its alias — + the swap dropped the legacy ``class Config: validate_by_name``.""" + from agentex.config.deployment_config import DeploymentConfig + + by_name = DeploymentConfig.model_validate({"image": {"repository": "r"}, "global_config": {"replicaCount": 2}}) + assert by_name.global_config.replicaCount == 2 + + by_alias = DeploymentConfig.model_validate({"image": {"repository": "r"}, "global": {"replicaCount": 3}}) + assert by_alias.global_config.replicaCount == 3 diff --git a/tests/test_extract_files.py b/tests/test_extract_files.py new file mode 100644 index 000000000..fabb0f3c2 --- /dev/null +++ b/tests/test_extract_files.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from typing import Sequence + +import pytest + +from agentex._types import FileTypes, ArrayFormat +from agentex._utils import extract_files + + +def test_removes_files_from_input() -> None: + query = {"foo": "bar"} + assert extract_files(query, paths=[]) == [] + assert query == {"foo": "bar"} + + query2 = {"foo": b"Bar", "hello": "world"} + assert extract_files(query2, paths=[["foo"]]) == [("foo", b"Bar")] + assert query2 == {"hello": "world"} + + query3 = {"foo": {"foo": {"bar": b"Bar"}}, "hello": "world"} + assert extract_files(query3, paths=[["foo", "foo", "bar"]]) == [("foo[foo][bar]", b"Bar")] + assert query3 == {"foo": {"foo": {}}, "hello": "world"} + + query4 = {"foo": {"bar": b"Bar", "baz": "foo"}, "hello": "world"} + assert extract_files(query4, paths=[["foo", "bar"]]) == [("foo[bar]", b"Bar")] + assert query4 == {"hello": "world", "foo": {"baz": "foo"}} + + +def test_multiple_files() -> None: + query = {"documents": [{"file": b"My first file"}, {"file": b"My second file"}]} + assert extract_files(query, paths=[["documents", "", "file"]]) == [ + ("documents[][file]", b"My first file"), + ("documents[][file]", b"My second file"), + ] + assert query == {"documents": [{}, {}]} + + +def test_top_level_file_array() -> None: + query = {"files": [b"file one", b"file two"], "title": "hello"} + assert extract_files(query, paths=[["files", ""]]) == [("files[]", b"file one"), ("files[]", b"file two")] + assert query == {"title": "hello"} + + +@pytest.mark.parametrize( + "query,paths,expected", + [ + [ + {"foo": {"bar": "baz"}}, + [["foo", "", "bar"]], + [], + ], + [ + {"foo": ["bar", "baz"]}, + [["foo", "bar"]], + [], + ], + [ + {"foo": {"bar": "baz"}}, + [["foo", "foo"]], + [], + ], + ], + ids=["dict expecting array", "array expecting dict", "unknown keys"], +) +def test_ignores_incorrect_paths( + query: dict[str, object], + paths: Sequence[Sequence[str]], + expected: list[tuple[str, FileTypes]], +) -> None: + assert extract_files(query, paths=paths) == expected + + +@pytest.mark.parametrize( + "array_format,expected_top_level,expected_nested", + [ + ("brackets", [("files[]", b"a"), ("files[]", b"b")], [("items[][file]", b"a"), ("items[][file]", b"b")]), + ("repeat", [("files", b"a"), ("files", b"b")], [("items[file]", b"a"), ("items[file]", b"b")]), + ("comma", [("files", b"a"), ("files", b"b")], [("items[file]", b"a"), ("items[file]", b"b")]), + ("indices", [("files[0]", b"a"), ("files[1]", b"b")], [("items[0][file]", b"a"), ("items[1][file]", b"b")]), + ], +) +def test_array_format_controls_file_field_names( + array_format: ArrayFormat, + expected_top_level: list[tuple[str, FileTypes]], + expected_nested: list[tuple[str, FileTypes]], +) -> None: + top_level = {"files": [b"a", b"b"]} + assert extract_files(top_level, paths=[["files", ""]], array_format=array_format) == expected_top_level + + nested = {"items": [{"file": b"a"}, {"file": b"b"}]} + assert extract_files(nested, paths=[["items", "", "file"]], array_format=array_format) == expected_nested diff --git a/tests/test_files.py b/tests/test_files.py new file mode 100644 index 000000000..947bc6a51 --- /dev/null +++ b/tests/test_files.py @@ -0,0 +1,148 @@ +from pathlib import Path + +import anyio +import pytest +from dirty_equals import IsDict, IsList, IsBytes, IsTuple + +from agentex._files import to_httpx_files, deepcopy_with_paths, async_to_httpx_files +from agentex._utils import extract_files + +readme_path = Path(__file__).parent.parent.joinpath("README.md") + + +def test_pathlib_includes_file_name() -> None: + result = to_httpx_files({"file": readme_path}) + print(result) + assert result == IsDict({"file": IsTuple("README.md", IsBytes())}) + + +def test_tuple_input() -> None: + result = to_httpx_files([("file", readme_path)]) + print(result) + assert result == IsList(IsTuple("file", IsTuple("README.md", IsBytes()))) + + +@pytest.mark.asyncio +async def test_async_pathlib_includes_file_name() -> None: + result = await async_to_httpx_files({"file": readme_path}) + print(result) + assert result == IsDict({"file": IsTuple("README.md", IsBytes())}) + + +@pytest.mark.asyncio +async def test_async_supports_anyio_path() -> None: + result = await async_to_httpx_files({"file": anyio.Path(readme_path)}) + print(result) + assert result == IsDict({"file": IsTuple("README.md", IsBytes())}) + + +@pytest.mark.asyncio +async def test_async_tuple_input() -> None: + result = await async_to_httpx_files([("file", readme_path)]) + print(result) + assert result == IsList(IsTuple("file", IsTuple("README.md", IsBytes()))) + + +def test_string_not_allowed() -> None: + with pytest.raises(TypeError, match="Expected file types input to be a FileContent type or to be a tuple"): + to_httpx_files( + { + "file": "foo", # type: ignore + } + ) + + +def assert_different_identities(obj1: object, obj2: object) -> None: + assert obj1 == obj2 + assert obj1 is not obj2 + + +class TestDeepcopyWithPaths: + def test_copies_top_level_dict(self) -> None: + original = {"file": b"data", "other": "value"} + result = deepcopy_with_paths(original, [["file"]]) + assert_different_identities(result, original) + + def test_file_value_is_same_reference(self) -> None: + file_bytes = b"contents" + original = {"file": file_bytes} + result = deepcopy_with_paths(original, [["file"]]) + assert_different_identities(result, original) + assert result["file"] is file_bytes + + def test_list_popped_wholesale(self) -> None: + files = [b"f1", b"f2"] + original = {"files": files, "title": "t"} + result = deepcopy_with_paths(original, [["files", ""]]) + assert_different_identities(result, original) + result_files = result["files"] + assert isinstance(result_files, list) + assert_different_identities(result_files, files) + + def test_nested_array_path_copies_list_and_elements(self) -> None: + elem1 = {"file": b"f1", "extra": 1} + elem2 = {"file": b"f2", "extra": 2} + original = {"items": [elem1, elem2]} + result = deepcopy_with_paths(original, [["items", "", "file"]]) + assert_different_identities(result, original) + result_items = result["items"] + assert isinstance(result_items, list) + assert_different_identities(result_items, original["items"]) + assert_different_identities(result_items[0], elem1) + assert_different_identities(result_items[1], elem2) + + def test_empty_paths_returns_same_object(self) -> None: + original = {"foo": "bar"} + result = deepcopy_with_paths(original, []) + assert result is original + + def test_multiple_paths(self) -> None: + f1 = b"file1" + f2 = b"file2" + original = {"a": f1, "b": f2, "c": "unchanged"} + result = deepcopy_with_paths(original, [["a"], ["b"]]) + assert_different_identities(result, original) + assert result["a"] is f1 + assert result["b"] is f2 + assert result["c"] is original["c"] + + def test_extract_files_does_not_mutate_original_top_level(self) -> None: + file_bytes = b"contents" + original = {"file": file_bytes, "other": "value"} + + copied = deepcopy_with_paths(original, [["file"]]) + extracted = extract_files(copied, paths=[["file"]]) + + assert extracted == [("file", file_bytes)] + assert original == {"file": file_bytes, "other": "value"} + assert copied == {"other": "value"} + + def test_extract_files_does_not_mutate_original_nested_array_path(self) -> None: + file1 = b"f1" + file2 = b"f2" + original = { + "items": [ + {"file": file1, "extra": 1}, + {"file": file2, "extra": 2}, + ], + "title": "example", + } + + copied = deepcopy_with_paths(original, [["items", "", "file"]]) + extracted = extract_files(copied, paths=[["items", "", "file"]]) + + assert [entry for _, entry in extracted] == [file1, file2] + assert original == { + "items": [ + {"file": file1, "extra": 1}, + {"file": file2, "extra": 2}, + ], + "title": "example", + } + assert copied == { + "items": [ + {"extra": 1}, + {"extra": 2}, + ], + "title": "example", + } diff --git a/tests/test_function_tool.py b/tests/test_function_tool.py new file mode 100644 index 000000000..484ce8af2 --- /dev/null +++ b/tests/test_function_tool.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import json +from typing import Any, override + +import pytest +from pydantic import ValidationError + +from agentex.lib.core.temporal.activities.adk.providers.openai_activities import ( + FunctionTool, +) + + +def sample_handler(context, args: str) -> str: + """Sample handler function for testing.""" + return f"Processed: {args}" + + +def complex_handler(context, args: str) -> dict[str, Any]: + """More complex handler that returns structured data.""" + parsed_args = json.loads(args) if args else {} + return { + "status": "success", + "input": parsed_args, + "context_info": str(type(context)), + } + + +class TestFunctionTool: + """Test cases for FunctionTool serialization with JSON.""" + + def test_basic_serialization_with_json(self): + """Test that FunctionTool can be serialized and deserialized with JSON.""" + # Create a FunctionTool with a callable + tool = FunctionTool( + name="test_tool", + description="A test tool", + params_json_schema={"type": "string"}, + strict_json_schema=True, + is_enabled=True, + on_invoke_tool=sample_handler, + ) + + # Serialize to JSON (this is what the caller will do) + json_data = json.dumps(tool.model_dump()) + + # Deserialize from JSON + data = json.loads(json_data) + new_tool = FunctionTool.model_validate(data) + + # Test that the callable is restored + assert new_tool.on_invoke_tool is not None + assert callable(new_tool.on_invoke_tool) + + # Test that the callable works as expected + result = new_tool.on_invoke_tool(None, "test_input") + assert result == "Processed: test_input" + + def test_complex_function_serialization(self): + """Test serialization of more complex functions.""" + tool = FunctionTool( + name="complex_tool", + description="A complex test tool", + params_json_schema={ + "type": "object", + "properties": {"key": {"type": "string"}}, + }, + on_invoke_tool=complex_handler, + ) + + # Serialize and deserialize via JSON + json_data = json.dumps(tool.model_dump()) + data = json.loads(json_data) + new_tool = FunctionTool.model_validate(data) + + # Test the complex function + test_input = '{"test": "value"}' + result = new_tool.on_invoke_tool(None, test_input) + + assert result["status"] == "success" + assert result["input"] == {"test": "value"} + + def test_none_callable_handling(self): + """Test that passing None for callable raises an error.""" + # Test that None callable raises ValueError + with pytest.raises( + ValueError, + match="One of `on_invoke_tool` or `on_invoke_tool_serialized` should be set", + ): + FunctionTool( + name="empty_tool", + description="Tool with no callable", + params_json_schema={"type": "string"}, + on_invoke_tool=None, + ) + + # Test with valid function - this should work + tool_func = FunctionTool( + name="func_tool", + description="Tool with function", + params_json_schema={"type": "string"}, + on_invoke_tool=sample_handler, + ) + assert tool_func.on_invoke_tool is not None + + def test_lambda_function_serialization(self): + """Test that lambda functions can be serialized.""" + # Set a lambda function + tool = FunctionTool( + name="lambda_tool", + description="Tool with lambda", + params_json_schema={"type": "string"}, + on_invoke_tool=lambda ctx, args: f"Lambda result: {args}", + ) + + # Serialize and deserialize via JSON + json_data = json.dumps(tool.model_dump()) + data = json.loads(json_data) + new_tool = FunctionTool.model_validate(data) + + # Test that the lambda works + result = new_tool.on_invoke_tool(None, "test") + assert result == "Lambda result: test" + + def test_closure_serialization(self): + """Test that closures can be serialized.""" + + def create_handler(prefix: str): + def handler(context, args: str) -> str: + return f"{prefix}: {args}" + + return handler + + # Set a closure + tool = FunctionTool( + name="closure_tool", + description="Tool with closure", + params_json_schema={"type": "string"}, + on_invoke_tool=create_handler("PREFIX"), + ) + + # Serialize and deserialize via JSON + json_data = json.dumps(tool.model_dump()) + data = json.loads(json_data) + new_tool = FunctionTool.model_validate(data) + + # Test that the closure works with captured variable + result = new_tool.on_invoke_tool(None, "test") + assert result == "PREFIX: test" + + def test_function_tool_with_none_handler_raises_error(self): + """Test that trying to create tool with None handler raises error.""" + # Test that None callable raises ValueError + with pytest.raises( + ValueError, + match="One of `on_invoke_tool` or `on_invoke_tool_serialized` should be set", + ): + FunctionTool( + name="none_handler_test", + description="Test tool with None handler", + params_json_schema={"type": "string"}, + on_invoke_tool=None, + ) + + def test_to_oai_function_tool_with_valid_handler(self): + """Test that to_oai_function_tool works with valid function.""" + tool = FunctionTool( + name="valid_handler_test", + description="Test tool with valid handler", + params_json_schema={"type": "string"}, + on_invoke_tool=sample_handler, + ) + + # This should work when on_invoke_tool is set + oai_tool = tool.to_oai_function_tool() + + # Verify the OAI tool was created successfully + assert oai_tool is not None + assert oai_tool.name == "valid_handler_test" + assert oai_tool.description == "Test tool with valid handler" + assert oai_tool.on_invoke_tool is not None + assert callable(oai_tool.on_invoke_tool) + + # Test that the handler works through the OAI tool + result = oai_tool.on_invoke_tool(None, "test_input") + assert result == "Processed: test_input" + + def test_serialization_error_handling(self): + """Test error handling when serialization fails.""" + + # Try to create a FunctionTool with an unserializable callable + class UnserializableCallable: + def __call__(self, context, args): + return "test" + + @override + def __getstate__(self): + raise Exception("Cannot serialize this object") + + unserializable = UnserializableCallable() + + # This should raise an Exception during construction (from the unserializable object) + with pytest.raises(Exception, match="Cannot serialize this object"): + FunctionTool( + name="error_test_with_unserializable", + description="Test error handling with unserializable", + params_json_schema={"type": "string"}, + on_invoke_tool=unserializable, + ) + + def test_deserialization_error_handling(self): + """Test error handling when deserialization fails.""" + + # Create a tool and manually corrupt its serialized data to test deserialization error + # First, create a valid tool + valid_tool = FunctionTool( + name="valid_tool", + description="Valid tool for corruption", + params_json_schema={"type": "string"}, + on_invoke_tool=sample_handler, + ) + + # Serialize it + serialized_data = valid_tool.model_dump() + + # Corrupt the serialized callable data with invalid base64 + serialized_data["on_invoke_tool_serialized"] = ( + "invalid_base64_data!" # Add invalid character + ) + + # This should raise an error during model validation due to invalid base64 + with pytest.raises((ValidationError, ValueError)): + FunctionTool.model_validate(serialized_data) + + def test_full_roundtrip_with_serialization(self): + """Test a full roundtrip with a single tool.""" + tool = FunctionTool( + name="test_tool", + description="Test tool for roundtrip", + params_json_schema={"type": "string"}, + on_invoke_tool=lambda ctx, args: f"Tool result: {args}", + ) + + # Serialize tool to JSON + json_data = json.dumps(tool.model_dump()) + + # Deserialize from JSON + data = json.loads(json_data) + new_tool = FunctionTool.model_validate(data) + + # Test the tool + result = new_tool.on_invoke_tool(None, "test") + assert "Tool result: test" == result + + result = new_tool.to_oai_function_tool().on_invoke_tool(None, "test") + assert "Tool result: test" == result diff --git a/tests/test_header_forwarding.py b/tests/test_header_forwarding.py new file mode 100644 index 000000000..596c6729c --- /dev/null +++ b/tests/test_header_forwarding.py @@ -0,0 +1,541 @@ +# ruff: noqa: I001 +from __future__ import annotations +from typing import Any, override +import sys +import types +from datetime import datetime, timezone +from unittest.mock import AsyncMock, Mock + +import pytest +from fastapi.testclient import TestClient + +"""Header forwarding tests consolidated. + +We stub tracing modules to avoid circular imports when importing ACPService. +""" + +# Stub tracing modules before importing ACPService +tracer_stub = types.ModuleType("agentex.lib.core.tracing.tracer") + +class _StubSpan: + async def __aenter__(self): + return self + async def __aexit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: object) -> bool: + return False + +class _StubTrace: + def span(self, **kwargs: Any) -> _StubSpan: # type: ignore[name-defined] + return _StubSpan() + +class _StubAsyncTracer: + def __init__(self, *args: Any, **kwargs: Any) -> None: + pass + def trace(self, trace_id: str | None = None) -> _StubTrace: # type: ignore[name-defined] + return _StubTrace() + +class _StubTracer(_StubAsyncTracer): + pass +tracer_stub.AsyncTracer = _StubAsyncTracer # type: ignore[attr-defined] +tracer_stub.Tracer = _StubTracer # type: ignore[attr-defined] +sys.modules["agentex.lib.core.tracing.tracer"] = tracer_stub + +tracing_pkg_stub = types.ModuleType("agentex.lib.core.tracing") +tracing_pkg_stub.AsyncTracer = _StubAsyncTracer # type: ignore[attr-defined] +tracing_pkg_stub.Tracer = _StubTracer # type: ignore[attr-defined] +sys.modules["agentex.lib.core.tracing"] = tracing_pkg_stub + +from agentex.lib.core.services.adk.acp.acp import ACPService +from agentex.lib.sdk.fastacp.base.base_acp_server import BaseACPServer +from agentex.protocol.acp import RPCMethod, SendMessageParams, SendEventParams +from agentex.types.task_message_content import TextContent +from agentex.lib.sdk.fastacp.impl.temporal_acp import TemporalACP +from agentex.lib.core.temporal.services.temporal_task_service import TemporalTaskService +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.types.agent import Agent +from agentex.types.task import Task +from agentex.types.event import Event + + +class DummySpan: + def __init__(self, **_kwargs: Any) -> None: + self.output = None + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: object) -> bool: + return False + + +class DummyTrace: + def span(self, **kwargs: Any) -> DummySpan: + return DummySpan(**kwargs) + + +class DummyTracer: + def trace(self, trace_id: str | None = None) -> DummyTrace: + return DummyTrace() + + +class DummyAgents: + async def rpc_by_name(self, *args: Any, **kwargs: Any) -> Any: + # Support both positional and keyword agent name, and both params/_params + method = kwargs.get("method") + extra_headers = kwargs.get("extra_headers") + # Ensure headers are forwarded as-is + assert extra_headers == {"x-user": "a", "authorization": "b"} + # Minimal response object with .result + if method == "task/create": + return type("R", (), {"result": {"id": "t1"}})() + if method == "message/send": + # include required task_id for TaskMessage model + return type("R", (), {"result": {"id": "m1", "task_id": "t1", "content": {"type": "text", "author": "user", "content": "ok"}}})() + if method == "event/send": + # include required fields for Event model + return type("R", (), {"result": {"id": "e1", "agent_id": "a1", "task_id": "t1", "sequence_id": 1}})() + if method == "task/cancel": + return type("R", (), {"result": {"id": "t1"}})() + raise AssertionError("Unexpected method") + + +class DummyClient: + def __init__(self) -> None: + self.agents = DummyAgents() + + +@pytest.mark.asyncio +async def test_header_forwarding() -> None: + client = DummyClient() + svc = ACPService(agentex_client=client, tracer=DummyTracer()) # type: ignore[arg-type] + + # Create task + task = await svc.task_create(agent_name="x", request={"headers": {"x-user": "a", "authorization": "b"}}) + assert task.id == "t1" + + # Send message + msgs = await svc.message_send( + agent_name="x", + task_id="t1", + content=TextContent(author="user", content="hi"), + request={"headers": {"x-user": "a", "authorization": "b"}}, + ) + assert len(msgs) == 1 + + # Send event + evt = await svc.event_send( + agent_name="x", + task_id="t1", + content=TextContent(author="user", content="hi"), + request={"headers": {"x-user": "a", "authorization": "b"}}, + ) + assert evt.id == "e1" + + # Cancel + task2 = await svc.task_cancel(agent_name="x", task_id="t1", request={"headers": {"x-user": "a", "authorization": "b"}}) + assert task2.id == "t1" + + +class TestServer(BaseACPServer): + __test__ = False + @override + def _setup_handlers(self): + @self.on_message_send + async def handler(params: SendMessageParams): # type: ignore[reportUnusedFunction] + headers = (params.request or {}).get("headers", {}) + assert "x-agent-api-key" not in headers + assert headers.get("x-user") == "a" + return TextContent(author="agent", content="ok") + + +def test_excludes_agent_api_key_header(): + app = TestServer.create() + client = TestClient(app) + req = { + "jsonrpc": "2.0", + "method": RPCMethod.MESSAGE_SEND.value, + "params": { + "agent": {"id": "a1", "name": "n1", "description": "d", "acp_type": "sync"}, + "task": {"id": "t1"}, + "content": {"type": "text", "author": "user", "content": "hi"}, + "stream": False, + }, + "id": 1, + } + r = client.post("/api", json=req, headers={"x-user": "a", "x-agent-api-key": "secret"}) + assert r.status_code == 200 + + +def filter_headers_standalone( + headers: dict[str, str] | None, + allowlist: list[str] | None +) -> dict[str, str]: + """Standalone header filtering function matching the production implementation.""" + if not headers: + return {} + + # Pass-through behavior: if no allowlist, forward all headers + if allowlist is None: + return headers + + # Apply filtering based on allowlist + if not allowlist: + return {} + + import fnmatch + filtered = {} + for header_name, header_value in headers.items(): + # Check against allowlist patterns (case-insensitive) + header_allowed = False + for pattern in allowlist: + if fnmatch.fnmatch(header_name.lower(), pattern.lower()): + header_allowed = True + break + + if header_allowed: + filtered[header_name] = header_value + + return filtered + + +def test_filter_headers_no_headers() -> None: + allowlist = ["x-user-email"] + result = filter_headers_standalone(None, allowlist) + assert result == {} + + result = filter_headers_standalone({}, allowlist) + assert result == {} + + +def test_filter_headers_pass_through_by_default() -> None: + headers = { + "x-user-email": "test@example.com", + "x-admin-token": "secret", + "authorization": "Bearer token", + "x-custom-header": "value" + } + result = filter_headers_standalone(headers, None) + assert result == headers + + +def test_filter_headers_empty_allowlist() -> None: + allowlist: list[str] = [] + headers = {"x-user-email": "test@example.com", "x-admin-token": "secret"} + result = filter_headers_standalone(headers, allowlist) + assert result == {} + + +def test_filter_headers_allowed_headers() -> None: + allowlist = ["x-user-email", "x-tenant-id"] + headers = { + "x-user-email": "test@example.com", + "x-tenant-id": "tenant123", + "x-admin-token": "secret", + "content-type": "application/json" + } + result = filter_headers_standalone(headers, allowlist) + expected = { + "x-user-email": "test@example.com", + "x-tenant-id": "tenant123" + } + assert result == expected + + +def test_filter_headers_case_insensitive_patterns() -> None: + allowlist = ["X-User-Email", "x-tenant-*"] + headers = { + "x-user-email": "test@example.com", + "X-TENANT-ID": "tenant123", + "x-tenant-name": "acme", + "x-admin-token": "secret" + } + result = filter_headers_standalone(headers, allowlist) + expected = { + "x-user-email": "test@example.com", + "X-TENANT-ID": "tenant123", + "x-tenant-name": "acme" + } + assert result == expected + + +def test_filter_headers_wildcard_patterns() -> None: + allowlist = ["x-user-*", "authorization"] + headers = { + "x-user-id": "123", + "x-user-email": "test@example.com", + "x-user-role": "admin", + "authorization": "Bearer token", + "x-system-info": "blocked", + "content-type": "application/json" + } + result = filter_headers_standalone(headers, allowlist) + expected = { + "x-user-id": "123", + "x-user-email": "test@example.com", + "x-user-role": "admin", + "authorization": "Bearer token" + } + assert result == expected + + +def test_filter_headers_complex_patterns() -> None: + allowlist = ["x-tenant-*", "x-user-[abc]*", "auth*"] + headers = { + "x-tenant-id": "tenant1", + "x-tenant-name": "acme", + "x-user-admin": "true", + "x-user-beta": "false", + "x-user-delta": "test", + "authorization": "Bearer x", + "authenticate": "digest", + "content-type": "json", + } + result = filter_headers_standalone(headers, allowlist) + expected = { + "x-tenant-id": "tenant1", + "x-tenant-name": "acme", + "x-user-admin": "true", + "x-user-beta": "false", + "authorization": "Bearer x", + "authenticate": "digest" + } + assert result == expected + + +def test_filter_headers_all_types() -> None: + allowlist = ["authorization", "accept-language", "custom-*"] + headers = { + "authorization": "Bearer token", + "accept-language": "en-US", + "custom-header": "value", + "custom-auth": "token", + "content-type": "application/json", + "x-blocked": "value" + } + result = filter_headers_standalone(headers, allowlist) + expected = { + "authorization": "Bearer token", + "accept-language": "en-US", + "custom-header": "value", + "custom-auth": "token" + } + assert result == expected + + + +# ============================================================================ +# Temporal Header Forwarding Tests +# ============================================================================ + +@pytest.fixture +def mock_temporal_client(): + """Create a mock TemporalClient""" + client = AsyncMock() + client.send_signal = AsyncMock(return_value=None) + return client + + +@pytest.fixture +def mock_env_vars(): + """Create mock environment variables""" + env_vars = Mock(spec=EnvironmentVariables) + env_vars.WORKFLOW_NAME = "test-workflow" + env_vars.WORKFLOW_TASK_QUEUE = "test-queue" + return env_vars + + +@pytest.fixture +def temporal_task_service(mock_temporal_client, mock_env_vars): + """Create TemporalTaskService with mocked client""" + return TemporalTaskService( + temporal_client=mock_temporal_client, + env_vars=mock_env_vars, + ) + + +@pytest.fixture +def sample_agent(): + """Create a sample agent""" + return Agent( + id="agent-123", + name="test-agent", + description="Test agent", + acp_type="async", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + + +@pytest.fixture +def sample_task(): + """Create a sample task""" + return Task(id="task-456") + + +@pytest.fixture +def sample_event(): + """Create a sample event""" + return Event( + id="event-789", + agent_id="agent-123", + task_id="task-456", + sequence_id=1, + content=TextContent(author="user", content="Test message") + ) + + +@pytest.mark.asyncio +async def test_temporal_task_service_send_event_with_headers( + temporal_task_service, + mock_temporal_client, + sample_agent, + sample_task, + sample_event +): + """Test that TemporalTaskService forwards request headers in signal payload""" + # Given + request_headers = { + "x-user-oauth-credentials": "test-oauth-token", + "x-custom-header": "custom-value" + } + request = {"headers": request_headers} + + # When + await temporal_task_service.send_event( + agent=sample_agent, + task=sample_task, + event=sample_event, + request=request + ) + + # Then + mock_temporal_client.send_signal.assert_called_once() + call_args = mock_temporal_client.send_signal.call_args + + # Verify the signal was sent to the correct workflow + assert call_args.kwargs["workflow_id"] == sample_task.id + assert call_args.kwargs["signal"] == "receive_event" + + # Verify the payload includes the request with headers + payload = call_args.kwargs["payload"] + assert "request" in payload + assert payload["request"] == request + assert payload["request"]["headers"] == request_headers + + +@pytest.mark.asyncio +async def test_temporal_task_service_send_event_without_headers( + temporal_task_service, + mock_temporal_client, + sample_agent, + sample_task, + sample_event +): + """Test that TemporalTaskService handles missing request gracefully""" + # When - Send event without request parameter + await temporal_task_service.send_event( + agent=sample_agent, + task=sample_task, + event=sample_event, + request=None + ) + + # Then + mock_temporal_client.send_signal.assert_called_once() + call_args = mock_temporal_client.send_signal.call_args + + # Verify the payload has request as None + payload = call_args.kwargs["payload"] + assert payload["request"] is None + + +@pytest.mark.asyncio +async def test_temporal_acp_integration_with_request_headers( + mock_temporal_client, + mock_env_vars, + sample_agent, + sample_task, + sample_event +): + """Test end-to-end integration: TemporalACP -> TemporalTaskService -> TemporalClient signal""" + # Given - Create real TemporalTaskService with mocked client + task_service = TemporalTaskService( + temporal_client=mock_temporal_client, + env_vars=mock_env_vars, + ) + + # Create TemporalACP with real task service + temporal_acp = TemporalACP( + temporal_address="localhost:7233", + temporal_task_service=task_service, + ) + temporal_acp._setup_handlers() + + request_headers = { + "x-user-id": "user-123", + "authorization": "Bearer token", + "x-tenant-id": "tenant-456" + } + request = {"headers": request_headers} + + # Create SendEventParams as TemporalACP would receive it + params = SendEventParams( + agent=sample_agent, + task=sample_task, + event=sample_event, + request=request + ) + + # When - Trigger the event handler via the decorated function + # The handler is registered via @temporal_acp.on_task_event_send + # We'll directly call the task service method as the handler does + await task_service.send_event( + agent=params.agent, + task=params.task, + event=params.event, + request=params.request + ) + + # Then - Verify the temporal client received the signal with request headers + mock_temporal_client.send_signal.assert_called_once() + call_args = mock_temporal_client.send_signal.call_args + + # Verify signal payload includes request with headers + payload = call_args.kwargs["payload"] + assert payload["request"] == request + assert payload["request"]["headers"] == request_headers + + +@pytest.mark.asyncio +async def test_temporal_task_service_preserves_all_header_types( + temporal_task_service, + mock_temporal_client, + sample_agent, + sample_task, + sample_event +): + """Test that various header types are preserved correctly""" + # Given - Headers with different patterns + request_headers = { + "x-user-oauth-credentials": "oauth-token-12345", + "authorization": "Bearer jwt-token", + "x-tenant-id": "tenant-999", + "x-custom-app-header": "custom-value" + } + request = {"headers": request_headers} + + # When + await temporal_task_service.send_event( + agent=sample_agent, + task=sample_task, + event=sample_event, + request=request + ) + + # Then - Verify all headers are preserved in the signal payload + call_args = mock_temporal_client.send_signal.call_args + payload = call_args.kwargs["payload"] + + assert payload["request"]["headers"] == request_headers + # Verify each header individually + for header_name, header_value in request_headers.items(): + assert payload["request"]["headers"][header_name] == header_value diff --git a/tests/test_model_utils.py b/tests/test_model_utils.py new file mode 100644 index 000000000..9c570223d --- /dev/null +++ b/tests/test_model_utils.py @@ -0,0 +1,226 @@ +import json +from datetime import datetime + +from pydantic import BaseModel + +from agentex.lib.utils.model_utils import recursive_model_dump + + +class SampleModel(BaseModel): + """Sample model for testing recursive_model_dump functionality.""" + + name: str + value: int + + +def sample_function(): + """A sample function for testing function serialization.""" + return "test" + + +def another_function(x: int) -> str: + """Another sample function with parameters.""" + return str(x) + + +class TestRecursiveModelDump: + """Test cases for the recursive_model_dump function.""" + + def test_pydantic_model_serialization(self): + """Test that Pydantic models are properly serialized.""" + model = SampleModel(name="test", value=42) + result = recursive_model_dump(model) + + assert isinstance(result, dict) + assert result["name"] == "test" + assert result["value"] == 42 + + def test_datetime_serialization(self): + """Test that datetime objects are serialized to ISO format.""" + dt = datetime(2023, 12, 25, 10, 30, 45) + result = recursive_model_dump(dt) + + assert isinstance(result, str) + assert result == "2023-12-25T10:30:45" + + def test_function_serialization(self): + """Test that functions are properly serialized to string representation.""" + result = recursive_model_dump(sample_function) + + assert isinstance(result, str) + assert result.startswith(" int: + return x * 2 + + result = recursive_model_dump(lambda_like_func) + + assert isinstance(result, str) + assert result.startswith(" None: + m = BasicModel.construct(foo=value) + assert m.foo == value + + +def test_directly_nested_model() -> None: + class NestedModel(BaseModel): + nested: BasicModel + + m = NestedModel.construct(nested={"foo": "Foo!"}) + assert m.nested.foo == "Foo!" + + # mismatched types + m = NestedModel.construct(nested="hello!") + assert cast(Any, m.nested) == "hello!" + + +def test_optional_nested_model() -> None: + class NestedModel(BaseModel): + nested: Optional[BasicModel] + + m1 = NestedModel.construct(nested=None) + assert m1.nested is None + + m2 = NestedModel.construct(nested={"foo": "bar"}) + assert m2.nested is not None + assert m2.nested.foo == "bar" + + # mismatched types + m3 = NestedModel.construct(nested={"foo"}) + assert isinstance(cast(Any, m3.nested), set) + assert cast(Any, m3.nested) == {"foo"} + + +def test_list_nested_model() -> None: + class NestedModel(BaseModel): + nested: List[BasicModel] + + m = NestedModel.construct(nested=[{"foo": "bar"}, {"foo": "2"}]) + assert m.nested is not None + assert isinstance(m.nested, list) + assert len(m.nested) == 2 + assert m.nested[0].foo == "bar" + assert m.nested[1].foo == "2" + + # mismatched types + m = NestedModel.construct(nested=True) + assert cast(Any, m.nested) is True + + m = NestedModel.construct(nested=[False]) + assert cast(Any, m.nested) == [False] + + +def test_optional_list_nested_model() -> None: + class NestedModel(BaseModel): + nested: Optional[List[BasicModel]] + + m1 = NestedModel.construct(nested=[{"foo": "bar"}, {"foo": "2"}]) + assert m1.nested is not None + assert isinstance(m1.nested, list) + assert len(m1.nested) == 2 + assert m1.nested[0].foo == "bar" + assert m1.nested[1].foo == "2" + + m2 = NestedModel.construct(nested=None) + assert m2.nested is None + + # mismatched types + m3 = NestedModel.construct(nested={1}) + assert cast(Any, m3.nested) == {1} + + m4 = NestedModel.construct(nested=[False]) + assert cast(Any, m4.nested) == [False] + + +def test_list_optional_items_nested_model() -> None: + class NestedModel(BaseModel): + nested: List[Optional[BasicModel]] + + m = NestedModel.construct(nested=[None, {"foo": "bar"}]) + assert m.nested is not None + assert isinstance(m.nested, list) + assert len(m.nested) == 2 + assert m.nested[0] is None + assert m.nested[1] is not None + assert m.nested[1].foo == "bar" + + # mismatched types + m3 = NestedModel.construct(nested="foo") + assert cast(Any, m3.nested) == "foo" + + m4 = NestedModel.construct(nested=[False]) + assert cast(Any, m4.nested) == [False] + + +def test_list_mismatched_type() -> None: + class NestedModel(BaseModel): + nested: List[str] + + m = NestedModel.construct(nested=False) + assert cast(Any, m.nested) is False + + +def test_raw_dictionary() -> None: + class NestedModel(BaseModel): + nested: Dict[str, str] + + m = NestedModel.construct(nested={"hello": "world"}) + assert m.nested == {"hello": "world"} + + # mismatched types + m = NestedModel.construct(nested=False) + assert cast(Any, m.nested) is False + + +def test_nested_dictionary_model() -> None: + class NestedModel(BaseModel): + nested: Dict[str, BasicModel] + + m = NestedModel.construct(nested={"hello": {"foo": "bar"}}) + assert isinstance(m.nested, dict) + assert m.nested["hello"].foo == "bar" + + # mismatched types + m = NestedModel.construct(nested={"hello": False}) + assert cast(Any, m.nested["hello"]) is False + + +def test_unknown_fields() -> None: + m1 = BasicModel.construct(foo="foo", unknown=1) + assert m1.foo == "foo" + assert cast(Any, m1).unknown == 1 + + m2 = BasicModel.construct(foo="foo", unknown={"foo_bar": True}) + assert m2.foo == "foo" + assert cast(Any, m2).unknown == {"foo_bar": True} + + assert model_dump(m2) == {"foo": "foo", "unknown": {"foo_bar": True}} + + +def test_strict_validation_unknown_fields() -> None: + class Model(BaseModel): + foo: str + + model = parse_obj(Model, dict(foo="hello!", user="Robert")) + assert model.foo == "hello!" + assert cast(Any, model).user == "Robert" + + assert model_dump(model) == {"foo": "hello!", "user": "Robert"} + + +def test_aliases() -> None: + class Model(BaseModel): + my_field: int = Field(alias="myField") + + m = Model.construct(myField=1) + assert m.my_field == 1 + + # mismatched types + m = Model.construct(myField={"hello": False}) + assert cast(Any, m.my_field) == {"hello": False} + + +def test_repr() -> None: + model = BasicModel(foo="bar") + assert str(model) == "BasicModel(foo='bar')" + assert repr(model) == "BasicModel(foo='bar')" + + +def test_repr_nested_model() -> None: + class Child(BaseModel): + name: str + age: int + + class Parent(BaseModel): + name: str + child: Child + + model = Parent(name="Robert", child=Child(name="Foo", age=5)) + assert str(model) == "Parent(name='Robert', child=Child(name='Foo', age=5))" + assert repr(model) == "Parent(name='Robert', child=Child(name='Foo', age=5))" + + +def test_optional_list() -> None: + class Submodel(BaseModel): + name: str + + class Model(BaseModel): + items: Optional[List[Submodel]] + + m = Model.construct(items=None) + assert m.items is None + + m = Model.construct(items=[]) + assert m.items == [] + + m = Model.construct(items=[{"name": "Robert"}]) + assert m.items is not None + assert len(m.items) == 1 + assert m.items[0].name == "Robert" + + +def test_nested_union_of_models() -> None: + class Submodel1(BaseModel): + bar: bool + + class Submodel2(BaseModel): + thing: str + + class Model(BaseModel): + foo: Union[Submodel1, Submodel2] + + m = Model.construct(foo={"thing": "hello"}) + assert isinstance(m.foo, Submodel2) + assert m.foo.thing == "hello" + + +def test_nested_union_of_mixed_types() -> None: + class Submodel1(BaseModel): + bar: bool + + class Model(BaseModel): + foo: Union[Submodel1, Literal[True], Literal["CARD_HOLDER"]] + + m = Model.construct(foo=True) + assert m.foo is True + + m = Model.construct(foo="CARD_HOLDER") + assert m.foo == "CARD_HOLDER" + + m = Model.construct(foo={"bar": False}) + assert isinstance(m.foo, Submodel1) + assert m.foo.bar is False + + +def test_nested_union_multiple_variants() -> None: + class Submodel1(BaseModel): + bar: bool + + class Submodel2(BaseModel): + thing: str + + class Submodel3(BaseModel): + foo: int + + class Model(BaseModel): + foo: Union[Submodel1, Submodel2, None, Submodel3] + + m = Model.construct(foo={"thing": "hello"}) + assert isinstance(m.foo, Submodel2) + assert m.foo.thing == "hello" + + m = Model.construct(foo=None) + assert m.foo is None + + m = Model.construct() + assert m.foo is None + + m = Model.construct(foo={"foo": "1"}) + assert isinstance(m.foo, Submodel3) + assert m.foo.foo == 1 + + +def test_nested_union_invalid_data() -> None: + class Submodel1(BaseModel): + level: int + + class Submodel2(BaseModel): + name: str + + class Model(BaseModel): + foo: Union[Submodel1, Submodel2] + + m = Model.construct(foo=True) + assert cast(bool, m.foo) is True + + m = Model.construct(foo={"name": 3}) + if PYDANTIC_V1: + assert isinstance(m.foo, Submodel2) + assert m.foo.name == "3" + else: + assert isinstance(m.foo, Submodel1) + assert m.foo.name == 3 # type: ignore + + +def test_list_of_unions() -> None: + class Submodel1(BaseModel): + level: int + + class Submodel2(BaseModel): + name: str + + class Model(BaseModel): + items: List[Union[Submodel1, Submodel2]] + + m = Model.construct(items=[{"level": 1}, {"name": "Robert"}]) + assert len(m.items) == 2 + assert isinstance(m.items[0], Submodel1) + assert m.items[0].level == 1 + assert isinstance(m.items[1], Submodel2) + assert m.items[1].name == "Robert" + + m = Model.construct(items=[{"level": -1}, 156]) + assert len(m.items) == 2 + assert isinstance(m.items[0], Submodel1) + assert m.items[0].level == -1 + assert cast(Any, m.items[1]) == 156 + + +def test_union_of_lists() -> None: + class SubModel1(BaseModel): + level: int + + class SubModel2(BaseModel): + name: str + + class Model(BaseModel): + items: Union[List[SubModel1], List[SubModel2]] + + # with one valid entry + m = Model.construct(items=[{"name": "Robert"}]) + assert len(m.items) == 1 + assert isinstance(m.items[0], SubModel2) + assert m.items[0].name == "Robert" + + # with two entries pointing to different types + m = Model.construct(items=[{"level": 1}, {"name": "Robert"}]) + assert len(m.items) == 2 + assert isinstance(m.items[0], SubModel1) + assert m.items[0].level == 1 + assert isinstance(m.items[1], SubModel1) + assert cast(Any, m.items[1]).name == "Robert" + + # with two entries pointing to *completely* different types + m = Model.construct(items=[{"level": -1}, 156]) + assert len(m.items) == 2 + assert isinstance(m.items[0], SubModel1) + assert m.items[0].level == -1 + assert cast(Any, m.items[1]) == 156 + + +def test_dict_of_union() -> None: + class SubModel1(BaseModel): + name: str + + class SubModel2(BaseModel): + foo: str + + class Model(BaseModel): + data: Dict[str, Union[SubModel1, SubModel2]] + + m = Model.construct(data={"hello": {"name": "there"}, "foo": {"foo": "bar"}}) + assert len(list(m.data.keys())) == 2 + assert isinstance(m.data["hello"], SubModel1) + assert m.data["hello"].name == "there" + assert isinstance(m.data["foo"], SubModel2) + assert m.data["foo"].foo == "bar" + + # TODO: test mismatched type + + +def test_double_nested_union() -> None: + class SubModel1(BaseModel): + name: str + + class SubModel2(BaseModel): + bar: str + + class Model(BaseModel): + data: Dict[str, List[Union[SubModel1, SubModel2]]] + + m = Model.construct(data={"foo": [{"bar": "baz"}, {"name": "Robert"}]}) + assert len(m.data["foo"]) == 2 + + entry1 = m.data["foo"][0] + assert isinstance(entry1, SubModel2) + assert entry1.bar == "baz" + + entry2 = m.data["foo"][1] + assert isinstance(entry2, SubModel1) + assert entry2.name == "Robert" + + # TODO: test mismatched type + + +def test_union_of_dict() -> None: + class SubModel1(BaseModel): + name: str + + class SubModel2(BaseModel): + foo: str + + class Model(BaseModel): + data: Union[Dict[str, SubModel1], Dict[str, SubModel2]] + + m = Model.construct(data={"hello": {"name": "there"}, "foo": {"foo": "bar"}}) + assert len(list(m.data.keys())) == 2 + assert isinstance(m.data["hello"], SubModel1) + assert m.data["hello"].name == "there" + assert isinstance(m.data["foo"], SubModel1) + assert cast(Any, m.data["foo"]).foo == "bar" + + +def test_iso8601_datetime() -> None: + class Model(BaseModel): + created_at: datetime + + expected = datetime(2019, 12, 27, 18, 11, 19, 117000, tzinfo=timezone.utc) + + if PYDANTIC_V1: + expected_json = '{"created_at": "2019-12-27T18:11:19.117000+00:00"}' + else: + expected_json = '{"created_at":"2019-12-27T18:11:19.117000Z"}' + + model = Model.construct(created_at="2019-12-27T18:11:19.117Z") + assert model.created_at == expected + assert model_json(model) == expected_json + + model = parse_obj(Model, dict(created_at="2019-12-27T18:11:19.117Z")) + assert model.created_at == expected + assert model_json(model) == expected_json + + +def test_does_not_coerce_int() -> None: + class Model(BaseModel): + bar: int + + assert Model.construct(bar=1).bar == 1 + assert Model.construct(bar=10.9).bar == 10.9 + assert Model.construct(bar="19").bar == "19" # type: ignore[comparison-overlap] + assert Model.construct(bar=False).bar is False + + +def test_int_to_float_safe_conversion() -> None: + class Model(BaseModel): + float_field: float + + m = Model.construct(float_field=10) + assert m.float_field == 10.0 + assert isinstance(m.float_field, float) + + m = Model.construct(float_field=10.12) + assert m.float_field == 10.12 + assert isinstance(m.float_field, float) + + # number too big + m = Model.construct(float_field=2**53 + 1) + assert m.float_field == 2**53 + 1 + assert isinstance(m.float_field, int) + + +def test_deprecated_alias() -> None: + class Model(BaseModel): + resource_id: str = Field(alias="model_id") + + @property + def model_id(self) -> str: + return self.resource_id + + m = Model.construct(model_id="id") + assert m.model_id == "id" + assert m.resource_id == "id" + assert m.resource_id is m.model_id + + m = parse_obj(Model, {"model_id": "id"}) + assert m.model_id == "id" + assert m.resource_id == "id" + assert m.resource_id is m.model_id + + +def test_omitted_fields() -> None: + class Model(BaseModel): + resource_id: Optional[str] = None + + m = Model.construct() + assert m.resource_id is None + assert "resource_id" not in m.model_fields_set + + m = Model.construct(resource_id=None) + assert m.resource_id is None + assert "resource_id" in m.model_fields_set + + m = Model.construct(resource_id="foo") + assert m.resource_id == "foo" + assert "resource_id" in m.model_fields_set + + +def test_to_dict() -> None: + class Model(BaseModel): + foo: Optional[str] = Field(alias="FOO", default=None) + + m = Model(FOO="hello") + assert m.to_dict() == {"FOO": "hello"} + assert m.to_dict(use_api_names=False) == {"foo": "hello"} + + m2 = Model() + assert m2.to_dict() == {} + assert m2.to_dict(exclude_unset=False) == {"FOO": None} + assert m2.to_dict(exclude_unset=False, exclude_none=True) == {} + assert m2.to_dict(exclude_unset=False, exclude_defaults=True) == {} + + m3 = Model(FOO=None) + assert m3.to_dict() == {"FOO": None} + assert m3.to_dict(exclude_none=True) == {} + assert m3.to_dict(exclude_defaults=True) == {} + + class Model2(BaseModel): + created_at: datetime + + time_str = "2024-03-21T11:39:01.275859" + m4 = Model2.construct(created_at=time_str) + assert m4.to_dict(mode="python") == {"created_at": datetime.fromisoformat(time_str)} + assert m4.to_dict(mode="json") == {"created_at": time_str} + + if PYDANTIC_V1: + with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): + m.to_dict(warnings=False) + + +def test_forwards_compat_model_dump_method() -> None: + class Model(BaseModel): + foo: Optional[str] = Field(alias="FOO", default=None) + + m = Model(FOO="hello") + assert m.model_dump() == {"foo": "hello"} + assert m.model_dump(include={"bar"}) == {} + assert m.model_dump(exclude={"foo"}) == {} + assert m.model_dump(by_alias=True) == {"FOO": "hello"} + + m2 = Model() + assert m2.model_dump() == {"foo": None} + assert m2.model_dump(exclude_unset=True) == {} + assert m2.model_dump(exclude_none=True) == {} + assert m2.model_dump(exclude_defaults=True) == {} + + m3 = Model(FOO=None) + assert m3.model_dump() == {"foo": None} + assert m3.model_dump(exclude_none=True) == {} + + if PYDANTIC_V1: + with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): + m.model_dump(round_trip=True) + + with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): + m.model_dump(warnings=False) + + +def test_compat_method_no_error_for_warnings() -> None: + class Model(BaseModel): + foo: Optional[str] + + m = Model(foo="hello") + assert isinstance(model_dump(m, warnings=False), dict) + + +def test_to_json() -> None: + class Model(BaseModel): + foo: Optional[str] = Field(alias="FOO", default=None) + + m = Model(FOO="hello") + assert json.loads(m.to_json()) == {"FOO": "hello"} + assert json.loads(m.to_json(use_api_names=False)) == {"foo": "hello"} + + if PYDANTIC_V1: + assert m.to_json(indent=None) == '{"FOO": "hello"}' + else: + assert m.to_json(indent=None) == '{"FOO":"hello"}' + + m2 = Model() + assert json.loads(m2.to_json()) == {} + assert json.loads(m2.to_json(exclude_unset=False)) == {"FOO": None} + assert json.loads(m2.to_json(exclude_unset=False, exclude_none=True)) == {} + assert json.loads(m2.to_json(exclude_unset=False, exclude_defaults=True)) == {} + + m3 = Model(FOO=None) + assert json.loads(m3.to_json()) == {"FOO": None} + assert json.loads(m3.to_json(exclude_none=True)) == {} + + if PYDANTIC_V1: + with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): + m.to_json(warnings=False) + + +def test_forwards_compat_model_dump_json_method() -> None: + class Model(BaseModel): + foo: Optional[str] = Field(alias="FOO", default=None) + + m = Model(FOO="hello") + assert json.loads(m.model_dump_json()) == {"foo": "hello"} + assert json.loads(m.model_dump_json(include={"bar"})) == {} + assert json.loads(m.model_dump_json(include={"foo"})) == {"foo": "hello"} + assert json.loads(m.model_dump_json(by_alias=True)) == {"FOO": "hello"} + + assert m.model_dump_json(indent=2) == '{\n "foo": "hello"\n}' + + m2 = Model() + assert json.loads(m2.model_dump_json()) == {"foo": None} + assert json.loads(m2.model_dump_json(exclude_unset=True)) == {} + assert json.loads(m2.model_dump_json(exclude_none=True)) == {} + assert json.loads(m2.model_dump_json(exclude_defaults=True)) == {} + + m3 = Model(FOO=None) + assert json.loads(m3.model_dump_json()) == {"foo": None} + assert json.loads(m3.model_dump_json(exclude_none=True)) == {} + + if PYDANTIC_V1: + with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): + m.model_dump_json(round_trip=True) + + with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): + m.model_dump_json(warnings=False) + + +def test_type_compat() -> None: + # our model type can be assigned to Pydantic's model type + + def takes_pydantic(model: pydantic.BaseModel) -> None: # noqa: ARG001 + ... + + class OurModel(BaseModel): + foo: Optional[str] = None + + takes_pydantic(OurModel()) + + +def test_annotated_types() -> None: + class Model(BaseModel): + value: str + + m = construct_type( + value={"value": "foo"}, + type_=cast(Any, Annotated[Model, "random metadata"]), + ) + assert isinstance(m, Model) + assert m.value == "foo" + + +def test_discriminated_unions_invalid_data() -> None: + class A(BaseModel): + type: Literal["a"] + + data: str + + class B(BaseModel): + type: Literal["b"] + + data: int + + m = construct_type( + value={"type": "b", "data": "foo"}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, B) + assert m.type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + m = construct_type( + value={"type": "a", "data": 100}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, A) + assert m.type == "a" + if PYDANTIC_V1: + # pydantic v1 automatically converts inputs to strings + # if the expected type is a str + assert m.data == "100" + else: + assert m.data == 100 # type: ignore[comparison-overlap] + + +def test_discriminated_unions_unknown_variant() -> None: + class A(BaseModel): + type: Literal["a"] + + data: str + + class B(BaseModel): + type: Literal["b"] + + data: int + + m = construct_type( + value={"type": "c", "data": None, "new_thing": "bar"}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="type")]), + ) + + # just chooses the first variant + assert isinstance(m, A) + assert m.type == "c" # type: ignore[comparison-overlap] + assert m.data == None # type: ignore[unreachable] + assert m.new_thing == "bar" + + +def test_discriminated_unions_invalid_data_nested_unions() -> None: + class A(BaseModel): + type: Literal["a"] + + data: str + + class B(BaseModel): + type: Literal["b"] + + data: int + + class C(BaseModel): + type: Literal["c"] + + data: bool + + m = construct_type( + value={"type": "b", "data": "foo"}, + type_=cast(Any, Annotated[Union[Union[A, B], C], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, B) + assert m.type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + m = construct_type( + value={"type": "c", "data": "foo"}, + type_=cast(Any, Annotated[Union[Union[A, B], C], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, C) + assert m.type == "c" + assert m.data == "foo" # type: ignore[comparison-overlap] + + +def test_discriminated_unions_with_aliases_invalid_data() -> None: + class A(BaseModel): + foo_type: Literal["a"] = Field(alias="type") + + data: str + + class B(BaseModel): + foo_type: Literal["b"] = Field(alias="type") + + data: int + + m = construct_type( + value={"type": "b", "data": "foo"}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="foo_type")]), + ) + assert isinstance(m, B) + assert m.foo_type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + m = construct_type( + value={"type": "a", "data": 100}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="foo_type")]), + ) + assert isinstance(m, A) + assert m.foo_type == "a" + if PYDANTIC_V1: + # pydantic v1 automatically converts inputs to strings + # if the expected type is a str + assert m.data == "100" + else: + assert m.data == 100 # type: ignore[comparison-overlap] + + +def test_discriminated_unions_overlapping_discriminators_invalid_data() -> None: + class A(BaseModel): + type: Literal["a"] + + data: bool + + class B(BaseModel): + type: Literal["a"] + + data: int + + m = construct_type( + value={"type": "a", "data": "foo"}, + type_=cast(Any, Annotated[Union[A, B], PropertyInfo(discriminator="type")]), + ) + assert isinstance(m, B) + assert m.type == "a" + assert m.data == "foo" # type: ignore[comparison-overlap] + + +def test_discriminated_unions_invalid_data_uses_cache() -> None: + class A(BaseModel): + type: Literal["a"] + + data: str + + class B(BaseModel): + type: Literal["b"] + + data: int + + UnionType = cast(Any, Union[A, B]) + + assert not DISCRIMINATOR_CACHE.get(UnionType) + + m = construct_type( + value={"type": "b", "data": "foo"}, type_=cast(Any, Annotated[UnionType, PropertyInfo(discriminator="type")]) + ) + assert isinstance(m, B) + assert m.type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + discriminator = DISCRIMINATOR_CACHE.get(UnionType) + assert discriminator is not None + + m = construct_type( + value={"type": "b", "data": "foo"}, type_=cast(Any, Annotated[UnionType, PropertyInfo(discriminator="type")]) + ) + assert isinstance(m, B) + assert m.type == "b" + assert m.data == "foo" # type: ignore[comparison-overlap] + + # if the discriminator details object stays the same between invocations then + # we hit the cache + assert DISCRIMINATOR_CACHE.get(UnionType) is discriminator + + +@pytest.mark.skipif(PYDANTIC_V1, reason="TypeAliasType is not supported in Pydantic v1") +def test_type_alias_type() -> None: + Alias = TypeAliasType("Alias", str) # pyright: ignore + + class Model(BaseModel): + alias: Alias + union: Union[int, Alias] + + m = construct_type(value={"alias": "foo", "union": "bar"}, type_=Model) + assert isinstance(m, Model) + assert isinstance(m.alias, str) + assert m.alias == "foo" + assert isinstance(m.union, str) + assert m.union == "bar" + + +@pytest.mark.skipif(PYDANTIC_V1, reason="TypeAliasType is not supported in Pydantic v1") +def test_field_named_cls() -> None: + class Model(BaseModel): + cls: str + + m = construct_type(value={"cls": "foo"}, type_=Model) + assert isinstance(m, Model) + assert isinstance(m.cls, str) + + +def test_discriminated_union_case() -> None: + class A(BaseModel): + type: Literal["a"] + + data: bool + + class B(BaseModel): + type: Literal["b"] + + data: List[Union[A, object]] + + class ModelA(BaseModel): + type: Literal["modelA"] + + data: int + + class ModelB(BaseModel): + type: Literal["modelB"] + + required: str + + data: Union[A, B] + + # when constructing ModelA | ModelB, value data doesn't match ModelB exactly - missing `required` + m = construct_type( + value={"type": "modelB", "data": {"type": "a", "data": True}}, + type_=cast(Any, Annotated[Union[ModelA, ModelB], PropertyInfo(discriminator="type")]), + ) + + assert isinstance(m, ModelB) + + +def test_nested_discriminated_union() -> None: + class InnerType1(BaseModel): + type: Literal["type_1"] + + class InnerModel(BaseModel): + inner_value: str + + class InnerType2(BaseModel): + type: Literal["type_2"] + some_inner_model: InnerModel + + class Type1(BaseModel): + base_type: Literal["base_type_1"] + value: Annotated[ + Union[ + InnerType1, + InnerType2, + ], + PropertyInfo(discriminator="type"), + ] + + class Type2(BaseModel): + base_type: Literal["base_type_2"] + + T = Annotated[ + Union[ + Type1, + Type2, + ], + PropertyInfo(discriminator="base_type"), + ] + + model = construct_type( + type_=T, + value={ + "base_type": "base_type_1", + "value": { + "type": "type_2", + }, + }, + ) + assert isinstance(model, Type1) + assert isinstance(model.value, InnerType2) + + +@pytest.mark.skipif(PYDANTIC_V1, reason="this is only supported in pydantic v2 for now") +def test_extra_properties() -> None: + class Item(BaseModel): + prop: int + + class Model(BaseModel): + __pydantic_extra__: Dict[str, Item] = Field(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + + other: str + + if TYPE_CHECKING: + + def __getattr__(self, attr: str) -> Item: ... + + model = construct_type( + type_=Model, + value={ + "a": {"prop": 1}, + "other": "foo", + }, + ) + assert isinstance(model, Model) + assert model.a.prop == 1 + assert isinstance(model.a, Item) + assert model.other == "foo" + + +# NOTE: Workaround for Pydantic Iterable behavior. +# Iterable fields are replaced with a ValidatorIterator and may be consumed +# during serialization, which can cause subsequent dumps to return empty data. +# See: https://github.com/pydantic/pydantic/issues/9541 +@pytest.mark.parametrize( + "data, expected_validated", + [ + ([1, 2, 3], [1, 2, 3]), + ((1, 2, 3), (1, 2, 3)), + (set([1, 2, 3]), set([1, 2, 3])), + (iter([1, 2, 3]), [1, 2, 3]), + ([], []), + ((x for x in [1, 2, 3]), [1, 2, 3]), + (map(lambda x: x, [1, 2, 3]), [1, 2, 3]), + (frozenset([1, 2, 3]), frozenset([1, 2, 3])), + (deque([1, 2, 3]), deque([1, 2, 3])), + ], + ids=["list", "tuple", "set", "iterator", "empty", "generator", "map", "frozenset", "deque"], +) +@pytest.mark.skipif(PYDANTIC_V1, reason="this is only supported in pydantic v2") +def test_iterable_construction(data: Iterable[int], expected_validated: Iterable[int]) -> None: + class TypeWithIterable(TypedDict): + items: EagerIterable[int] + + class Model(BaseModel): + data: TypeWithIterable + + m = Model.model_validate({"data": {"items": data}}) + assert m.data["items"] == expected_validated + + # Verify repeated dumps don't lose data (the original bug) + assert m.model_dump()["data"]["items"] == list(expected_validated) + assert m.model_dump()["data"]["items"] == list(expected_validated) + + +@pytest.mark.skipif(PYDANTIC_V1, reason="this is only supported in pydantic v2") +def test_iterable_construction_str_falls_back_to_list() -> None: + # str is iterable (over chars), but str(list_of_chars) produces the list's repr + # rather than reconstructing a string from items. We special-case str to fall + # back to list instead of attempting reconstruction. + class TypeWithIterable(TypedDict): + items: EagerIterable[str] + + class Model(BaseModel): + data: TypeWithIterable + + m = Model.model_validate({"data": {"items": "hello"}}) + + # falls back to list of chars rather than calling str(["h", "e", "l", "l", "o"]) + assert m.data["items"] == ["h", "e", "l", "l", "o"] + assert m.model_dump()["data"]["items"] == ["h", "e", "l", "l", "o"] diff --git a/tests/test_obs_handle_registry.py b/tests/test_obs_handle_registry.py new file mode 100644 index 000000000..02d3adf0a --- /dev/null +++ b/tests/test_obs_handle_registry.py @@ -0,0 +1,126 @@ +"""Tests for the obs-handle registry: leak safety + app-path safety. + +Two guarantees are pinned here: + + 1. A tracing processor whose ``on_span_start`` / ``on_span_end`` raises must + NOT crash the app path (``start_span`` / ``end_span`` still return). Because + start_span returns normally, the standard end_span path still pops+closes + the obs handle -- so the registration-order leak Greptile flagged cannot + happen. + 2. ``_OBS_HANDLES`` is bounded: a caller that starts spans without ending them + (public, unpaired ``start_span`` / ``end_span`` API) degrades gracefully -- + the oldest handle is evicted AND closed rather than growing unbounded. +""" + +from __future__ import annotations + +from typing import Any, cast + +import pytest +from opentelemetry import trace as otel_trace +from opentelemetry.trace import ( + TraceFlags, + SpanContext, + NonRecordingSpan, +) + +import agentex.lib.core.tracing.trace as trace_mod +from agentex.types.span import Span +from agentex.lib.core.tracing.trace import _OBS_HANDLES, _OBS_HANDLES_MAX, Trace +from agentex.lib.core.tracing.obs_span import ObsSpanHandle + + +@pytest.fixture(autouse=True) +def _clear_registry() -> Any: + """The registry is module-level global; keep tests isolated.""" + _OBS_HANDLES.clear() + yield + _OBS_HANDLES.clear() + + +def _valid_wrapper_span() -> NonRecordingSpan: + ctx = SpanContext( + trace_id=0x0123456789ABCDEF0123456789ABCDEF, + span_id=0x0123456789ABCDEF, + is_remote=False, + trace_flags=TraceFlags(TraceFlags.SAMPLED), + ) + return NonRecordingSpan(ctx) + + +class _RaisingProcessor: + """A processor whose lifecycle hooks blow up -- an obs bug must not crash the app.""" + + def __init__(self) -> None: + self.started = 0 + self.ended = 0 + + def on_span_start(self, span: Span) -> None: + self.started += 1 + raise RuntimeError("processor on_span_start is broken") + + def on_span_end(self, span: Span) -> None: + self.ended += 1 + raise RuntimeError("processor on_span_end is broken") + + +def _trace_with(processors: list[Any]) -> Trace: + return Trace(processors=processors, client=cast(Any, object()), trace_id="trace-1") + + +def test_start_span_survives_raising_processor_and_no_leak(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SGP_OBS_MODE", "lgtm") + # Wrapper opens with a valid context -> a real handle is registered. + monkeypatch.setattr( + otel_trace, + "get_tracer", + lambda *a, **k: type("T", (), {"start_span": staticmethod(lambda *a, **k: _valid_wrapper_span())})(), + ) + + proc = _RaisingProcessor() + trace_obj = _trace_with([proc]) + + # A processor exploding in on_span_start must NOT propagate. + span = trace_obj.start_span(name="step") + assert proc.started == 1 + # The handle was registered despite the processor blowing up afterwards. + assert span.id in _OBS_HANDLES + + # end_span also survives a raising on_span_end AND pops/closes the handle, + # so nothing leaks. + trace_obj.end_span(span) + assert proc.ended == 1 + assert span.id not in _OBS_HANDLES + + +def test_registry_is_bounded_and_evicts_and_closes_oldest() -> None: + closed: list[str] = [] + + def _make_handle(marker: str) -> ObsSpanHandle: + return ObsSpanHandle(correlation={}, close=lambda _err=None, _m=marker: closed.append(_m)) + + # Fill exactly to the cap: nothing evicted yet. + for i in range(_OBS_HANDLES_MAX): + trace_mod._register_obs_handle(f"span-{i}", _make_handle(f"span-{i}")) + assert len(_OBS_HANDLES) == _OBS_HANDLES_MAX + assert closed == [] + + # One over the cap: the OLDEST (span-0) is evicted AND closed. + trace_mod._register_obs_handle("span-overflow", _make_handle("span-overflow")) + assert len(_OBS_HANDLES) == _OBS_HANDLES_MAX + assert "span-0" not in _OBS_HANDLES + assert "span-overflow" in _OBS_HANDLES + assert closed == ["span-0"] # evicted handle was closed, not just dropped + + +def test_reinserting_same_span_id_refreshes_recency() -> None: + def _noop_handle() -> ObsSpanHandle: + return ObsSpanHandle(correlation={}, close=lambda _err=None: None) + + trace_mod._register_obs_handle("a", _noop_handle()) + trace_mod._register_obs_handle("b", _noop_handle()) + # Touch "a" again -> it becomes the most-recent, so "b" is now the oldest. + trace_mod._register_obs_handle("a", _noop_handle()) + + oldest_key = next(iter(_OBS_HANDLES)) + assert oldest_key == "b" diff --git a/tests/test_obs_span_fallback.py b/tests/test_obs_span_fallback.py new file mode 100644 index 000000000..c92a42e34 --- /dev/null +++ b/tests/test_obs_span_fallback.py @@ -0,0 +1,116 @@ +"""Tests for the obs-wrapper -> ambient-correlation fallback. + +Regression coverage for: in ``lgtm`` mode with no OTel TracerProvider installed +(the documented current state of agents), ``open_obs_span`` used to return a +handle carrying an *empty* correlation. At the call site (``trace.py``) that +handle is not None, so the ambient ``obs_correlation()`` fallback was never +consulted and the business span ended up with **no** ``obs_*`` ids at all -- +strictly worse than falling back. + +The fix: ``open_obs_span`` bails out to ``None`` when the wrapper span's context +is invalid (proxy ``NonRecordingSpan``), so the caller falls back to the ambient +obs ids. These tests pin: + + - invalid wrapper context -> ``open_obs_span`` returns ``None`` and restores + the active context (no leaked attach), + - valid wrapper context -> a handle with real 32/16-hex correlation, + - end-to-end: with an invalid wrapper but a valid *ambient* span active, + ``Trace.start_span`` stamps the ambient ``obs_trace_id`` / ``obs_span_id`` + onto the business span (the fallback fires). +""" + +from __future__ import annotations + +from typing import Any, cast + +import pytest +from opentelemetry import trace as otel_trace, context as otel_context +from opentelemetry.trace import ( + INVALID_SPAN_CONTEXT, + TraceFlags, + SpanContext, + NonRecordingSpan, + set_span_in_context, +) + +from agentex.lib.core.tracing.trace import Trace +from agentex.lib.core.tracing.obs_span import open_obs_span, close_obs_span + +# Deterministic, valid ids for the "provider present" / ambient-span cases. +_TRACE_ID = 0x0123456789ABCDEF0123456789ABCDEF +_SPAN_ID = 0x0123456789ABCDEF +_TRACE_HEX = format(_TRACE_ID, "032x") +_SPAN_HEX = format(_SPAN_ID, "016x") + + +def _valid_span() -> NonRecordingSpan: + ctx = SpanContext( + trace_id=_TRACE_ID, + span_id=_SPAN_ID, + is_remote=False, + trace_flags=TraceFlags(TraceFlags.SAMPLED), + ) + return NonRecordingSpan(ctx) + + +class _FakeTracer: + """A tracer whose start_span returns a fixed span (bypasses any real provider).""" + + def __init__(self, span: NonRecordingSpan): + self._span = span + + def start_span(self, name: str, *args: object, **kwargs: object) -> NonRecordingSpan: + return self._span + + +def _patch_wrapper_tracer(monkeypatch: pytest.MonkeyPatch, span: NonRecordingSpan) -> None: + """Force the obs wrapper's ``trace.get_tracer(...).start_span`` to yield ``span``. + + Only affects the wrapper opened inside open_obs_span; obs_correlation reads + the *current* span via ``trace.get_current_span()`` and is untouched. + """ + monkeypatch.setattr(otel_trace, "get_tracer", lambda *a, **k: _FakeTracer(span)) + + +def test_open_obs_span_returns_none_on_invalid_context(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SGP_OBS_MODE", "lgtm") + _patch_wrapper_tracer(monkeypatch, NonRecordingSpan(INVALID_SPAN_CONTEXT)) + + before = otel_trace.get_current_span() + handle = open_obs_span("step", business_span_id="bs", business_trace_id="bt") + + # No handle -> caller falls back to obs_correlation() instead of an empty {}. + assert handle is None + # The context attach inside open_obs_span was detached: no leak. + assert otel_trace.get_current_span() is before + + +def test_open_obs_span_returns_handle_on_valid_context(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SGP_OBS_MODE", "lgtm") + _patch_wrapper_tracer(monkeypatch, _valid_span()) + + handle = open_obs_span("step", business_span_id="bs", business_trace_id="bt") + + assert handle is not None + assert handle.correlation == {"obs_trace_id": _TRACE_HEX, "obs_span_id": _SPAN_HEX} + close_obs_span(handle) + + +def test_start_span_falls_back_to_ambient_when_wrapper_invalid(monkeypatch: pytest.MonkeyPatch) -> None: + """End-to-end: invalid wrapper -> ambient obs ids land on the business span.""" + monkeypatch.setenv("SGP_OBS_MODE", "lgtm") + # Wrapper span has an invalid context (no real provider) -> open_obs_span None. + _patch_wrapper_tracer(monkeypatch, NonRecordingSpan(INVALID_SPAN_CONTEXT)) + + # But a VALID ambient span is active (e.g. the ACP ingress / interceptor span). + token = otel_context.attach(set_span_in_context(_valid_span())) + try: + trace_obj = Trace(processors=[], client=cast(Any, object()), trace_id="trace-1") + span = trace_obj.start_span(name="step") + finally: + otel_context.detach(token) + + # obs_correlation() was consulted and stamped the ambient ids onto data. + assert isinstance(span.data, dict) + assert span.data["obs_trace_id"] == _TRACE_HEX + assert span.data["obs_span_id"] == _SPAN_HEX diff --git a/tests/test_protocol_shims.py b/tests/test_protocol_shims.py new file mode 100644 index 000000000..e5e651b68 --- /dev/null +++ b/tests/test_protocol_shims.py @@ -0,0 +1,98 @@ +"""Tests that pin the back-compat contract for protocol-type shims. + +The canonical location for wire-protocol shapes is :mod:`agentex.protocol` +(see PR scaleapi/scale-agentex-python#371). The historical locations +:mod:`agentex.lib.types.acp` and :mod:`agentex.lib.types.json_rpc` are +preserved as re-export shims so external consumers' existing imports +continue to work. + +These tests enforce two invariants: + +1. **Symbol parity** — every public name the original modules exported + is still importable from the old path. Greptile flagged + ``RPC_SYNC_METHODS`` and ``PARAMS_MODEL_BY_METHOD`` as missing in an + earlier pass; this test prevents that regression. +2. **Identity** — the class objects at the shim path are the *same* + objects as the canonical path. Without this, type-narrowing via + ``isinstance`` or pattern matching would silently misbehave for code + that mixes import styles. + +Also asserts the :class:`pydantic.ConfigDict` settings on the JSON-RPC +classes survived the move from :mod:`agentex.lib.utils.model_utils` to +plain :mod:`pydantic` — Greptile flagged the silent loss of +``from_attributes=True`` / ``populate_by_name=True``. +""" + +from __future__ import annotations + + +def test_acp_shim_re_exports_all_original_symbols() -> None: + """Every name historically exported from agentex.lib.types.acp must + still be importable from that path via the back-compat shim.""" + # Importing each symbol; ImportError here means the shim regressed. + from agentex.lib.types.acp import ( # noqa: F401 + RPC_SYNC_METHODS, + PARAMS_MODEL_BY_METHOD, + RPCMethod, + SendEventParams, + CancelTaskParams, + CreateTaskParams, + SendMessageParams, + ) + + +def test_json_rpc_shim_re_exports_all_original_symbols() -> None: + """Every name historically exported from agentex.lib.types.json_rpc + must still be importable from that path via the back-compat shim.""" + from agentex.lib.types.json_rpc import ( # noqa: F401 + JSONRPCError, + JSONRPCRequest, + JSONRPCResponse, + ) + + +def test_acp_shim_classes_are_identical_to_canonical() -> None: + """Shim re-exports must be the *same* class objects as the canonical + path. Different objects would break ``isinstance`` for code that + mixes import styles.""" + from agentex.protocol import acp as canon + from agentex.lib.types import acp as shim + + assert shim.RPCMethod is canon.RPCMethod + assert shim.CreateTaskParams is canon.CreateTaskParams + assert shim.SendMessageParams is canon.SendMessageParams + assert shim.SendEventParams is canon.SendEventParams + assert shim.CancelTaskParams is canon.CancelTaskParams + assert shim.RPC_SYNC_METHODS is canon.RPC_SYNC_METHODS + assert shim.PARAMS_MODEL_BY_METHOD is canon.PARAMS_MODEL_BY_METHOD + + +def test_json_rpc_shim_classes_are_identical_to_canonical() -> None: + """Same identity check for the JSON-RPC envelope types.""" + from agentex.protocol import json_rpc as canon + from agentex.lib.types import json_rpc as shim + + assert shim.JSONRPCError is canon.JSONRPCError + assert shim.JSONRPCRequest is canon.JSONRPCRequest + assert shim.JSONRPCResponse is canon.JSONRPCResponse + + +def test_json_rpc_classes_preserve_legacy_model_config() -> None: + """Pre-refactor, JSON-RPC classes inherited + ``from_attributes=True`` / ``populate_by_name=True`` from + ``agentex.lib.utils.model_utils.BaseModel``. The refactor swapped + to plain ``pydantic.BaseModel`` and set ``model_config`` explicitly + to preserve both flags. Catch any future drop.""" + from agentex.protocol.json_rpc import ( + JSONRPCError, + JSONRPCRequest, + JSONRPCResponse, + ) + + for cls in (JSONRPCError, JSONRPCRequest, JSONRPCResponse): + assert cls.model_config.get("from_attributes") is True, ( + f"{cls.__name__}.model_config dropped from_attributes=True" + ) + assert cls.model_config.get("populate_by_name") is True, ( + f"{cls.__name__}.model_config dropped populate_by_name=True" + ) diff --git a/tests/test_qs.py b/tests/test_qs.py new file mode 100644 index 000000000..a938eb261 --- /dev/null +++ b/tests/test_qs.py @@ -0,0 +1,78 @@ +from typing import Any, cast +from functools import partial +from urllib.parse import unquote + +import pytest + +from agentex._qs import Querystring, stringify + + +def test_empty() -> None: + assert stringify({}) == "" + assert stringify({"a": {}}) == "" + assert stringify({"a": {"b": {"c": {}}}}) == "" + + +def test_basic() -> None: + assert stringify({"a": 1}) == "a=1" + assert stringify({"a": "b"}) == "a=b" + assert stringify({"a": True}) == "a=true" + assert stringify({"a": False}) == "a=false" + assert stringify({"a": 1.23456}) == "a=1.23456" + assert stringify({"a": None}) == "" + + +@pytest.mark.parametrize("method", ["class", "function"]) +def test_nested_dotted(method: str) -> None: + if method == "class": + serialise = Querystring(nested_format="dots").stringify + else: + serialise = partial(stringify, nested_format="dots") + + assert unquote(serialise({"a": {"b": "c"}})) == "a.b=c" + assert unquote(serialise({"a": {"b": "c", "d": "e", "f": "g"}})) == "a.b=c&a.d=e&a.f=g" + assert unquote(serialise({"a": {"b": {"c": {"d": "e"}}}})) == "a.b.c.d=e" + assert unquote(serialise({"a": {"b": True}})) == "a.b=true" + + +def test_nested_brackets() -> None: + assert unquote(stringify({"a": {"b": "c"}})) == "a[b]=c" + assert unquote(stringify({"a": {"b": "c", "d": "e", "f": "g"}})) == "a[b]=c&a[d]=e&a[f]=g" + assert unquote(stringify({"a": {"b": {"c": {"d": "e"}}}})) == "a[b][c][d]=e" + assert unquote(stringify({"a": {"b": True}})) == "a[b]=true" + + +@pytest.mark.parametrize("method", ["class", "function"]) +def test_array_comma(method: str) -> None: + if method == "class": + serialise = Querystring(array_format="comma").stringify + else: + serialise = partial(stringify, array_format="comma") + + assert unquote(serialise({"in": ["foo", "bar"]})) == "in=foo,bar" + assert unquote(serialise({"a": {"b": [True, False]}})) == "a[b]=true,false" + assert unquote(serialise({"a": {"b": [True, False, None, True]}})) == "a[b]=true,false,true" + + +def test_array_repeat() -> None: + assert unquote(stringify({"in": ["foo", "bar"]})) == "in=foo&in=bar" + assert unquote(stringify({"a": {"b": [True, False]}})) == "a[b]=true&a[b]=false" + assert unquote(stringify({"a": {"b": [True, False, None, True]}})) == "a[b]=true&a[b]=false&a[b]=true" + assert unquote(stringify({"in": ["foo", {"b": {"c": ["d", "e"]}}]})) == "in=foo&in[b][c]=d&in[b][c]=e" + + +@pytest.mark.parametrize("method", ["class", "function"]) +def test_array_brackets(method: str) -> None: + if method == "class": + serialise = Querystring(array_format="brackets").stringify + else: + serialise = partial(stringify, array_format="brackets") + + assert unquote(serialise({"in": ["foo", "bar"]})) == "in[]=foo&in[]=bar" + assert unquote(serialise({"a": {"b": [True, False]}})) == "a[b][]=true&a[b][]=false" + assert unquote(serialise({"a": {"b": [True, False, None, True]}})) == "a[b][]=true&a[b][]=false&a[b][]=true" + + +def test_unknown_array_format() -> None: + with pytest.raises(NotImplementedError, match="Unknown array_format value: foo, choose from comma, repeat"): + stringify({"a": ["foo", "bar"]}, array_format=cast(Any, "foo")) diff --git a/tests/test_required_args.py b/tests/test_required_args.py new file mode 100644 index 000000000..f507b1c04 --- /dev/null +++ b/tests/test_required_args.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import pytest + +from agentex._utils import required_args + + +def test_too_many_positional_params() -> None: + @required_args(["a"]) + def foo(a: str | None = None) -> str | None: + return a + + with pytest.raises(TypeError, match=r"foo\(\) takes 1 argument\(s\) but 2 were given"): + foo("a", "b") # type: ignore + + +def test_positional_param() -> None: + @required_args(["a"]) + def foo(a: str | None = None) -> str | None: + return a + + assert foo("a") == "a" + assert foo(None) is None + assert foo(a="b") == "b" + + with pytest.raises(TypeError, match="Missing required argument: 'a'"): + foo() + + +def test_keyword_only_param() -> None: + @required_args(["a"]) + def foo(*, a: str | None = None) -> str | None: + return a + + assert foo(a="a") == "a" + assert foo(a=None) is None + assert foo(a="b") == "b" + + with pytest.raises(TypeError, match="Missing required argument: 'a'"): + foo() + + +def test_multiple_params() -> None: + @required_args(["a", "b", "c"]) + def foo(a: str = "", *, b: str = "", c: str = "") -> str | None: + return f"{a} {b} {c}" + + assert foo(a="a", b="b", c="c") == "a b c" + + error_message = r"Missing required arguments.*" + + with pytest.raises(TypeError, match=error_message): + foo() + + with pytest.raises(TypeError, match=error_message): + foo(a="a") + + with pytest.raises(TypeError, match=error_message): + foo(b="b") + + with pytest.raises(TypeError, match=error_message): + foo(c="c") + + with pytest.raises(TypeError, match=r"Missing required argument: 'a'"): + foo(b="a", c="c") + + with pytest.raises(TypeError, match=r"Missing required argument: 'b'"): + foo("a", c="c") + + +def test_multiple_variants() -> None: + @required_args(["a"], ["b"]) + def foo(*, a: str | None = None, b: str | None = None) -> str | None: + return a if a is not None else b + + assert foo(a="foo") == "foo" + assert foo(b="bar") == "bar" + assert foo(a=None) is None + assert foo(b=None) is None + + # TODO: this error message could probably be improved + with pytest.raises( + TypeError, + match=r"Missing required arguments; Expected either \('a'\) or \('b'\) arguments to be given", + ): + foo() + + +def test_multiple_params_multiple_variants() -> None: + @required_args(["a", "b"], ["c"]) + def foo(*, a: str | None = None, b: str | None = None, c: str | None = None) -> str | None: + if a is not None: + return a + if b is not None: + return b + return c + + error_message = r"Missing required arguments; Expected either \('a' and 'b'\) or \('c'\) arguments to be given" + + with pytest.raises(TypeError, match=error_message): + foo(a="foo") + + with pytest.raises(TypeError, match=error_message): + foo(b="bar") + + with pytest.raises(TypeError, match=error_message): + foo() + + assert foo(a=None, b="bar") == "bar" + assert foo(c=None) is None + assert foo(c="foo") == "foo" diff --git a/tests/test_response.py b/tests/test_response.py new file mode 100644 index 000000000..ed94eb680 --- /dev/null +++ b/tests/test_response.py @@ -0,0 +1,277 @@ +import json +from typing import Any, List, Union, cast +from typing_extensions import Annotated + +import httpx +import pytest +import pydantic + +from agentex import Agentex, BaseModel, AsyncAgentex +from agentex._response import ( + APIResponse, + BaseAPIResponse, + AsyncAPIResponse, + BinaryAPIResponse, + AsyncBinaryAPIResponse, + extract_response_type, +) +from agentex._streaming import Stream +from agentex._base_client import FinalRequestOptions + + +class ConcreteBaseAPIResponse(APIResponse[bytes]): ... + + +class ConcreteAPIResponse(APIResponse[List[str]]): ... + + +class ConcreteAsyncAPIResponse(APIResponse[httpx.Response]): ... + + +def test_extract_response_type_direct_classes() -> None: + assert extract_response_type(BaseAPIResponse[str]) == str + assert extract_response_type(APIResponse[str]) == str + assert extract_response_type(AsyncAPIResponse[str]) == str + + +def test_extract_response_type_direct_class_missing_type_arg() -> None: + with pytest.raises( + RuntimeError, + match="Expected type to have a type argument at index 0 but it did not", + ): + extract_response_type(AsyncAPIResponse) + + +def test_extract_response_type_concrete_subclasses() -> None: + assert extract_response_type(ConcreteBaseAPIResponse) == bytes + assert extract_response_type(ConcreteAPIResponse) == List[str] + assert extract_response_type(ConcreteAsyncAPIResponse) == httpx.Response + + +def test_extract_response_type_binary_response() -> None: + assert extract_response_type(BinaryAPIResponse) == bytes + assert extract_response_type(AsyncBinaryAPIResponse) == bytes + + +class PydanticModel(pydantic.BaseModel): ... + + +def test_response_parse_mismatched_basemodel(client: Agentex) -> None: + response = APIResponse( + raw=httpx.Response(200, content=b"foo"), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + with pytest.raises( + TypeError, + match="Pydantic models must subclass our base model type, e.g. `from agentex import BaseModel`", + ): + response.parse(to=PydanticModel) + + +@pytest.mark.asyncio +async def test_async_response_parse_mismatched_basemodel(async_client: AsyncAgentex) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=b"foo"), + client=async_client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + with pytest.raises( + TypeError, + match="Pydantic models must subclass our base model type, e.g. `from agentex import BaseModel`", + ): + await response.parse(to=PydanticModel) + + +def test_response_parse_custom_stream(client: Agentex) -> None: + response = APIResponse( + raw=httpx.Response(200, content=b"foo"), + client=client, + stream=True, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + stream = response.parse(to=Stream[int]) + assert stream._cast_to == int + + +@pytest.mark.asyncio +async def test_async_response_parse_custom_stream(async_client: AsyncAgentex) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=b"foo"), + client=async_client, + stream=True, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + stream = await response.parse(to=Stream[int]) + assert stream._cast_to == int + + +class CustomModel(BaseModel): + foo: str + bar: int + + +def test_response_parse_custom_model(client: Agentex) -> None: + response = APIResponse( + raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = response.parse(to=CustomModel) + assert obj.foo == "hello!" + assert obj.bar == 2 + + +@pytest.mark.asyncio +async def test_async_response_parse_custom_model(async_client: AsyncAgentex) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), + client=async_client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = await response.parse(to=CustomModel) + assert obj.foo == "hello!" + assert obj.bar == 2 + + +def test_response_parse_annotated_type(client: Agentex) -> None: + response = APIResponse( + raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = response.parse( + to=cast("type[CustomModel]", Annotated[CustomModel, "random metadata"]), + ) + assert obj.foo == "hello!" + assert obj.bar == 2 + + +async def test_async_response_parse_annotated_type(async_client: AsyncAgentex) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})), + client=async_client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = await response.parse( + to=cast("type[CustomModel]", Annotated[CustomModel, "random metadata"]), + ) + assert obj.foo == "hello!" + assert obj.bar == 2 + + +@pytest.mark.parametrize( + "content, expected", + [ + ("false", False), + ("true", True), + ("False", False), + ("True", True), + ("TrUe", True), + ("FalSe", False), + ], +) +def test_response_parse_bool(client: Agentex, content: str, expected: bool) -> None: + response = APIResponse( + raw=httpx.Response(200, content=content), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + result = response.parse(to=bool) + assert result is expected + + +@pytest.mark.parametrize( + "content, expected", + [ + ("false", False), + ("true", True), + ("False", False), + ("True", True), + ("TrUe", True), + ("FalSe", False), + ], +) +async def test_async_response_parse_bool(client: AsyncAgentex, content: str, expected: bool) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=content), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + result = await response.parse(to=bool) + assert result is expected + + +class OtherModel(BaseModel): + a: str + + +@pytest.mark.parametrize("client", [False], indirect=True) # loose validation +def test_response_parse_expect_model_union_non_json_content(client: Agentex) -> None: + response = APIResponse( + raw=httpx.Response(200, content=b"foo", headers={"Content-Type": "application/text"}), + client=client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = response.parse(to=cast(Any, Union[CustomModel, OtherModel])) + assert isinstance(obj, str) + assert obj == "foo" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("async_client", [False], indirect=True) # loose validation +async def test_async_response_parse_expect_model_union_non_json_content(async_client: AsyncAgentex) -> None: + response = AsyncAPIResponse( + raw=httpx.Response(200, content=b"foo", headers={"Content-Type": "application/text"}), + client=async_client, + stream=False, + stream_cls=None, + cast_to=str, + options=FinalRequestOptions.construct(method="get", url="/foo"), + ) + + obj = await response.parse(to=cast(Any, Union[CustomModel, OtherModel])) + assert isinstance(obj, str) + assert obj == "foo" diff --git a/tests/test_streaming.py b/tests/test_streaming.py new file mode 100644 index 000000000..4b6a392d0 --- /dev/null +++ b/tests/test_streaming.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +from typing import Iterator, AsyncIterator + +import httpx +import pytest + +from agentex import Agentex, AsyncAgentex +from agentex._streaming import Stream, AsyncStream, ServerSentEvent + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_basic(sync: bool, client: Agentex, async_client: AsyncAgentex) -> None: + def body() -> Iterator[bytes]: + yield b"event: completion\n" + yield b'data: {"foo":true}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "completion" + assert sse.json() == {"foo": True} + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_data_missing_event(sync: bool, client: Agentex, async_client: AsyncAgentex) -> None: + def body() -> Iterator[bytes]: + yield b'data: {"foo":true}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"foo": True} + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_event_missing_data(sync: bool, client: Agentex, async_client: AsyncAgentex) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.data == "" + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multiple_events(sync: bool, client: Agentex, async_client: AsyncAgentex) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b"\n" + yield b"event: completion\n" + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.data == "" + + sse = await iter_next(iterator) + assert sse.event == "completion" + assert sse.data == "" + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multiple_events_with_data(sync: bool, client: Agentex, async_client: AsyncAgentex) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b'data: {"foo":true}\n' + yield b"\n" + yield b"event: completion\n" + yield b'data: {"bar":false}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.json() == {"foo": True} + + sse = await iter_next(iterator) + assert sse.event == "completion" + assert sse.json() == {"bar": False} + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multiple_data_lines_with_empty_line(sync: bool, client: Agentex, async_client: AsyncAgentex) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b"data: {\n" + yield b'data: "foo":\n' + yield b"data: \n" + yield b"data:\n" + yield b"data: true}\n" + yield b"\n\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.json() == {"foo": True} + assert sse.data == '{\n"foo":\n\n\ntrue}' + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_data_json_escaped_double_new_line(sync: bool, client: Agentex, async_client: AsyncAgentex) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b'data: {"foo": "my long\\n\\ncontent"}' + yield b"\n\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.json() == {"foo": "my long\n\ncontent"} + + await assert_empty_iter(iterator) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multiple_data_lines(sync: bool, client: Agentex, async_client: AsyncAgentex) -> None: + def body() -> Iterator[bytes]: + yield b"event: ping\n" + yield b"data: {\n" + yield b'data: "foo":\n' + yield b"data: true}\n" + yield b"\n\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event == "ping" + assert sse.json() == {"foo": True} + + await assert_empty_iter(iterator) + + +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_special_new_line_character( + sync: bool, + client: Agentex, + async_client: AsyncAgentex, +) -> None: + def body() -> Iterator[bytes]: + yield b'data: {"content":" culpa"}\n' + yield b"\n" + yield b'data: {"content":" \xe2\x80\xa8"}\n' + yield b"\n" + yield b'data: {"content":"foo"}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"content": " culpa"} + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"content": " 
"} + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"content": "foo"} + + await assert_empty_iter(iterator) + + +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_multi_byte_character_multiple_chunks( + sync: bool, + client: Agentex, + async_client: AsyncAgentex, +) -> None: + def body() -> Iterator[bytes]: + yield b'data: {"content":"' + # bytes taken from the string 'известни' and arbitrarily split + # so that some multi-byte characters span multiple chunks + yield b"\xd0" + yield b"\xb8\xd0\xb7\xd0" + yield b"\xb2\xd0\xb5\xd1\x81\xd1\x82\xd0\xbd\xd0\xb8" + yield b'"}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.event is None + assert sse.json() == {"content": "известни"} + + +async def to_aiter(iter: Iterator[bytes]) -> AsyncIterator[bytes]: + for chunk in iter: + yield chunk + + +async def iter_next(iter: Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]) -> ServerSentEvent: + if isinstance(iter, AsyncIterator): + return await iter.__anext__() + + return next(iter) + + +async def assert_empty_iter(iter: Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]) -> None: + with pytest.raises((StopAsyncIteration, RuntimeError)): + await iter_next(iter) + + +def make_event_iterator( + content: Iterator[bytes], + *, + sync: bool, + client: Agentex, + async_client: AsyncAgentex, +) -> Iterator[ServerSentEvent] | AsyncIterator[ServerSentEvent]: + if sync: + return Stream(cast_to=object, client=client, response=httpx.Response(200, content=content))._iter_events() + + return AsyncStream( + cast_to=object, client=async_client, response=httpx.Response(200, content=to_aiter(content)) + )._iter_events() diff --git a/tests/test_task_cancel.py b/tests/test_task_cancel.py new file mode 100644 index 000000000..aaa2c44f2 --- /dev/null +++ b/tests/test_task_cancel.py @@ -0,0 +1,41 @@ +"""Tests for task cancellation bug fix.""" + +import os + +import pytest + +from agentex import AsyncAgentex +from agentex.types import Task + +from .utils import assert_matches_type + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestTaskCancelBugFix: + """Test that task cancellation bug is fixed - agent identification is required.""" + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Integration test - demonstrates the fix for task cancel bug") + @parametrize + async def test_task_cancel_requires_agent_and_task_identification(self, client: AsyncAgentex) -> None: + """ + Test that demonstrates the task cancellation bug fix. + + Previously: task_cancel(task_name="my-task") incorrectly treated task_name as agent_name + Fixed: task_cancel(task_name="my-task", agent_name="my-agent") correctly identifies both + """ + # This test documents the correct usage pattern + # In practice, you would need a real agent and task for this to work + try: + task = await client.agents.cancel_task( + agent_name="test-agent", # REQUIRED: Agent that owns the task + params={ + "task_id": "test-task-123" # REQUIRED: Task to cancel + } + ) + assert_matches_type(Task, task, path=["response"]) + except Exception: + # Expected to fail in test environment without real agents/tasks + # The important thing is that the API now requires both parameters + pass diff --git a/tests/test_temporal_obs_backend.py b/tests/test_temporal_obs_backend.py new file mode 100644 index 000000000..f69fde451 --- /dev/null +++ b/tests/test_temporal_obs_backend.py @@ -0,0 +1,270 @@ +"""Tests for the Temporal-path obs backend selection. + +Inside a Temporal activity the ambient span is temporalio's OpenTelemetry +``TracingInterceptor`` span -- always OTel, regardless of ``SGP_OBS_MODE``. The +reverse tag (``tag_ambient_obs_span``) and the forward correlation read +(``obs_correlation``) must therefore target OTel there, even in the default +``dd_only`` mode. Before the fix they branched on ``SGP_OBS_MODE`` and, in +``dd_only``, tagged/read an unrelated ddtrace span -- so the business<->obs +correlation on the async/Temporal path pointed at the wrong trace (or nowhere). +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, cast + +import pytest +from temporalio import activity as temporal_activity +from opentelemetry import trace as otel_trace +from opentelemetry.trace import TraceFlags, SpanContext + +import agentex.lib.core.tracing.trace as trace_mod +import agentex.lib.core.tracing.obs_ids as obs_ids_mod +from agentex.lib.core.tracing.trace import _OBS_HANDLES, Trace +from agentex.lib.core.tracing.obs_ids import obs_correlation +from agentex.lib.core.tracing.obs_span import tag_ambient_obs_span +from agentex.lib.core.temporal.activities.adk.tracing_activities import TracingActivityName + +_TRACE_ID = 0x0123456789ABCDEF0123456789ABCDEF +_SPAN_ID = 0x0123456789ABCDEF +_TRACE_HEX = format(_TRACE_ID, "032x") +_SPAN_HEX = format(_SPAN_ID, "016x") + + +def _valid_ctx() -> SpanContext: + return SpanContext( + trace_id=_TRACE_ID, + span_id=_SPAN_ID, + is_remote=True, # like a Temporal-propagated remote parent + trace_flags=TraceFlags(TraceFlags.SAMPLED), + ) + + +class _RecordingOtelSpan: + """A stand-in for the interceptor's activity span that records set_attribute.""" + + def __init__(self, ctx: SpanContext) -> None: + self._ctx = ctx + self.attributes: dict[str, Any] = {} + + def get_span_context(self) -> SpanContext: + return self._ctx + + def set_attribute(self, key: str, value: Any) -> None: + self.attributes[key] = value + + +@pytest.fixture(autouse=True) +def _clear_registry() -> Any: + _OBS_HANDLES.clear() + yield + _OBS_HANDLES.clear() + + +def _activate_otel_span(monkeypatch: pytest.MonkeyPatch) -> _RecordingOtelSpan: + span = _RecordingOtelSpan(_valid_ctx()) + monkeypatch.setattr(otel_trace, "get_current_span", lambda *a, **k: span) + return span + + +def test_temporal_path_tags_and_reads_otel_in_dd_only(monkeypatch: pytest.MonkeyPatch) -> None: + # Default/dd_only mode is exactly where the old code went to ddtrace. + # Tagging the ambient interceptor span (no wrapper) now applies only inside + # the SDK's dispatched START_SPAN/END_SPAN activity, not any activity. + monkeypatch.setenv("SGP_OBS_MODE", "dd_only") + monkeypatch.setattr(trace_mod, "_in_tracing_dispatch_activity", lambda: True) + activity_span = _activate_otel_span(monkeypatch) + + trace_obj = Trace(processors=[], client=cast(Any, object()), trace_id="trace-1") + span = trace_obj.start_span(name="process_turn") + + # Reverse tag landed on the OTel activity span (not a ddtrace span / nowhere). + assert activity_span.attributes["agentex.business_span_id"] == span.id + assert activity_span.attributes["agentex.business_trace_id"] == "trace-1" + + # Forward correlation recorded the OTel activity trace ids. + assert isinstance(span.data, dict) + assert span.data["obs_trace_id"] == _TRACE_HEX + assert span.data["obs_span_id"] == _SPAN_HEX + + # Temporal path opens no wrapper -> no handle registered (nothing to leak). + assert span.id not in _OBS_HANDLES + + +def test_obs_correlation_expect_otel_prefers_otel_over_ddtrace(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SGP_OBS_MODE", "dd_only") + _activate_otel_span(monkeypatch) + # Make ddtrace resolve to DIFFERENT ids so we can prove which backend won. + monkeypatch.setattr(obs_ids_mod, "_ddtrace_ids", lambda: ("d" * 32, "e" * 16)) + + # expect_otel (Temporal path): OTel wins even though mode is dd_only. + assert obs_correlation(expect_otel=True) == {"obs_trace_id": _TRACE_HEX, "obs_span_id": _SPAN_HEX} + # Default (in-process path): still honors mode -> ddtrace. + assert obs_correlation() == {"obs_trace_id": "d" * 32, "obs_span_id": "e" * 16} + + +def test_tag_ambient_expect_otel_falls_back_to_ddtrace(monkeypatch: pytest.MonkeyPatch) -> None: + """When no valid OTel span is active, expect_otel falls back to ddtrace.""" + monkeypatch.setenv("SGP_OBS_MODE", "dd_only") + + # No valid OTel span active. + invalid = _RecordingOtelSpan(otel_trace.INVALID_SPAN_CONTEXT) + monkeypatch.setattr(otel_trace, "get_current_span", lambda *a, **k: invalid) + + tagged: dict[str, Any] = {} + + class _FakeDDSpan: + def set_tag(self, k: str, v: Any) -> None: + tagged[k] = v + + class _FakeDDTracer: + def current_span(self) -> _FakeDDSpan: + return _FakeDDSpan() + + # obs_span imports `from ddtrace.trace import tracer` lazily; inject a stub module. + import sys + import types + + ddtrace_trace = types.ModuleType("ddtrace.trace") + ddtrace_trace.tracer = _FakeDDTracer() # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "ddtrace.trace", ddtrace_trace) + + tag_ambient_obs_span(business_span_id="bs", business_trace_id="bt", expect_otel=True) + + # OTel was invalid -> fell back to ddtrace, which got the reverse tag. + assert tagged["agentex.business_span_id"] == "bs" + assert tagged["agentex.business_trace_id"] == "bt" + # The invalid OTel span was NOT tagged. + assert invalid.attributes == {} + + +class _FakeHandle: + def __init__(self, corr): + self.correlation = corr + + +def test_begin_obs_opens_wrapper_outside_dispatch_activity(monkeypatch: pytest.MonkeyPatch) -> None: + """Sync path or inside a business Temporal activity: open a per-step wrapper + (1:1), not the ambient-span tag. Each business span gets its own obs span.""" + monkeypatch.setattr(trace_mod, "_in_tracing_dispatch_activity", lambda: False) + monkeypatch.setattr( + trace_mod, "open_obs_span", + lambda *a, **k: _FakeHandle({"obs_trace_id": "t1", "obs_span_id": "s1"}), + ) + handle, corr = trace_mod._begin_obs("mortgage.classify_intent", "bs", "bt") + assert handle is not None + assert corr == {"obs_trace_id": "t1", "obs_span_id": "s1"} + + +def test_begin_obs_tags_ambient_inside_dispatch_activity(monkeypatch: pytest.MonkeyPatch) -> None: + """Inside the dispatched START_SPAN/END_SPAN activity: no wrapper (would leak + across activities); tag the ambient interceptor span instead.""" + monkeypatch.setattr(trace_mod, "_in_tracing_dispatch_activity", lambda: True) + tagged: dict = {} + monkeypatch.setattr(trace_mod, "tag_ambient_obs_span", lambda **k: tagged.update(k)) + monkeypatch.setattr(trace_mod, "obs_correlation", lambda **k: {"obs_trace_id": "amb", "obs_span_id": "amb"}) + handle, corr = trace_mod._begin_obs("mortgage.advisor.turn", "bs", "bt") + assert handle is None + assert tagged.get("business_span_id") == "bs" and tagged.get("expect_otel") is True + assert corr == {"obs_trace_id": "amb", "obs_span_id": "amb"} + + +def test_business_activity_dd_only_reads_otel_not_ddtrace(monkeypatch: pytest.MonkeyPatch) -> None: + """Regression: inside a BUSINESS activity (not the dispatched start/end-span) + with default dd_only mode, the per-step wrapper branch must prefer OTel. The + ambient span is the temporalio OTel interceptor span regardless of mode; a + dd_only ddtrace read finds no request context in a worker, so before the fix + the business span persisted with empty obs ids. Assert it carries the OTel + activity ids, not ddtrace's.""" + monkeypatch.setenv("SGP_OBS_MODE", "dd_only") + monkeypatch.setattr(trace_mod, "_in_tracing_dispatch_activity", lambda: False) + monkeypatch.setattr(trace_mod, "_in_temporal_activity", lambda: True) + # Make ddtrace resolve to DIFFERENT ids so we can prove which backend won. + monkeypatch.setattr(obs_ids_mod, "_ddtrace_ids", lambda: ("d" * 32, "e" * 16)) + _activate_otel_span(monkeypatch) # valid OTel span active (the interceptor span) + + trace_obj = Trace(processors=[], client=cast(Any, object()), trace_id="trace-1") + span = trace_obj.start_span(name="mortgage.classify_intent") + + assert isinstance(span.data, dict) + # OTel ids, not ddtrace's ("d"*32) and not empty. + assert span.data["obs_trace_id"] == _TRACE_HEX + assert span.data["obs_span_id"] == _SPAN_HEX + + +# --------------------------------------------------------------------------- # +# The dispatch discriminator itself (trace.py:_in_tracing_dispatch_activity). +# This is the one line preventing a cross-worker handle leak inside START_SPAN, +# so it gets exercised directly with a faked activity.info() -- and against the +# enum's own .value, so it also fails if TracingActivityName ever drifts from +# the strings hardcoded in trace.py. +# --------------------------------------------------------------------------- # +def test_dispatch_discriminator_true_for_start_and_end_span(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(temporal_activity, "in_activity", lambda: True) + for name in (TracingActivityName.START_SPAN, TracingActivityName.END_SPAN): + # activity_type round-trips as the plain string value through protobuf. + monkeypatch.setattr(temporal_activity, "info", lambda n=name: SimpleNamespace(activity_type=n.value)) + assert trace_mod._in_tracing_dispatch_activity() is True, name + + +def test_dispatch_discriminator_false_for_business_activity(monkeypatch: pytest.MonkeyPatch) -> None: + # A real agent-turn activity (e.g. process_mortgage_turn) is NOT a dispatch + # activity -> it must take the per-step wrapper branch, not Option-A tagging. + monkeypatch.setattr(temporal_activity, "in_activity", lambda: True) + monkeypatch.setattr(temporal_activity, "info", lambda: SimpleNamespace(activity_type="process_mortgage_turn")) + assert trace_mod._in_tracing_dispatch_activity() is False + + +def test_dispatch_discriminator_false_when_not_in_activity(monkeypatch: pytest.MonkeyPatch) -> None: + # Guard short-circuits on in_activity()==False; info() (here a dispatch value) + # must never be consulted, else the sync path would be misclassified. + monkeypatch.setattr(temporal_activity, "in_activity", lambda: False) + monkeypatch.setattr(temporal_activity, "info", lambda: SimpleNamespace(activity_type="start-span")) + assert trace_mod._in_tracing_dispatch_activity() is False + + +def test_in_temporal_activity_tracks_in_activity(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(temporal_activity, "in_activity", lambda: True) + assert trace_mod._in_temporal_activity() is True + monkeypatch.setattr(temporal_activity, "in_activity", lambda: False) + assert trace_mod._in_temporal_activity() is False + + +# --------------------------------------------------------------------------- # +# Backend-drift warning: the mode stays authoritative, and a mode-vs-live +# mismatch is surfaced once (not silently absorbed by the fallback). +# --------------------------------------------------------------------------- # +def test_warn_on_backend_drift_logs_once_on_mismatch(monkeypatch: pytest.MonkeyPatch, caplog) -> None: + """dd_only configured but the live span is OTel (config doesn't match the + running tracer) -> warn, and only once even across repeated spans.""" + import logging + + monkeypatch.setenv("SGP_OBS_MODE", "dd_only") + obs_ids_mod._WARNED_DRIFT.clear() + _activate_otel_span(monkeypatch) # OTel span live + monkeypatch.setattr(obs_ids_mod, "_ddtrace_ids", lambda: None) # ddtrace absent + + with caplog.at_level(logging.WARNING, logger="agentex.lib.core.tracing.obs_ids"): + obs_ids_mod.warn_on_backend_drift(expect_otel=False) + obs_ids_mod.warn_on_backend_drift(expect_otel=False) # deduped + + drift = [r for r in caplog.records if "backend drift" in r.getMessage()] + assert len(drift) == 1 + + +def test_warn_on_backend_drift_silent_when_expected_backend_is_live( + monkeypatch: pytest.MonkeyPatch, caplog +) -> None: + """Temporal path expects OTel and OTel IS the live span -> the by-design case, + no warning.""" + import logging + + monkeypatch.setenv("SGP_OBS_MODE", "dd_only") + obs_ids_mod._WARNED_DRIFT.clear() + _activate_otel_span(monkeypatch) # OTel live == what expect_otel expects + + with caplog.at_level(logging.WARNING, logger="agentex.lib.core.tracing.obs_ids"): + obs_ids_mod.warn_on_backend_drift(expect_otel=True) + + assert [r for r in caplog.records if "backend drift" in r.getMessage()] == [] diff --git a/tests/test_trace_context_extraction.py b/tests/test_trace_context_extraction.py new file mode 100644 index 000000000..3da6fd55d --- /dev/null +++ b/tests/test_trace_context_extraction.py @@ -0,0 +1,87 @@ +"""Unit tests for ACP inbound W3C trace-context extraction. + +Regression guard for the async end-to-end tracing fix: FastACP must *continue* +an incoming traceparent (make it the active OpenTelemetry context) so the +downstream Temporal start/signal — and the work dispatched via +asyncio.create_task — run under the ingress trace instead of detaching into a +fresh trace. See RequestIDMiddleware / _attach_incoming_otel_context. +""" + +from __future__ import annotations + +from opentelemetry.propagate import inject + +from agentex.lib.sdk.fastacp.base.base_acp_server import ( + _detach_otel_context, + _attach_incoming_otel_context, +) + + +def _active_traceparent() -> str | None: + carrier: dict[str, str] = {} + inject(carrier) + return carrier.get("traceparent") + + +def test_attach_makes_inbound_traceparent_the_active_context() -> None: + trace_id = "0af7651916cd43dd8448eb211c80319c" + headers = [ + (b"traceparent", f"00-{trace_id}-b7ad6b7169203331-01".encode()), + (b"content-type", b"application/json"), + ] + token = _attach_incoming_otel_context(headers) + try: + active = _active_traceparent() + assert active is not None, "no active traceparent after attach" + # The active context must carry the ingress trace id, so the Temporal + # interceptor propagates it downstream instead of starting a fresh trace. + assert trace_id in active, f"expected ingress trace {trace_id}, got {active}" + finally: + _detach_otel_context(token) + + +def test_no_inbound_traceparent_is_fail_open() -> None: + # No traceparent header: must not raise, and detach must be safe. + token = _attach_incoming_otel_context([(b"content-type", b"application/json")]) + _detach_otel_context(token) + + +def test_repeated_tracestate_headers_are_combined() -> None: + # ASGI can deliver tracestate as multiple header lines; W3C/RFC7230 require + # combining them. The old dict-comprehension carrier kept only the last. + from opentelemetry import trace as _trace + + trace_id = "0af7651916cd43dd8448eb211c80319c" + headers = [ + (b"traceparent", f"00-{trace_id}-b7ad6b7169203331-01".encode()), + (b"tracestate", b"vendora=1"), + (b"tracestate", b"vendorb=2"), + ] + token = _attach_incoming_otel_context(headers) + try: + ts = _trace.get_current_span().get_span_context().trace_state + assert ts.get("vendora") == "1" + assert ts.get("vendorb") == "2" # would be missing if repeats collapsed + finally: + _detach_otel_context(token) + + +def test_inbound_baggage_is_not_extracted() -> None: + # W3C tracecontext-only extraction: arbitrary inbound baggage (attacker- + # controlled keys) must not be pulled into the downstream context. + from opentelemetry.baggage import get_all + + trace_id = "0af7651916cd43dd8448eb211c80319c" + headers = [ + (b"traceparent", f"00-{trace_id}-b7ad6b7169203331-01".encode()), + (b"baggage", b"user_id=secret,role=admin"), + ] + token = _attach_incoming_otel_context(headers) + try: + assert get_all() == {} + finally: + _detach_otel_context(token) + + +def test_detach_none_is_safe() -> None: + _detach_otel_context(None) diff --git a/tests/test_transform.py b/tests/test_transform.py new file mode 100644 index 000000000..beafc1b8e --- /dev/null +++ b/tests/test_transform.py @@ -0,0 +1,460 @@ +from __future__ import annotations + +import io +import pathlib +from typing import Any, Dict, List, Union, TypeVar, Iterable, Optional, cast +from datetime import date, datetime +from typing_extensions import Required, Annotated, TypedDict + +import pytest + +from agentex._types import Base64FileInput, omit, not_given +from agentex._utils import ( + PropertyInfo, + transform as _transform, + parse_datetime, + async_transform as _async_transform, +) +from agentex._compat import PYDANTIC_V1 +from agentex._models import BaseModel + +_T = TypeVar("_T") + +SAMPLE_FILE_PATH = pathlib.Path(__file__).parent.joinpath("sample_file.txt") + + +async def transform( + data: _T, + expected_type: object, + use_async: bool, +) -> _T: + if use_async: + return await _async_transform(data, expected_type=expected_type) + + return _transform(data, expected_type=expected_type) + + +parametrize = pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) + + +class Foo1(TypedDict): + foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] + + +@parametrize +@pytest.mark.asyncio +async def test_top_level_alias(use_async: bool) -> None: + assert await transform({"foo_bar": "hello"}, expected_type=Foo1, use_async=use_async) == {"fooBar": "hello"} + + +class Foo2(TypedDict): + bar: Bar2 + + +class Bar2(TypedDict): + this_thing: Annotated[int, PropertyInfo(alias="this__thing")] + baz: Annotated[Baz2, PropertyInfo(alias="Baz")] + + +class Baz2(TypedDict): + my_baz: Annotated[str, PropertyInfo(alias="myBaz")] + + +@parametrize +@pytest.mark.asyncio +async def test_recursive_typeddict(use_async: bool) -> None: + assert await transform({"bar": {"this_thing": 1}}, Foo2, use_async) == {"bar": {"this__thing": 1}} + assert await transform({"bar": {"baz": {"my_baz": "foo"}}}, Foo2, use_async) == {"bar": {"Baz": {"myBaz": "foo"}}} + + +class Foo3(TypedDict): + things: List[Bar3] + + +class Bar3(TypedDict): + my_field: Annotated[str, PropertyInfo(alias="myField")] + + +@parametrize +@pytest.mark.asyncio +async def test_list_of_typeddict(use_async: bool) -> None: + result = await transform({"things": [{"my_field": "foo"}, {"my_field": "foo2"}]}, Foo3, use_async) + assert result == {"things": [{"myField": "foo"}, {"myField": "foo2"}]} + + +class Foo4(TypedDict): + foo: Union[Bar4, Baz4] + + +class Bar4(TypedDict): + foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] + + +class Baz4(TypedDict): + foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] + + +@parametrize +@pytest.mark.asyncio +async def test_union_of_typeddict(use_async: bool) -> None: + assert await transform({"foo": {"foo_bar": "bar"}}, Foo4, use_async) == {"foo": {"fooBar": "bar"}} + assert await transform({"foo": {"foo_baz": "baz"}}, Foo4, use_async) == {"foo": {"fooBaz": "baz"}} + assert await transform({"foo": {"foo_baz": "baz", "foo_bar": "bar"}}, Foo4, use_async) == { + "foo": {"fooBaz": "baz", "fooBar": "bar"} + } + + +class Foo5(TypedDict): + foo: Annotated[Union[Bar4, List[Baz4]], PropertyInfo(alias="FOO")] + + +class Bar5(TypedDict): + foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] + + +class Baz5(TypedDict): + foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] + + +@parametrize +@pytest.mark.asyncio +async def test_union_of_list(use_async: bool) -> None: + assert await transform({"foo": {"foo_bar": "bar"}}, Foo5, use_async) == {"FOO": {"fooBar": "bar"}} + assert await transform( + { + "foo": [ + {"foo_baz": "baz"}, + {"foo_baz": "baz"}, + ] + }, + Foo5, + use_async, + ) == {"FOO": [{"fooBaz": "baz"}, {"fooBaz": "baz"}]} + + +class Foo6(TypedDict): + bar: Annotated[str, PropertyInfo(alias="Bar")] + + +@parametrize +@pytest.mark.asyncio +async def test_includes_unknown_keys(use_async: bool) -> None: + assert await transform({"bar": "bar", "baz_": {"FOO": 1}}, Foo6, use_async) == { + "Bar": "bar", + "baz_": {"FOO": 1}, + } + + +class Foo7(TypedDict): + bar: Annotated[List[Bar7], PropertyInfo(alias="bAr")] + foo: Bar7 + + +class Bar7(TypedDict): + foo: str + + +@parametrize +@pytest.mark.asyncio +async def test_ignores_invalid_input(use_async: bool) -> None: + assert await transform({"bar": ""}, Foo7, use_async) == {"bAr": ""} + assert await transform({"foo": ""}, Foo7, use_async) == {"foo": ""} + + +class DatetimeDict(TypedDict, total=False): + foo: Annotated[datetime, PropertyInfo(format="iso8601")] + + bar: Annotated[Optional[datetime], PropertyInfo(format="iso8601")] + + required: Required[Annotated[Optional[datetime], PropertyInfo(format="iso8601")]] + + list_: Required[Annotated[Optional[List[datetime]], PropertyInfo(format="iso8601")]] + + union: Annotated[Union[int, datetime], PropertyInfo(format="iso8601")] + + +class DateDict(TypedDict, total=False): + foo: Annotated[date, PropertyInfo(format="iso8601")] + + +class DatetimeModel(BaseModel): + foo: datetime + + +class DateModel(BaseModel): + foo: Optional[date] + + +@parametrize +@pytest.mark.asyncio +async def test_iso8601_format(use_async: bool) -> None: + dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + tz = "+00:00" if PYDANTIC_V1 else "Z" + assert await transform({"foo": dt}, DatetimeDict, use_async) == {"foo": "2023-02-23T14:16:36.337692+00:00"} # type: ignore[comparison-overlap] + assert await transform(DatetimeModel(foo=dt), Any, use_async) == {"foo": "2023-02-23T14:16:36.337692" + tz} # type: ignore[comparison-overlap] + + dt = dt.replace(tzinfo=None) + assert await transform({"foo": dt}, DatetimeDict, use_async) == {"foo": "2023-02-23T14:16:36.337692"} # type: ignore[comparison-overlap] + assert await transform(DatetimeModel(foo=dt), Any, use_async) == {"foo": "2023-02-23T14:16:36.337692"} # type: ignore[comparison-overlap] + + assert await transform({"foo": None}, DateDict, use_async) == {"foo": None} # type: ignore[comparison-overlap] + assert await transform(DateModel(foo=None), Any, use_async) == {"foo": None} # type: ignore + assert await transform({"foo": date.fromisoformat("2023-02-23")}, DateDict, use_async) == {"foo": "2023-02-23"} # type: ignore[comparison-overlap] + assert await transform(DateModel(foo=date.fromisoformat("2023-02-23")), DateDict, use_async) == { + "foo": "2023-02-23" + } # type: ignore[comparison-overlap] + + +@parametrize +@pytest.mark.asyncio +async def test_optional_iso8601_format(use_async: bool) -> None: + dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + assert await transform({"bar": dt}, DatetimeDict, use_async) == {"bar": "2023-02-23T14:16:36.337692+00:00"} # type: ignore[comparison-overlap] + + assert await transform({"bar": None}, DatetimeDict, use_async) == {"bar": None} + + +@parametrize +@pytest.mark.asyncio +async def test_required_iso8601_format(use_async: bool) -> None: + dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + assert await transform({"required": dt}, DatetimeDict, use_async) == { + "required": "2023-02-23T14:16:36.337692+00:00" + } # type: ignore[comparison-overlap] + + assert await transform({"required": None}, DatetimeDict, use_async) == {"required": None} + + +@parametrize +@pytest.mark.asyncio +async def test_union_datetime(use_async: bool) -> None: + dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + assert await transform({"union": dt}, DatetimeDict, use_async) == { # type: ignore[comparison-overlap] + "union": "2023-02-23T14:16:36.337692+00:00" + } + + assert await transform({"union": "foo"}, DatetimeDict, use_async) == {"union": "foo"} + + +@parametrize +@pytest.mark.asyncio +async def test_nested_list_iso6801_format(use_async: bool) -> None: + dt1 = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + dt2 = parse_datetime("2022-01-15T06:34:23Z") + assert await transform({"list_": [dt1, dt2]}, DatetimeDict, use_async) == { # type: ignore[comparison-overlap] + "list_": ["2023-02-23T14:16:36.337692+00:00", "2022-01-15T06:34:23+00:00"] + } + + +@parametrize +@pytest.mark.asyncio +async def test_datetime_custom_format(use_async: bool) -> None: + dt = parse_datetime("2022-01-15T06:34:23Z") + + result = await transform(dt, Annotated[datetime, PropertyInfo(format="custom", format_template="%H")], use_async) + assert result == "06" # type: ignore[comparison-overlap] + + +class DateDictWithRequiredAlias(TypedDict, total=False): + required_prop: Required[Annotated[date, PropertyInfo(format="iso8601", alias="prop")]] + + +@parametrize +@pytest.mark.asyncio +async def test_datetime_with_alias(use_async: bool) -> None: + assert await transform({"required_prop": None}, DateDictWithRequiredAlias, use_async) == {"prop": None} # type: ignore[comparison-overlap] + assert await transform( + {"required_prop": date.fromisoformat("2023-02-23")}, DateDictWithRequiredAlias, use_async + ) == {"prop": "2023-02-23"} # type: ignore[comparison-overlap] + + +class MyModel(BaseModel): + foo: str + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_model_to_dictionary(use_async: bool) -> None: + assert cast(Any, await transform(MyModel(foo="hi!"), Any, use_async)) == {"foo": "hi!"} + assert cast(Any, await transform(MyModel.construct(foo="hi!"), Any, use_async)) == {"foo": "hi!"} + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_empty_model(use_async: bool) -> None: + assert cast(Any, await transform(MyModel.construct(), Any, use_async)) == {} + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_unknown_field(use_async: bool) -> None: + assert cast(Any, await transform(MyModel.construct(my_untyped_field=True), Any, use_async)) == { + "my_untyped_field": True + } + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_mismatched_types(use_async: bool) -> None: + model = MyModel.construct(foo=True) + if PYDANTIC_V1: + params = await transform(model, Any, use_async) + else: + with pytest.warns(UserWarning): + params = await transform(model, Any, use_async) + assert cast(Any, params) == {"foo": True} + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_mismatched_object_type(use_async: bool) -> None: + model = MyModel.construct(foo=MyModel.construct(hello="world")) + if PYDANTIC_V1: + params = await transform(model, Any, use_async) + else: + with pytest.warns(UserWarning): + params = await transform(model, Any, use_async) + assert cast(Any, params) == {"foo": {"hello": "world"}} + + +class ModelNestedObjects(BaseModel): + nested: MyModel + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_nested_objects(use_async: bool) -> None: + model = ModelNestedObjects.construct(nested={"foo": "stainless"}) + assert isinstance(model.nested, MyModel) + assert cast(Any, await transform(model, Any, use_async)) == {"nested": {"foo": "stainless"}} + + +class ModelWithDefaultField(BaseModel): + foo: str + with_none_default: Union[str, None] = None + with_str_default: str = "foo" + + +@parametrize +@pytest.mark.asyncio +async def test_pydantic_default_field(use_async: bool) -> None: + # should be excluded when defaults are used + model = ModelWithDefaultField.construct() + assert model.with_none_default is None + assert model.with_str_default == "foo" + assert cast(Any, await transform(model, Any, use_async)) == {} + + # should be included when the default value is explicitly given + model = ModelWithDefaultField.construct(with_none_default=None, with_str_default="foo") + assert model.with_none_default is None + assert model.with_str_default == "foo" + assert cast(Any, await transform(model, Any, use_async)) == {"with_none_default": None, "with_str_default": "foo"} + + # should be included when a non-default value is explicitly given + model = ModelWithDefaultField.construct(with_none_default="bar", with_str_default="baz") + assert model.with_none_default == "bar" + assert model.with_str_default == "baz" + assert cast(Any, await transform(model, Any, use_async)) == {"with_none_default": "bar", "with_str_default": "baz"} + + +class TypedDictIterableUnion(TypedDict): + foo: Annotated[Union[Bar8, Iterable[Baz8]], PropertyInfo(alias="FOO")] + + +class Bar8(TypedDict): + foo_bar: Annotated[str, PropertyInfo(alias="fooBar")] + + +class Baz8(TypedDict): + foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] + + +@parametrize +@pytest.mark.asyncio +async def test_iterable_of_dictionaries(use_async: bool) -> None: + assert await transform({"foo": [{"foo_baz": "bar"}]}, TypedDictIterableUnion, use_async) == { + "FOO": [{"fooBaz": "bar"}] + } + assert cast(Any, await transform({"foo": ({"foo_baz": "bar"},)}, TypedDictIterableUnion, use_async)) == { + "FOO": [{"fooBaz": "bar"}] + } + + def my_iter() -> Iterable[Baz8]: + yield {"foo_baz": "hello"} + yield {"foo_baz": "world"} + + assert await transform({"foo": my_iter()}, TypedDictIterableUnion, use_async) == { + "FOO": [{"fooBaz": "hello"}, {"fooBaz": "world"}] + } + + +@parametrize +@pytest.mark.asyncio +async def test_dictionary_items(use_async: bool) -> None: + class DictItems(TypedDict): + foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] + + assert await transform({"foo": {"foo_baz": "bar"}}, Dict[str, DictItems], use_async) == {"foo": {"fooBaz": "bar"}} + + +class TypedDictIterableUnionStr(TypedDict): + foo: Annotated[Union[str, Iterable[Baz8]], PropertyInfo(alias="FOO")] + + +@parametrize +@pytest.mark.asyncio +async def test_iterable_union_str(use_async: bool) -> None: + assert await transform({"foo": "bar"}, TypedDictIterableUnionStr, use_async) == {"FOO": "bar"} + assert cast(Any, await transform(iter([{"foo_baz": "bar"}]), Union[str, Iterable[Baz8]], use_async)) == [ + {"fooBaz": "bar"} + ] + + +class TypedDictBase64Input(TypedDict): + foo: Annotated[Union[str, Base64FileInput], PropertyInfo(format="base64")] + + +@parametrize +@pytest.mark.asyncio +async def test_base64_file_input(use_async: bool) -> None: + # strings are left as-is + assert await transform({"foo": "bar"}, TypedDictBase64Input, use_async) == {"foo": "bar"} + + # pathlib.Path is automatically converted to base64 + assert await transform({"foo": SAMPLE_FILE_PATH}, TypedDictBase64Input, use_async) == { + "foo": "SGVsbG8sIHdvcmxkIQo=" + } # type: ignore[comparison-overlap] + + # io instances are automatically converted to base64 + assert await transform({"foo": io.StringIO("Hello, world!")}, TypedDictBase64Input, use_async) == { + "foo": "SGVsbG8sIHdvcmxkIQ==" + } # type: ignore[comparison-overlap] + assert await transform({"foo": io.BytesIO(b"Hello, world!")}, TypedDictBase64Input, use_async) == { + "foo": "SGVsbG8sIHdvcmxkIQ==" + } # type: ignore[comparison-overlap] + + +@parametrize +@pytest.mark.asyncio +async def test_transform_skipping(use_async: bool) -> None: + # lists of ints are left as-is + data = [1, 2, 3] + assert await transform(data, List[int], use_async) is data + + # iterables of ints are converted to a list + data = iter([1, 2, 3]) + assert await transform(data, Iterable[int], use_async) == [1, 2, 3] + + +@parametrize +@pytest.mark.asyncio +async def test_strips_notgiven(use_async: bool) -> None: + assert await transform({"foo_bar": "bar"}, Foo1, use_async) == {"fooBar": "bar"} + assert await transform({"foo_bar": not_given}, Foo1, use_async) == {} + + +@parametrize +@pytest.mark.asyncio +async def test_strips_omit(use_async: bool) -> None: + assert await transform({"foo_bar": "bar"}, Foo1, use_async) == {"fooBar": "bar"} + assert await transform({"foo_bar": omit}, Foo1, use_async) == {} diff --git a/tests/test_utils/test_datetime_parse.py b/tests/test_utils/test_datetime_parse.py new file mode 100644 index 000000000..b55158349 --- /dev/null +++ b/tests/test_utils/test_datetime_parse.py @@ -0,0 +1,110 @@ +""" +Copied from https://github.com/pydantic/pydantic/blob/v1.10.22/tests/test_datetime_parse.py +with modifications so it works without pydantic v1 imports. +""" + +from typing import Type, Union +from datetime import date, datetime, timezone, timedelta + +import pytest + +from agentex._utils import parse_date, parse_datetime + + +def create_tz(minutes: int) -> timezone: + return timezone(timedelta(minutes=minutes)) + + +@pytest.mark.parametrize( + "value,result", + [ + # Valid inputs + ("1494012444.883309", date(2017, 5, 5)), + (b"1494012444.883309", date(2017, 5, 5)), + (1_494_012_444.883_309, date(2017, 5, 5)), + ("1494012444", date(2017, 5, 5)), + (1_494_012_444, date(2017, 5, 5)), + (0, date(1970, 1, 1)), + ("2012-04-23", date(2012, 4, 23)), + (b"2012-04-23", date(2012, 4, 23)), + ("2012-4-9", date(2012, 4, 9)), + (date(2012, 4, 9), date(2012, 4, 9)), + (datetime(2012, 4, 9, 12, 15), date(2012, 4, 9)), + # Invalid inputs + ("x20120423", ValueError), + ("2012-04-56", ValueError), + (19_999_999_999, date(2603, 10, 11)), # just before watershed + (20_000_000_001, date(1970, 8, 20)), # just after watershed + (1_549_316_052, date(2019, 2, 4)), # nowish in s + (1_549_316_052_104, date(2019, 2, 4)), # nowish in ms + (1_549_316_052_104_324, date(2019, 2, 4)), # nowish in μs + (1_549_316_052_104_324_096, date(2019, 2, 4)), # nowish in ns + ("infinity", date(9999, 12, 31)), + ("inf", date(9999, 12, 31)), + (float("inf"), date(9999, 12, 31)), + ("infinity ", date(9999, 12, 31)), + (int("1" + "0" * 100), date(9999, 12, 31)), + (1e1000, date(9999, 12, 31)), + ("-infinity", date(1, 1, 1)), + ("-inf", date(1, 1, 1)), + ("nan", ValueError), + ], +) +def test_date_parsing(value: Union[str, bytes, int, float], result: Union[date, Type[Exception]]) -> None: + if type(result) == type and issubclass(result, Exception): # pyright: ignore[reportUnnecessaryIsInstance] + with pytest.raises(result): + parse_date(value) + else: + assert parse_date(value) == result + + +@pytest.mark.parametrize( + "value,result", + [ + # Valid inputs + # values in seconds + ("1494012444.883309", datetime(2017, 5, 5, 19, 27, 24, 883_309, tzinfo=timezone.utc)), + (1_494_012_444.883_309, datetime(2017, 5, 5, 19, 27, 24, 883_309, tzinfo=timezone.utc)), + ("1494012444", datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + (b"1494012444", datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + (1_494_012_444, datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + # values in ms + ("1494012444000.883309", datetime(2017, 5, 5, 19, 27, 24, 883, tzinfo=timezone.utc)), + ("-1494012444000.883309", datetime(1922, 8, 29, 4, 32, 35, 999117, tzinfo=timezone.utc)), + (1_494_012_444_000, datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + ("2012-04-23T09:15:00", datetime(2012, 4, 23, 9, 15)), + ("2012-4-9 4:8:16", datetime(2012, 4, 9, 4, 8, 16)), + ("2012-04-23T09:15:00Z", datetime(2012, 4, 23, 9, 15, 0, 0, timezone.utc)), + ("2012-4-9 4:8:16-0320", datetime(2012, 4, 9, 4, 8, 16, 0, create_tz(-200))), + ("2012-04-23T10:20:30.400+02:30", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(150))), + ("2012-04-23T10:20:30.400+02", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(120))), + ("2012-04-23T10:20:30.400-02", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(-120))), + (b"2012-04-23T10:20:30.400-02", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(-120))), + (datetime(2017, 5, 5), datetime(2017, 5, 5)), + (0, datetime(1970, 1, 1, 0, 0, 0, tzinfo=timezone.utc)), + # Invalid inputs + ("x20120423091500", ValueError), + ("2012-04-56T09:15:90", ValueError), + ("2012-04-23T11:05:00-25:00", ValueError), + (19_999_999_999, datetime(2603, 10, 11, 11, 33, 19, tzinfo=timezone.utc)), # just before watershed + (20_000_000_001, datetime(1970, 8, 20, 11, 33, 20, 1000, tzinfo=timezone.utc)), # just after watershed + (1_549_316_052, datetime(2019, 2, 4, 21, 34, 12, 0, tzinfo=timezone.utc)), # nowish in s + (1_549_316_052_104, datetime(2019, 2, 4, 21, 34, 12, 104_000, tzinfo=timezone.utc)), # nowish in ms + (1_549_316_052_104_324, datetime(2019, 2, 4, 21, 34, 12, 104_324, tzinfo=timezone.utc)), # nowish in μs + (1_549_316_052_104_324_096, datetime(2019, 2, 4, 21, 34, 12, 104_324, tzinfo=timezone.utc)), # nowish in ns + ("infinity", datetime(9999, 12, 31, 23, 59, 59, 999999)), + ("inf", datetime(9999, 12, 31, 23, 59, 59, 999999)), + ("inf ", datetime(9999, 12, 31, 23, 59, 59, 999999)), + (1e50, datetime(9999, 12, 31, 23, 59, 59, 999999)), + (float("inf"), datetime(9999, 12, 31, 23, 59, 59, 999999)), + ("-infinity", datetime(1, 1, 1, 0, 0)), + ("-inf", datetime(1, 1, 1, 0, 0)), + ("nan", ValueError), + ], +) +def test_datetime_parsing(value: Union[str, bytes, int, float], result: Union[datetime, Type[Exception]]) -> None: + if type(result) == type and issubclass(result, Exception): # pyright: ignore[reportUnnecessaryIsInstance] + with pytest.raises(result): + parse_datetime(value) + else: + assert parse_datetime(value) == result diff --git a/tests/test_utils/test_json.py b/tests/test_utils/test_json.py new file mode 100644 index 000000000..31579b69a --- /dev/null +++ b/tests/test_utils/test_json.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import datetime +from typing import Union + +import pydantic + +from agentex import _compat +from agentex._utils._json import openapi_dumps + + +class TestOpenapiDumps: + def test_basic(self) -> None: + data = {"key": "value", "number": 42} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"key":"value","number":42}' + + def test_datetime_serialization(self) -> None: + dt = datetime.datetime(2023, 1, 1, 12, 0, 0) + data = {"datetime": dt} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"datetime":"2023-01-01T12:00:00"}' + + def test_pydantic_model_serialization(self) -> None: + class User(pydantic.BaseModel): + first_name: str + last_name: str + age: int + + model_instance = User(first_name="John", last_name="Kramer", age=83) + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"first_name":"John","last_name":"Kramer","age":83}}' + + def test_pydantic_model_with_default_values(self) -> None: + class User(pydantic.BaseModel): + name: str + role: str = "user" + active: bool = True + score: int = 0 + + model_instance = User(name="Alice") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Alice"}}' + + def test_pydantic_model_with_default_values_overridden(self) -> None: + class User(pydantic.BaseModel): + name: str + role: str = "user" + active: bool = True + + model_instance = User(name="Bob", role="admin", active=False) + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Bob","role":"admin","active":false}}' + + def test_pydantic_model_with_alias(self) -> None: + class User(pydantic.BaseModel): + first_name: str = pydantic.Field(alias="firstName") + last_name: str = pydantic.Field(alias="lastName") + + model_instance = User(firstName="John", lastName="Doe") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"firstName":"John","lastName":"Doe"}}' + + def test_pydantic_model_with_alias_and_default(self) -> None: + class User(pydantic.BaseModel): + user_name: str = pydantic.Field(alias="userName") + user_role: str = pydantic.Field(default="member", alias="userRole") + is_active: bool = pydantic.Field(default=True, alias="isActive") + + model_instance = User(userName="charlie") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"userName":"charlie"}}' + + model_with_overrides = User(userName="diana", userRole="admin", isActive=False) + data = {"model": model_with_overrides} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"userName":"diana","userRole":"admin","isActive":false}}' + + def test_pydantic_model_with_nested_models_and_defaults(self) -> None: + class Address(pydantic.BaseModel): + street: str + city: str = "Unknown" + + class User(pydantic.BaseModel): + name: str + address: Address + verified: bool = False + + if _compat.PYDANTIC_V1: + # to handle forward references in Pydantic v1 + User.update_forward_refs(**locals()) # type: ignore[reportDeprecated] + + address = Address(street="123 Main St") + user = User(name="Diana", address=address) + data = {"user": user} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"user":{"name":"Diana","address":{"street":"123 Main St"}}}' + + address_with_city = Address(street="456 Oak Ave", city="Boston") + user_verified = User(name="Eve", address=address_with_city, verified=True) + data = {"user": user_verified} + json_bytes = openapi_dumps(data) + assert ( + json_bytes == b'{"user":{"name":"Eve","address":{"street":"456 Oak Ave","city":"Boston"},"verified":true}}' + ) + + def test_pydantic_model_with_optional_fields(self) -> None: + class User(pydantic.BaseModel): + name: str + email: Union[str, None] + phone: Union[str, None] + + model_with_none = User(name="Eve", email=None, phone=None) + data = {"model": model_with_none} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Eve","email":null,"phone":null}}' + + model_with_values = User(name="Frank", email="frank@example.com", phone=None) + data = {"model": model_with_values} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Frank","email":"frank@example.com","phone":null}}' diff --git a/tests/test_utils/test_path.py b/tests/test_utils/test_path.py new file mode 100644 index 000000000..fdc41f1f8 --- /dev/null +++ b/tests/test_utils/test_path.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from agentex._utils._path import path_template + + +@pytest.mark.parametrize( + "template, kwargs, expected", + [ + ("/v1/{id}", dict(id="abc"), "/v1/abc"), + ("/v1/{a}/{b}", dict(a="x", b="y"), "/v1/x/y"), + ("/v1/{a}{b}/path/{c}?val={d}#{e}", dict(a="x", b="y", c="z", d="u", e="v"), "/v1/xy/path/z?val=u#v"), + ("/{w}/{w}", dict(w="echo"), "/echo/echo"), + ("/v1/static", {}, "/v1/static"), + ("", {}, ""), + ("/v1/?q={n}&count=10", dict(n=42), "/v1/?q=42&count=10"), + ("/v1/{v}", dict(v=None), "/v1/null"), + ("/v1/{v}", dict(v=True), "/v1/true"), + ("/v1/{v}", dict(v=False), "/v1/false"), + ("/v1/{v}", dict(v=".hidden"), "/v1/.hidden"), # dot prefix ok + ("/v1/{v}", dict(v="file.txt"), "/v1/file.txt"), # dot in middle ok + ("/v1/{v}", dict(v="..."), "/v1/..."), # triple dot ok + ("/v1/{a}{b}", dict(a=".", b="txt"), "/v1/.txt"), # dot var combining with adjacent to be ok + ("/items?q={v}#{f}", dict(v=".", f=".."), "/items?q=.#.."), # dots in query/fragment are fine + ( + "/v1/{a}?query={b}", + dict(a="../../other/endpoint", b="a&bad=true"), + "/v1/..%2F..%2Fother%2Fendpoint?query=a%26bad%3Dtrue", + ), + ("/v1/{val}", dict(val="a/b/c"), "/v1/a%2Fb%2Fc"), + ("/v1/{val}", dict(val="a/b/c?query=value"), "/v1/a%2Fb%2Fc%3Fquery=value"), + ("/v1/{val}", dict(val="a/b/c?query=value&bad=true"), "/v1/a%2Fb%2Fc%3Fquery=value&bad=true"), + ("/v1/{val}", dict(val="%20"), "/v1/%2520"), # escapes escape sequences in input + # Query: slash and ? are safe, # is not + ("/items?q={v}", dict(v="a/b"), "/items?q=a/b"), + ("/items?q={v}", dict(v="a?b"), "/items?q=a?b"), + ("/items?q={v}", dict(v="a#b"), "/items?q=a%23b"), + ("/items?q={v}", dict(v="a b"), "/items?q=a%20b"), + # Fragment: slash and ? are safe + ("/docs#{v}", dict(v="a/b"), "/docs#a/b"), + ("/docs#{v}", dict(v="a?b"), "/docs#a?b"), + # Path: slash, ? and # are all encoded + ("/v1/{v}", dict(v="a/b"), "/v1/a%2Fb"), + ("/v1/{v}", dict(v="a?b"), "/v1/a%3Fb"), + ("/v1/{v}", dict(v="a#b"), "/v1/a%23b"), + # same var encoded differently by component + ( + "/v1/{v}?q={v}#{v}", + dict(v="a/b?c#d"), + "/v1/a%2Fb%3Fc%23d?q=a/b?c%23d#a/b?c%23d", + ), + ("/v1/{val}", dict(val="x?admin=true"), "/v1/x%3Fadmin=true"), # query injection + ("/v1/{val}", dict(val="x#admin"), "/v1/x%23admin"), # fragment injection + ], +) +def test_interpolation(template: str, kwargs: dict[str, Any], expected: str) -> None: + assert path_template(template, **kwargs) == expected + + +def test_missing_kwarg_raises_key_error() -> None: + with pytest.raises(KeyError, match="org_id"): + path_template("/v1/{org_id}") + + +@pytest.mark.parametrize( + "template, kwargs", + [ + ("{a}/path", dict(a=".")), + ("{a}/path", dict(a="..")), + ("/v1/{a}", dict(a=".")), + ("/v1/{a}", dict(a="..")), + ("/v1/{a}/path", dict(a=".")), + ("/v1/{a}/path", dict(a="..")), + ("/v1/{a}{b}", dict(a=".", b=".")), # adjacent vars → ".." + ("/v1/{a}.", dict(a=".")), # var + static → ".." + ("/v1/{a}{b}", dict(a="", b=".")), # empty + dot → "." + ("/v1/%2e/{x}", dict(x="ok")), # encoded dot in static text + ("/v1/%2e./{x}", dict(x="ok")), # mixed encoded ".." in static + ("/v1/.%2E/{x}", dict(x="ok")), # mixed encoded ".." in static + ("/v1/{v}?q=1", dict(v="..")), + ("/v1/{v}#frag", dict(v="..")), + ], +) +def test_dot_segment_rejected(template: str, kwargs: dict[str, Any]) -> None: + with pytest.raises(ValueError, match="dot-segment"): + path_template(template, **kwargs) diff --git a/tests/test_utils/test_proxy.py b/tests/test_utils/test_proxy.py new file mode 100644 index 000000000..21a15f981 --- /dev/null +++ b/tests/test_utils/test_proxy.py @@ -0,0 +1,34 @@ +import operator +from typing import Any +from typing_extensions import override + +from agentex._utils import LazyProxy + + +class RecursiveLazyProxy(LazyProxy[Any]): + @override + def __load__(self) -> Any: + return self + + def __call__(self, *_args: Any, **_kwds: Any) -> Any: + raise RuntimeError("This should never be called!") + + +def test_recursive_proxy() -> None: + proxy = RecursiveLazyProxy() + assert repr(proxy) == "RecursiveLazyProxy" + assert str(proxy) == "RecursiveLazyProxy" + assert dir(proxy) == [] + assert type(proxy).__name__ == "RecursiveLazyProxy" + assert type(operator.attrgetter("name.foo.bar.baz")(proxy)).__name__ == "RecursiveLazyProxy" + + +def test_isinstance_does_not_error() -> None: + class AlwaysErrorProxy(LazyProxy[Any]): + @override + def __load__(self) -> Any: + raise RuntimeError("Mocking missing dependency") + + proxy = AlwaysErrorProxy() + assert not isinstance(proxy, dict) + assert isinstance(proxy, LazyProxy) diff --git a/tests/test_utils/test_typing.py b/tests/test_utils/test_typing.py new file mode 100644 index 000000000..cd4f1800d --- /dev/null +++ b/tests/test_utils/test_typing.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from typing import Generic, TypeVar, cast + +from agentex._utils import extract_type_var_from_base + +_T = TypeVar("_T") +_T2 = TypeVar("_T2") +_T3 = TypeVar("_T3") + + +class BaseGeneric(Generic[_T]): ... + + +class SubclassGeneric(BaseGeneric[_T]): ... + + +class BaseGenericMultipleTypeArgs(Generic[_T, _T2, _T3]): ... + + +class SubclassGenericMultipleTypeArgs(BaseGenericMultipleTypeArgs[_T, _T2, _T3]): ... + + +class SubclassDifferentOrderGenericMultipleTypeArgs(BaseGenericMultipleTypeArgs[_T2, _T, _T3]): ... + + +def test_extract_type_var() -> None: + assert ( + extract_type_var_from_base( + BaseGeneric[int], + index=0, + generic_bases=cast("tuple[type, ...]", (BaseGeneric,)), + ) + == int + ) + + +def test_extract_type_var_generic_subclass() -> None: + assert ( + extract_type_var_from_base( + SubclassGeneric[int], + index=0, + generic_bases=cast("tuple[type, ...]", (BaseGeneric,)), + ) + == int + ) + + +def test_extract_type_var_multiple() -> None: + typ = BaseGenericMultipleTypeArgs[int, str, None] + + generic_bases = cast("tuple[type, ...]", (BaseGenericMultipleTypeArgs,)) + assert extract_type_var_from_base(typ, index=0, generic_bases=generic_bases) == int + assert extract_type_var_from_base(typ, index=1, generic_bases=generic_bases) == str + assert extract_type_var_from_base(typ, index=2, generic_bases=generic_bases) == type(None) + + +def test_extract_type_var_generic_subclass_multiple() -> None: + typ = SubclassGenericMultipleTypeArgs[int, str, None] + + generic_bases = cast("tuple[type, ...]", (BaseGenericMultipleTypeArgs,)) + assert extract_type_var_from_base(typ, index=0, generic_bases=generic_bases) == int + assert extract_type_var_from_base(typ, index=1, generic_bases=generic_bases) == str + assert extract_type_var_from_base(typ, index=2, generic_bases=generic_bases) == type(None) + + +def test_extract_type_var_generic_subclass_different_ordering_multiple() -> None: + typ = SubclassDifferentOrderGenericMultipleTypeArgs[int, str, None] + + generic_bases = cast("tuple[type, ...]", (BaseGenericMultipleTypeArgs,)) + assert extract_type_var_from_base(typ, index=0, generic_bases=generic_bases) == int + assert extract_type_var_from_base(typ, index=1, generic_bases=generic_bases) == str + assert extract_type_var_from_base(typ, index=2, generic_bases=generic_bases) == type(None) diff --git a/tests/test_version_guard.py b/tests/test_version_guard.py new file mode 100644 index 000000000..dba4a50e2 --- /dev/null +++ b/tests/test_version_guard.py @@ -0,0 +1,208 @@ +"""Unit tests for the runtime backend version guard (agentex.lib.core.compat.version_guard).""" + +from __future__ import annotations + +import asyncio + +import httpx +import pytest + +from agentex.lib.core.compat import version_guard as vg + + +def _run(coro): + return asyncio.run(coro) + + +def _patch_transport(monkeypatch, handler): + """Make version_guard's httpx.AsyncClient route through an in-memory MockTransport, + so fetch_backend_version runs for real (request build, status check, JSON parse) + without touching the network. `handler(request) -> httpx.Response` (or raises).""" + + real_client = httpx.AsyncClient # capture before patching to avoid recursing into the factory + + def factory(**kwargs): + kwargs.pop("transport", None) + return real_client(transport=httpx.MockTransport(handler), **kwargs) + + monkeypatch.setattr(vg.httpx, "AsyncClient", factory) + + +def test_parse_versions(): + assert vg._parse("0.2.1") == (0, 2, 1, None) + assert vg._parse("v1.4.0") == (1, 4, 0, None) + assert vg._parse("0.2.1-rc.1+build5") == (0, 2, 1, "rc.1") # build metadata ignored + assert vg._parse("0.1.0+build5") == (0, 1, 0, None) # build metadata only, still stable + assert vg._parse("garbage") is None + assert vg._parse(None) is None + + +def test_parse_rejects_malformed_tails(): + # Anchored regex: a junk tail after the triplet must NOT silently parse as stable 0.1.0; + # it has to fall through to None (→ unknown / unparseable path), not satisfy the floor. + for bad in ("0.1.0rc1", "0.1.0foo", "0.1.0.1", "0.1.0-", "1.2", "0.1.0-rc 1"): + assert vg._parse(bad) is None, bad + + +def test_parse_anchored_both_ends(): + # Leading anchor (^): anything before the triplet (other than whitespace / a `v`) is rejected. + for bad in ("foo0.1.0", ">=0.1.0", "x0.1.0", "=0.1.0", "0 0.1.0"): + assert vg._parse(bad) is None, bad + # Trailing anchor ($): anything after the version (other than whitespace) is rejected. + for bad in ("0.1.0 extra", "0.1.0;", "0.1.0/", "0.1.0+", "0.1.0 0.1.0"): + assert vg._parse(bad) is None, bad + # What the anchors DO permit: surrounding whitespace and an optional leading `v`. + assert vg._parse(" 0.1.0 ") == (0, 1, 0, None) + assert vg._parse("\tv1.2.3\n") == (1, 2, 3, None) + assert vg._parse(" 0.2.0-rc.1 ") == (0, 2, 0, "rc.1") + + +def test_prerelease_precedence(): + k = lambda v: vg._precedence_key(vg._parse(v)) # noqa: E731 + assert k("0.1.0-rc.1") < k("0.1.0") # prerelease precedes its stable release (SemVer §11) + assert k("0.1.0-rc.1") < k("0.1.0-rc.2") # numeric prerelease identifiers compare numerically + assert k("0.1.0-alpha") < k("0.1.0-rc") # numeric/alpha ordering by identifier + assert k("0.1.0") < k("0.1.1-rc.1") # patch bump outranks prior stable + assert k("0.2.0-rc.1") > k("0.1.0") # prerelease of a higher version still clears the floor + + +def test_compatible_backend_passes(monkeypatch): + async def fake(url, **kw): + return "0.2.0" + + monkeypatch.setattr(vg, "fetch_backend_version", fake) + # backend (0.2.0) >= min (0.1.0) → no raise + _run(vg.assert_backend_compatible("http://backend", min_version="0.1.0")) + + +def test_incompatible_backend_raises(monkeypatch): + async def fake(url, **kw): + return "0.0.9" + + monkeypatch.setattr(vg, "fetch_backend_version", fake) + with pytest.raises(vg.IncompatibleBackendError) as exc: + _run(vg.assert_backend_compatible("http://backend", min_version="0.1.0", sdk_version="0.13.0")) + msg = str(exc.value) + assert "0.13.0" in msg and "0.1.0" in msg and "0.0.9" in msg # actionable message + + +def test_prerelease_backend_below_stable_floor_raises(monkeypatch): + async def fake(url, **kw): + return "0.1.0-rc.1" # release candidate: precedes the stable 0.1.0 contract + + monkeypatch.setattr(vg, "fetch_backend_version", fake) + with pytest.raises(vg.IncompatibleBackendError): + _run(vg.assert_backend_compatible("http://backend", min_version="0.1.0", sdk_version="0.13.0")) + + +def test_skip_env_bypasses(monkeypatch): + async def fake(url, **kw): + raise AssertionError("must not fetch when skip env is set") + + monkeypatch.setattr(vg, "fetch_backend_version", fake) + monkeypatch.setenv(vg.SKIP_ENV, "1") + # even an impossible min must not raise when explicitly skipped + _run(vg.assert_backend_compatible("http://backend", min_version="9.9.9")) + + +def test_unknown_backend_version_does_not_crash(monkeypatch): + async def fake(url, **kw): + return None # unreachable / no version → unknown + + monkeypatch.setattr(vg, "fetch_backend_version", fake) + # unknown version warns but must not raise (transient/contract-less server) + _run(vg.assert_backend_compatible("http://backend", min_version="9.9.9")) + + +def test_no_base_url_is_noop(): + _run(vg.assert_backend_compatible(None)) + _run(vg.assert_backend_compatible("")) + + +def test_truthy(monkeypatch): + for val in ("1", "true", "True", "YES", "on"): + monkeypatch.setenv("X_GUARD_FLAG", val) + assert vg._truthy("X_GUARD_FLAG") + for val in ("0", "false", "no", "off", ""): + monkeypatch.setenv("X_GUARD_FLAG", val) + assert not vg._truthy("X_GUARD_FLAG") + monkeypatch.delenv("X_GUARD_FLAG", raising=False) + assert not vg._truthy("X_GUARD_FLAG") # unset → falsy + + +# --- fetch_backend_version: exercised for real through MockTransport (not mocked out) --- + + +def test_fetch_success_and_url_construction(monkeypatch): + seen = {} + + def handler(request): + seen["url"] = str(request.url) + seen["method"] = request.method + return httpx.Response(200, json={"openapi": "3.1.0", "info": {"version": "0.2.0"}}) + + _patch_transport(monkeypatch, handler) + assert _run(vg.fetch_backend_version("http://backend/")) == "0.2.0" + assert seen["url"] == "http://backend/openapi.json" # trailing slash trimmed, path appended + assert seen["method"] == "GET" + + +def test_fetch_missing_version_field(monkeypatch): + _patch_transport(monkeypatch, lambda r: httpx.Response(200, json={"info": {}})) + assert _run(vg.fetch_backend_version("http://backend")) is None + + +def test_fetch_missing_info_object(monkeypatch): + # `info` absent entirely, and `info: null` — both must coalesce to None, not crash. + _patch_transport(monkeypatch, lambda r: httpx.Response(200, json={})) + assert _run(vg.fetch_backend_version("http://backend")) is None + _patch_transport(monkeypatch, lambda r: httpx.Response(200, json={"info": None})) + assert _run(vg.fetch_backend_version("http://backend")) is None + + +def test_fetch_http_error_status(monkeypatch): + # raise_for_status() → caught → None (e.g. server has no /openapi.json) + _patch_transport(monkeypatch, lambda r: httpx.Response(404, text="not found")) + assert _run(vg.fetch_backend_version("http://backend")) is None + _patch_transport(monkeypatch, lambda r: httpx.Response(503, text="unavailable")) + assert _run(vg.fetch_backend_version("http://backend")) is None + + +def test_fetch_non_json_body(monkeypatch): + _patch_transport(monkeypatch, lambda r: httpx.Response(200, text="nope")) + assert _run(vg.fetch_backend_version("http://backend")) is None + + +def test_fetch_connection_error(monkeypatch): + def handler(request): + raise httpx.ConnectError("connection refused", request=request) + + _patch_transport(monkeypatch, handler) + assert _run(vg.fetch_backend_version("http://backend")) is None + + +# --- assert_backend_compatible end-to-end: real fetch through MockTransport, not mocked out --- + + +def test_assert_end_to_end_old_backend_raises(monkeypatch): + monkeypatch.delenv(vg.SKIP_ENV, raising=False) + _patch_transport(monkeypatch, lambda r: httpx.Response(200, json={"info": {"version": "0.0.9"}})) + with pytest.raises(vg.IncompatibleBackendError): + _run(vg.assert_backend_compatible("http://backend", min_version="0.1.0", sdk_version="0.13.0")) + + +def test_assert_end_to_end_new_backend_passes(monkeypatch): + monkeypatch.delenv(vg.SKIP_ENV, raising=False) + _patch_transport(monkeypatch, lambda r: httpx.Response(200, json={"info": {"version": "0.2.0"}})) + _run(vg.assert_backend_compatible("http://backend", min_version="0.1.0")) + + +def test_assert_end_to_end_unreachable_backend_does_not_raise(monkeypatch): + # real fetch returns None on connection failure → guard proceeds (no crash on transient blip) + monkeypatch.delenv(vg.SKIP_ENV, raising=False) + + def handler(request): + raise httpx.ConnectError("refused", request=request) + + _patch_transport(monkeypatch, handler) + _run(vg.assert_backend_compatible("http://backend", min_version="9.9.9")) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 000000000..f03ee4f3c --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import os +import inspect +import traceback +import contextlib +from typing import Any, TypeVar, Iterator, Sequence, cast +from datetime import date, datetime +from typing_extensions import Literal, get_args, get_origin, assert_type + +from agentex._types import Omit, NoneType +from agentex._utils import ( + is_dict, + is_list, + is_list_type, + is_union_type, + extract_type_arg, + is_sequence_type, + is_annotated_type, + is_type_alias_type, +) +from agentex._compat import PYDANTIC_V1, field_outer_type, get_model_fields +from agentex._models import BaseModel + +BaseModelT = TypeVar("BaseModelT", bound=BaseModel) + + +def assert_matches_model(model: type[BaseModelT], value: BaseModelT, *, path: list[str]) -> bool: + for name, field in get_model_fields(model).items(): + field_value = getattr(value, name) + if PYDANTIC_V1: + # in v1 nullability was structured differently + # https://docs.pydantic.dev/2.0/migration/#required-optional-and-nullable-fields + allow_none = getattr(field, "allow_none", False) + else: + allow_none = False + + assert_matches_type( + field_outer_type(field), + field_value, + path=[*path, name], + allow_none=allow_none, + ) + + return True + + +# Note: the `path` argument is only used to improve error messages when `--showlocals` is used +def assert_matches_type( + type_: Any, + value: object, + *, + path: list[str], + allow_none: bool = False, +) -> None: + if is_type_alias_type(type_): + type_ = type_.__value__ + + # unwrap `Annotated[T, ...]` -> `T` + if is_annotated_type(type_): + type_ = extract_type_arg(type_, 0) + + if allow_none and value is None: + return + + if type_ is None or type_ is NoneType: + assert value is None + return + + origin = get_origin(type_) or type_ + + if is_list_type(type_): + return _assert_list_type(type_, value) + + if is_sequence_type(type_): + assert isinstance(value, Sequence) + inner_type = get_args(type_)[0] + for entry in value: # type: ignore + assert_type(inner_type, entry) # type: ignore + return + + if origin == str: + assert isinstance(value, str) + elif origin == int: + assert isinstance(value, int) + elif origin == bool: + assert isinstance(value, bool) + elif origin == float: + assert isinstance(value, float) + elif origin == bytes: + assert isinstance(value, bytes) + elif origin == datetime: + assert isinstance(value, datetime) + elif origin == date: + assert isinstance(value, date) + elif origin == object: + # nothing to do here, the expected type is unknown + pass + elif origin == Literal: + assert value in get_args(type_) + elif origin == dict: + assert is_dict(value) + + args = get_args(type_) + key_type = args[0] + items_type = args[1] + + for key, item in value.items(): + assert_matches_type(key_type, key, path=[*path, ""]) + assert_matches_type(items_type, item, path=[*path, ""]) + elif is_union_type(type_): + variants = get_args(type_) + + try: + none_index = variants.index(type(None)) + except ValueError: + pass + else: + # special case Optional[T] for better error messages + if len(variants) == 2: + if value is None: + # valid + return + + return assert_matches_type(type_=variants[not none_index], value=value, path=path) + + for i, variant in enumerate(variants): + try: + assert_matches_type(variant, value, path=[*path, f"variant {i}"]) + return + except AssertionError: + traceback.print_exc() + continue + + raise AssertionError("Did not match any variants") + elif issubclass(origin, BaseModel): + assert isinstance(value, type_) + assert assert_matches_model(type_, cast(Any, value), path=path) + elif inspect.isclass(origin) and origin.__name__ == "HttpxBinaryResponseContent": + assert value.__class__.__name__ == "HttpxBinaryResponseContent" + else: + assert None, f"Unhandled field type: {type_}" + + +def _assert_list_type(type_: type[object], value: object) -> None: + assert is_list(value) + + inner_type = get_args(type_)[0] + for entry in value: + assert_type(inner_type, entry) # type: ignore + + +@contextlib.contextmanager +def update_env(**new_env: str | Omit) -> Iterator[None]: + old = os.environ.copy() + + try: + for name, value in new_env.items(): + if isinstance(value, Omit): + os.environ.pop(name, None) + else: + os.environ[name] = value + + yield None + finally: + os.environ.clear() + os.environ.update(old) diff --git a/uv.lock b/uv.lock new file mode 100644 index 000000000..6c79c4ace --- /dev/null +++ b/uv.lock @@ -0,0 +1,3692 @@ +version = 1 +revision = 3 +requires-python = ">=3.12, <4" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] + +[manifest] +members = [ + "agentex-client", + "agentex-sdk", +] + +[[package]] +name = "agentex-client" +version = "0.21.0" +source = { editable = "." } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] + +[package.optional-dependencies] +aiohttp = [ + { name = "aiohttp" }, + { name = "httpx-aiohttp" }, +] +dev = [ + { name = "ruff" }, +] + +[package.dev-dependencies] +dev = [ + { name = "debugpy" }, + { name = "dirty-equals" }, + { name = "importlib-metadata" }, + { name = "ipywidgets" }, + { name = "mypy" }, + { name = "nbstripout" }, + { name = "nest-asyncio" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-xdist" }, + { name = "respx" }, + { name = "rich" }, + { name = "ruff" }, + { name = "time-machine" }, + { name = "yaspin" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", marker = "extra == 'aiohttp'" }, + { name = "anyio", specifier = ">=3.5.0,<5" }, + { name = "distro", specifier = ">=1.7.0,<2" }, + { name = "httpx", specifier = ">=0.28.1,<0.29" }, + { name = "httpx-aiohttp", marker = "extra == 'aiohttp'", specifier = ">=0.1.9" }, + { name = "pydantic", specifier = ">=2.0.0,<3" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3.4" }, + { name = "sniffio" }, + { name = "typing-extensions", specifier = ">=4.14,<5" }, +] +provides-extras = ["aiohttp", "dev"] + +[package.metadata.requires-dev] +dev = [ + { name = "debugpy", specifier = ">=1.8.15" }, + { name = "dirty-equals", specifier = ">=0.6.0" }, + { name = "importlib-metadata", specifier = ">=6.7.0" }, + { name = "ipywidgets", specifier = ">=8.1.7" }, + { name = "mypy", specifier = "==1.17" }, + { name = "nbstripout", specifier = ">=0.8.1" }, + { name = "nest-asyncio", specifier = "==1.6.0" }, + { name = "pyright", specifier = "==1.1.399" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-xdist", specifier = ">=3.6.1" }, + { name = "respx" }, + { name = "rich", specifier = ">=13.7.1" }, + { name = "ruff" }, + { name = "time-machine" }, + { name = "yaspin", specifier = ">=3.1.0" }, +] + +[[package]] +name = "agentex-sdk" +version = "0.21.0" +source = { editable = "adk" } +dependencies = [ + { name = "agentex-client" }, + { name = "aiohttp" }, + { name = "claude-agent-sdk" }, + { name = "cloudpickle" }, + { name = "ddtrace" }, + { name = "fastapi" }, + { name = "jinja2" }, + { name = "json-log-formatter" }, + { name = "jsonref" }, + { name = "jsonschema" }, + { name = "kubernetes" }, + { name = "langgraph-checkpoint" }, + { name = "litellm" }, + { name = "mcp" }, + { name = "openai" }, + { name = "openai-agents" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "pydantic-ai-slim" }, + { name = "python-on-whales" }, + { name = "pyyaml" }, + { name = "questionary" }, + { name = "redis" }, + { name = "rich" }, + { name = "scale-gp" }, + { name = "scale-gp-beta" }, + { name = "starlette" }, + { name = "temporalio" }, + { name = "typer" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "yaspin" }, +] + +[package.metadata] +requires-dist = [ + { name = "agentex-client", editable = "." }, + { name = "aiohttp", specifier = ">=3.10.10,<4" }, + { name = "claude-agent-sdk", specifier = ">=0.1.0" }, + { name = "cloudpickle", specifier = ">=3.1.1" }, + { name = "ddtrace", specifier = ">=3.13.0" }, + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "jinja2", specifier = ">=3.1.3,<4" }, + { name = "json-log-formatter", specifier = ">=1.1.1" }, + { name = "jsonref", specifier = ">=1.1.0,<2" }, + { name = "jsonschema", specifier = ">=4.23.0,<5" }, + { name = "kubernetes", specifier = ">=25.0.0,<36.0.0" }, + { name = "langgraph-checkpoint", specifier = ">=2.0.0" }, + { name = "litellm", specifier = ">=1.83.7,<2" }, + { name = "mcp", specifier = ">=1.4.1" }, + { name = "openai", specifier = ">=2.2,<2.45" }, + { name = "openai-agents", specifier = ">=0.14.3,<0.15" }, + { name = "opentelemetry-api", specifier = ">=1.20.0" }, + { name = "opentelemetry-sdk", specifier = ">=1.20.0" }, + { name = "pydantic-ai-slim", specifier = ">=1.0,<2" }, + { name = "python-on-whales", specifier = ">=0.73.0,<0.74" }, + { name = "pyyaml", specifier = ">=6.0.2,<7" }, + { name = "questionary", specifier = ">=2.0.1,<3" }, + { name = "redis", specifier = ">=5.2.0,<8" }, + { name = "rich", specifier = ">=13.9.2,<14" }, + { name = "scale-gp", specifier = ">=0.1.0a59" }, + { name = "scale-gp-beta", specifier = ">=0.5.0" }, + { name = "starlette", specifier = ">=0.49.1" }, + { name = "temporalio", specifier = ">=1.26.0,<2" }, + { name = "typer", specifier = ">=0.16,<0.17" }, + { name = "uvicorn", specifier = ">=0.31.1" }, + { name = "watchfiles", specifier = ">=0.24.0,<1.0" }, + { name = "yaspin", specifier = ">=3.1.0" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, + { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, + { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, + { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, + { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, + { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, + { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, + { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, + { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, + { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, + { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, + { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, + { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, + { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, + { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, + { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, + { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, + { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "bytecode" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/c4/4818b392104bd426171fc2ce9c79c8edb4019ba6505747626d0f7107766c/bytecode-0.17.0.tar.gz", hash = "sha256:0c37efa5bd158b1b873f530cceea2c645611d55bd2dc2a4758b09f185749b6fd", size = 105863, upload-time = "2025-09-03T19:55:45.703Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/80/379e685099841f8501a19fb58b496512ef432331fed38276c3938ab09d8e/bytecode-0.17.0-py3-none-any.whl", hash = "sha256:64fb10cde1db7ef5cc39bd414ecebd54ba3b40e1c4cf8121ca5e72f170916ff8", size = 43045, upload-time = "2025-09-03T19:55:43.879Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "claude-agent-sdk" +version = "0.2.87" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "mcp" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/dc/e2afd59a1dd6484b6500245fa2331a0d8c0b68e6c180bc29d8ce9540f38a/claude_agent_sdk-0.2.87.tar.gz", hash = "sha256:56f02a49a97f7be37e0cd7323494d1c09e52fb0db7ab94f53bba8a230bb4bd0e", size = 252063, upload-time = "2026-05-23T04:19:25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/4e/b83c4c6ec1e0b63e9d4d58ba9a5abfd9936c55b8ee4c06b88f5e93bdfd70/claude_agent_sdk-0.2.87-py3-none-macosx_11_0_arm64.whl", hash = "sha256:52204a9609dec3aa96032afd48c07d72e05d13311faf614978f17b61326e6e31", size = 63037960, upload-time = "2026-05-23T04:19:29.056Z" }, + { url = "https://files.pythonhosted.org/packages/13/d7/5fb02260c5b95c66e108c35e046d4d66011921251f7896274b6b21594f14/claude_agent_sdk-0.2.87-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:1713e34e50b830ecac54386d39af14e3a2775f833f1ef715eb53566eaa1b6325", size = 65095745, upload-time = "2026-05-23T04:19:32.533Z" }, + { url = "https://files.pythonhosted.org/packages/1d/84/1061f6580bbbc78de629467abf051cdbbabe71b982297b401e3fde65c7e0/claude_agent_sdk-0.2.87-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:e9e23119d2a02ad1ea1a2707214db98f5baf2c8809577186629843ddfcb8ec18", size = 72725120, upload-time = "2026-05-23T04:19:36.539Z" }, + { url = "https://files.pythonhosted.org/packages/04/50/449f5044d76d9de18cf6a9f4b1c9386a74f41b4e2da5312df245d9dd23ef/claude_agent_sdk-0.2.87-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:5ac525d9ae3481296df5639d005e12ce2b6b0427426991f35da64db30be25c6e", size = 72875504, upload-time = "2026-05-23T04:19:40.839Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/3f9d7c491d5a98138d293192b31cc9ed792d3552b3a7e276163d7fe2d43a/claude_agent_sdk-0.2.87-py3-none-win_amd64.whl", hash = "sha256:f34973669a1efaeb1543e7b22d7b22feefd8af2fae3adfd39181635077dae432", size = 73514880, upload-time = "2026-05-23T04:19:44.65Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "cloudpickle" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, +] + +[[package]] +name = "ddtrace" +version = "4.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bytecode" }, + { name = "envier" }, + { name = "opentelemetry-api" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/4a/a2c6560cea93dfa9cd42b5d5ab3373f14cda901eb89a1bacf205a294db6e/ddtrace-4.10.1.tar.gz", hash = "sha256:b9951591fafa31296a108e19bda93043c1c73090be114f78f1543a66488ff4ec", size = 2336710, upload-time = "2026-06-01T17:54:13.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/e1/d2a1d706fc6ce6d23c8214895b429ece7fba8ea260c6616e76ae87ef628d/ddtrace-4.10.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78cf0f9ced6ffc13d93900ea8a8c1f186ea7031a775b1c4b0769b22b1979cae7", size = 6973760, upload-time = "2026-06-01T17:52:31.341Z" }, + { url = "https://files.pythonhosted.org/packages/75/44/2a11e1aab03cba07896974caad6b0de160bff28ebd666fd8ad6d513a8a2a/ddtrace-4.10.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3f4eca2d73f27eb6bd3bfcb8775aae342fbf39a0e2b697767a5e751328f2e362", size = 7298247, upload-time = "2026-06-01T17:52:33.815Z" }, + { url = "https://files.pythonhosted.org/packages/b7/be/7f978edfaf5c9ee5d80674ef1ffdf85da1bfeeb9d40a85f574094f36b31f/ddtrace-4.10.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:935a032b9333d3a9270bb8a49ea867143bf0426ace874345dbbf38eec2794c9c", size = 8398985, upload-time = "2026-06-01T17:52:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ab/12782aa4966f7cf26dd7e566c12e714c6692344afbefb9a929b3710bce26/ddtrace-4.10.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6e76874dcd00e78765289338b2e438e46f77864ad55c478ca6c46f0972eff60b", size = 8623502, upload-time = "2026-06-01T17:52:39.212Z" }, + { url = "https://files.pythonhosted.org/packages/ed/7e/848666e0b79be60083ba8f4600d867592c1ecc4312b04e3404e07705f5eb/ddtrace-4.10.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e33e50873d8840b0929598e936ff9e513763d0c02659144740239ca876956532", size = 9406765, upload-time = "2026-06-01T17:52:41.949Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/a4d294da6cc2b0ba151fd5670a47aa7cfe0b7853e966266f8aafb6f689c8/ddtrace-4.10.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d6d76f9604c4bbe92a7737feeea55c09c024035ce72d6c9a300b98da63684c8b", size = 9676840, upload-time = "2026-06-01T17:52:44.417Z" }, + { url = "https://files.pythonhosted.org/packages/93/0d/f2b4b2b2446be8ecf3134f39ab5bb1a4c5889b108f913047fc36e879391f/ddtrace-4.10.1-cp312-cp312-win32.whl", hash = "sha256:d437f2b810de02dd5fd8b69f47c85af88740bcda3718ebb0d6c232621c373aee", size = 5578605, upload-time = "2026-06-01T17:52:47.142Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ae/b2509da7658e6d8c4458227b7f0a3beebe52c174dab6b039b8d7522de851/ddtrace-4.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:6a420e1ed9258a8050c826b4c90343a313fb13dfc6deea1215c689834fba1e82", size = 6150473, upload-time = "2026-06-01T17:52:49.46Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7e/ab135d949d6f48bdb0fe15c64a134a71acc5fb0776fdc22a45dce9f43138/ddtrace-4.10.1-cp312-cp312-win_arm64.whl", hash = "sha256:c68474b8b8352dd76315e19b8bc8f2e895bfe1f4503b4aef56f952360877fb97", size = 5834288, upload-time = "2026-06-01T17:52:51.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/08/0a2465ec498f1416192a172ba08e035b49b0a6394027581607a86ec6f6c6/ddtrace-4.10.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:266f7bbea4cbaa7d2fd83d2c81319c7e96f9ddc8df7b175d5610a2ae91a308ae", size = 6966669, upload-time = "2026-06-01T17:52:54.249Z" }, + { url = "https://files.pythonhosted.org/packages/37/3a/82f14c16223e2da17c077ba7f37030c4f4ca33b9355294e960f63592209b/ddtrace-4.10.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:1562a89a6612c7d77b6dfb0fa688edf77839b0536927ac37b94185da6d20d922", size = 7291845, upload-time = "2026-06-01T17:52:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/99/08/0f248213ea94f4f3832330340d268368ce7e5b33cd9900234d9044405765/ddtrace-4.10.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b98834cf401356d93a3e8aaa0c5cef62bf9f5eb7ecda062573c3d7be6da9b9ca", size = 8394275, upload-time = "2026-06-01T17:52:59.529Z" }, + { url = "https://files.pythonhosted.org/packages/17/48/bba5f03ac0594a4803f2c4980269d1902b7579ff5d4fc8fd00044dc05d5b/ddtrace-4.10.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:de534b85973021a6e4e62f2be5657b93ad71bb46c1527c604bb8c2ad5e2905b1", size = 8615470, upload-time = "2026-06-01T17:53:02.444Z" }, + { url = "https://files.pythonhosted.org/packages/f7/fc/0d6d3a5640bd18b957396bceae9ec04667af036540592f6787dff54e295e/ddtrace-4.10.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8ca4b419f711e8420ce31940a533d220e48d6c184a3768f3ff328bcacbf378bf", size = 9403713, upload-time = "2026-06-01T17:53:05.364Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fe/893bdef6c0d8c4d5831e01201a41ac0d346d56f18c4f033822b00eb73bc1/ddtrace-4.10.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c5751220f69d90d084fcdb5033552ea0c1b81a8cfe44ced980407cc8a6ad502c", size = 9670193, upload-time = "2026-06-01T17:53:08.425Z" }, + { url = "https://files.pythonhosted.org/packages/b8/5c/a2ab89363394a3f192f169020dd090dac6a5be81e6b84db6b8b6e4e671f8/ddtrace-4.10.1-cp313-cp313-win32.whl", hash = "sha256:de31b005c6f0e83b2bfe60c1a26fa55ec5d9e9ea7608c6c7f06a7579ad414125", size = 5575654, upload-time = "2026-06-01T17:53:11.25Z" }, + { url = "https://files.pythonhosted.org/packages/09/74/12a38c4f5367687aaa72a4e76614826391659c9370fe2199ca0a7076da17/ddtrace-4.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:86eb0df8e2f888f08c2d0e7c9db4a6eb813f9546187ffaab85a5b3b1e9650b38", size = 6147671, upload-time = "2026-06-01T17:53:13.912Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/f060f07678e2515c9802024169f4ba1a640e1b28aef4b5a5fa7d5d3b76c8/ddtrace-4.10.1-cp313-cp313-win_arm64.whl", hash = "sha256:b9bcb591ac143e70df4b3732e2c0ec056a82b967a9b5a6adbf7f40266d5f9859", size = 5831355, upload-time = "2026-06-01T17:53:16.452Z" }, + { url = "https://files.pythonhosted.org/packages/3c/aa/c8513539633304304b9a3246b2c5c2f4af1ba2d437cfdb7d8c4df5e2bbf9/ddtrace-4.10.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:43d342b0293ed009ea6ede4800a9443b839326c55f61b242a3f6e86162991709", size = 6970858, upload-time = "2026-06-01T17:53:19.116Z" }, + { url = "https://files.pythonhosted.org/packages/fe/02/1b88c5ef89265cd439cc82cc035d93145d0c99913aafad9ecd7430708f3e/ddtrace-4.10.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:8e2e3435c8a647f89dd1f733ea45f677664df6800863eca1f3a75d370aeec3d6", size = 7295182, upload-time = "2026-06-01T17:53:22.044Z" }, + { url = "https://files.pythonhosted.org/packages/67/1a/1f9532c31c137d948c4c1f6bee5b358e16ede086b1a50dd8fbb62c1faa2e/ddtrace-4.10.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e27391bc80a657b3990683b4a555a1fbff266f9bf3eda90c2c23cc6fcc629c2", size = 8402723, upload-time = "2026-06-01T17:53:24.864Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a3/b3d6dd313779da8fd532574254a69a4241d6ddfe3ea6b2ec1b7760661ba0/ddtrace-4.10.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1b79b7c9bc43148ed77637f0a982ec4c7250b75b56ab24343edddd73b905c2c5", size = 8618621, upload-time = "2026-06-01T17:53:27.901Z" }, + { url = "https://files.pythonhosted.org/packages/b8/47/f947235329c79154adc98d2d86398f3eca626b395d59978b5bd649970646/ddtrace-4.10.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3dd12f7efecdd13f94f773868a5ee0baaa5635504bb862d2e9950658d17532a1", size = 9414716, upload-time = "2026-06-01T17:53:31.456Z" }, + { url = "https://files.pythonhosted.org/packages/6e/26/f06cf5039df5b02f514f83af8c0f00f74cb2ff563ecf398fcbd81f4311e7/ddtrace-4.10.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d056716160a8098be4d65386d1a6e99893c21c68b48d283bb0ccd8633ec48d78", size = 9676493, upload-time = "2026-06-01T17:53:34.853Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/f16d752c446ad3772c82a21eb5d91efe768f98de460b841461745d75aef0/ddtrace-4.10.1-cp314-cp314-win32.whl", hash = "sha256:6ee375cba898893c9c8c8f1dafcec486d112e7b16871abacf98ede792d0edde1", size = 5674042, upload-time = "2026-06-01T17:53:38.232Z" }, + { url = "https://files.pythonhosted.org/packages/18/d0/20d8184b3be09ce5caf60037684ea72cc75fa824e64ff9bbd6195656d7f2/ddtrace-4.10.1-cp314-cp314-win_amd64.whl", hash = "sha256:7b0a747eae14fc04d25db738f556dc24a12484182ad58b107bece4ff4abb468b", size = 6289030, upload-time = "2026-06-01T17:53:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4e/2d46162b66097f39dba17f9015a5d64e23ef9beb096394823438dfc3eb9d/ddtrace-4.10.1-cp314-cp314-win_arm64.whl", hash = "sha256:41069ce5232339ad85c5008bf7934bb716c78ba621426c1525b6ebb35c5aff2b", size = 5984250, upload-time = "2026-06-01T17:53:44.17Z" }, +] + +[[package]] +name = "debugpy" +version = "1.8.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/df/bf625547431a9cadc9f4cbfeda38866e2b17f6aed147b625377e87834449/debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e", size = 2483609, upload-time = "2026-06-01T19:30:50.794Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/59324b903599031ff9faaec1758292409f6561a0ec2492fe4b703327705a/debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176", size = 3968900, upload-time = "2026-06-01T19:30:52.341Z" }, + { url = "https://files.pythonhosted.org/packages/14/cd/27f65b805d7fe005c44e1a36b9183ecdfbcdbf9d3e721a5115d461ecc7ee/debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9", size = 5336340, upload-time = "2026-06-01T19:30:54.047Z" }, + { url = "https://files.pythonhosted.org/packages/77/1d/c84e30c0c674184948b66f076ab271c01d940618a2824c23cd035a27bc20/debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c", size = 5374751, upload-time = "2026-06-01T19:30:55.891Z" }, + { url = "https://files.pythonhosted.org/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88", size = 2477398, upload-time = "2026-06-01T19:30:57.644Z" }, + { url = "https://files.pythonhosted.org/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2", size = 3962096, upload-time = "2026-06-01T19:30:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1", size = 5336288, upload-time = "2026-06-01T19:31:00.79Z" }, + { url = "https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0", size = 5376567, upload-time = "2026-06-01T19:31:02.56Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" }, + { url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + +[[package]] +name = "dirty-equals" +version = "0.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/1d/c5913ac9d6615515a00f4bdc71356d302437cb74ff2e9aaccd3c14493b78/dirty_equals-0.11.tar.gz", hash = "sha256:f4ac74ee88f2d11e2fa0f65eb30ee4f07105c5f86f4dc92b09eb1138775027c3", size = 128067, upload-time = "2025-11-17T01:51:24.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/8d/dbff05239043271dbeace563a7686212a3dd517864a35623fe4d4a64ca19/dirty_equals-0.11-py3-none-any.whl", hash = "sha256:b1d7093273fc2f9be12f443a8ead954ef6daaf6746fd42ef3a5616433ee85286", size = 28051, upload-time = "2025-11-17T01:51:22.849Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "durationpy" +version = "0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335, upload-time = "2025-05-17T13:52:37.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, +] + +[[package]] +name = "envier" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/e7/4fe4d3f6e21213cea9bcddc36ba60e6ae4003035f9ce8055e6a9f0322ddb/envier-0.6.1.tar.gz", hash = "sha256:3309a01bb3d8850c9e7a31a5166d5a836846db2faecb79b9cb32654dd50ca9f9", size = 10063, upload-time = "2024-10-22T09:56:47.226Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/e9/30493b1cc967f7c07869de4b2ab3929151a58e6bb04495015554d24b61db/envier-0.6.1-py3-none-any.whl", hash = "sha256:73609040a76be48bbcb97074d9969666484aa0de706183a6e9ef773156a8a6a9", size = 10638, upload-time = "2024-10-22T09:56:45.968Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, +] + +[[package]] +name = "fastjsonschema" +version = "2.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, +] + +[[package]] +name = "fastuuid" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26", size = 18232, upload-time = "2025-10-19T22:19:22.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a", size = 510164, upload-time = "2025-10-19T22:31:45.635Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d", size = 261837, upload-time = "2025-10-19T22:38:38.53Z" }, + { url = "https://files.pythonhosted.org/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070", size = 251370, upload-time = "2025-10-19T22:40:26.07Z" }, + { url = "https://files.pythonhosted.org/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796", size = 277766, upload-time = "2025-10-19T22:37:23.779Z" }, + { url = "https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09", size = 278105, upload-time = "2025-10-19T22:26:56.821Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8", size = 301564, upload-time = "2025-10-19T22:30:31.604Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741", size = 459659, upload-time = "2025-10-19T22:31:32.341Z" }, + { url = "https://files.pythonhosted.org/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057", size = 478430, upload-time = "2025-10-19T22:26:22.962Z" }, + { url = "https://files.pythonhosted.org/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8", size = 450894, upload-time = "2025-10-19T22:27:01.647Z" }, + { url = "https://files.pythonhosted.org/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176", size = 154374, upload-time = "2025-10-19T22:29:19.879Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397", size = 156550, upload-time = "2025-10-19T22:27:49.658Z" }, + { url = "https://files.pythonhosted.org/packages/a5/83/ae12dd39b9a39b55d7f90abb8971f1a5f3c321fd72d5aa83f90dc67fe9ed/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021", size = 510720, upload-time = "2025-10-19T22:42:34.633Z" }, + { url = "https://files.pythonhosted.org/packages/53/b0/a4b03ff5d00f563cc7546b933c28cb3f2a07344b2aec5834e874f7d44143/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc", size = 262024, upload-time = "2025-10-19T22:30:25.482Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6d/64aee0a0f6a58eeabadd582e55d0d7d70258ffdd01d093b30c53d668303b/fastuuid-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5", size = 251679, upload-time = "2025-10-19T22:36:14.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/a7e9cda8369e4f7919d36552db9b2ae21db7915083bc6336f1b0082c8b2e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f", size = 277862, upload-time = "2025-10-19T22:36:23.302Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/8ce11827c783affffd5bd4d6378b28eb6cc6d2ddf41474006b8d62e7448e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87", size = 278278, upload-time = "2025-10-19T22:29:43.809Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/680fb6352d0bbade04036da46264a8001f74b7484e2fd1f4da9e3db1c666/fastuuid-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b", size = 301788, upload-time = "2025-10-19T22:36:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7c/2014b5785bd8ebdab04ec857635ebd84d5ee4950186a577db9eff0fb8ff6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022", size = 459819, upload-time = "2025-10-19T22:35:31.623Z" }, + { url = "https://files.pythonhosted.org/packages/01/d2/524d4ceeba9160e7a9bc2ea3e8f4ccf1ad78f3bde34090ca0c51f09a5e91/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995", size = 478546, upload-time = "2025-10-19T22:26:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/354d04951ce114bf4afc78e27a18cfbd6ee319ab1829c2d5fb5e94063ac6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab", size = 450921, upload-time = "2025-10-19T22:31:02.151Z" }, + { url = "https://files.pythonhosted.org/packages/fb/be/d7be8670151d16d88f15bb121c5b66cdb5ea6a0c2a362d0dcf30276ade53/fastuuid-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad", size = 154559, upload-time = "2025-10-19T22:36:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/22/1d/5573ef3624ceb7abf4a46073d3554e37191c868abc3aecd5289a72f9810a/fastuuid-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed", size = 156539, upload-time = "2025-10-19T22:33:35.898Z" }, + { url = "https://files.pythonhosted.org/packages/16/c9/8c7660d1fe3862e3f8acabd9be7fc9ad71eb270f1c65cce9a2b7a31329ab/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b852a870a61cfc26c884af205d502881a2e59cc07076b60ab4a951cc0c94d1ad", size = 510600, upload-time = "2025-10-19T22:43:44.17Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f4/a989c82f9a90d0ad995aa957b3e572ebef163c5299823b4027986f133dfb/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c7502d6f54cd08024c3ea9b3514e2d6f190feb2f46e6dbcd3747882264bb5f7b", size = 262069, upload-time = "2025-10-19T22:43:38.38Z" }, + { url = "https://files.pythonhosted.org/packages/da/6c/a1a24f73574ac995482b1326cf7ab41301af0fabaa3e37eeb6b3df00e6e2/fastuuid-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ca61b592120cf314cfd66e662a5b54a578c5a15b26305e1b8b618a6f22df714", size = 251543, upload-time = "2025-10-19T22:32:22.537Z" }, + { url = "https://files.pythonhosted.org/packages/1a/20/2a9b59185ba7a6c7b37808431477c2d739fcbdabbf63e00243e37bd6bf49/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa75b6657ec129d0abded3bec745e6f7ab642e6dba3a5272a68247e85f5f316f", size = 277798, upload-time = "2025-10-19T22:33:53.821Z" }, + { url = "https://files.pythonhosted.org/packages/ef/33/4105ca574f6ded0af6a797d39add041bcfb468a1255fbbe82fcb6f592da2/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8a0dfea3972200f72d4c7df02c8ac70bad1bb4c58d7e0ec1e6f341679073a7f", size = 278283, upload-time = "2025-10-19T22:29:02.812Z" }, + { url = "https://files.pythonhosted.org/packages/fe/8c/fca59f8e21c4deb013f574eae05723737ddb1d2937ce87cb2a5d20992dc3/fastuuid-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1bf539a7a95f35b419f9ad105d5a8a35036df35fdafae48fb2fd2e5f318f0d75", size = 301627, upload-time = "2025-10-19T22:35:54.985Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e2/f78c271b909c034d429218f2798ca4e89eeda7983f4257d7865976ddbb6c/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9a133bf9cc78fdbd1179cb58a59ad0100aa32d8675508150f3658814aeefeaa4", size = 459778, upload-time = "2025-10-19T22:28:00.999Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f0/5ff209d865897667a2ff3e7a572267a9ced8f7313919f6d6043aed8b1caa/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:f54d5b36c56a2d5e1a31e73b950b28a0d83eb0c37b91d10408875a5a29494bad", size = 478605, upload-time = "2025-10-19T22:36:21.764Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c8/2ce1c78f983a2c4987ea865d9516dbdfb141a120fd3abb977ae6f02ba7ca/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ec27778c6ca3393ef662e2762dba8af13f4ec1aaa32d08d77f71f2a70ae9feb8", size = 450837, upload-time = "2025-10-19T22:34:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/df/60/dad662ec9a33b4a5fe44f60699258da64172c39bd041da2994422cdc40fe/fastuuid-0.14.0-cp314-cp314-win32.whl", hash = "sha256:e23fc6a83f112de4be0cc1990e5b127c27663ae43f866353166f87df58e73d06", size = 154532, upload-time = "2025-10-19T22:35:18.217Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/da4db31001e854025ffd26bc9ba0740a9cbba2c3259695f7c5834908b336/fastuuid-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a", size = 156457, upload-time = "2025-10-19T22:33:44.579Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/8d/1c51c094345df128ca4a990d633fe1a0ff28726c9e6b3c41ba65087bba1d/fsspec-2026.4.0.tar.gz", hash = "sha256:301d8ac70ae90ef3ad05dcf94d6c3754a097f9b5fe4667d2787aa359ec7df7e4", size = 312760, upload-time = "2026-04-29T20:42:38.635Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl", hash = "sha256:11ef7bb35dab8a394fde6e608221d5cf3e8499401c249bebaeaad760a1a8dec2", size = 203402, upload-time = "2026-04-29T20:42:36.842Z" }, +] + +[[package]] +name = "genai-prices" +version = "0.0.62" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx2" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/8e/ed322d1f22b57fd455749bdbe2f285d310e1c1ebe921cb3d5c0b920de648/genai_prices-0.0.62.tar.gz", hash = "sha256:baf1ffa64be0d15577878216464d6a2d04244db5fbdf78d56bde43809e7aef44", size = 67611, upload-time = "2026-05-25T18:47:16.306Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/35/ce64112dcc6f406b3e290dcf57a97acfa2b7d3d0391979219cb9d4a9db6d/genai_prices-0.0.62-py3-none-any.whl", hash = "sha256:5d9ab0d9e5d81e035f88bf591fb6a8dde527922786acf1ee2737358f7bbe0167", size = 70333, upload-time = "2026-05-25T18:47:17.642Z" }, +] + +[[package]] +name = "griffelib" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/d8/5c06fc76461418326a7decf8367480c35be11a41fd938633929c60a9ec6b/hf_xet-1.5.0.tar.gz", hash = "sha256:e0fb0a34d9f406eed88233e829a67ec016bec5af19e480eac65a233ea289a948", size = 837196, upload-time = "2026-05-06T06:18:15.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/9b/6912c99070915a4f28119e3c5b52a9abd1eec0ad5cb293b8c967a0c6f5a2/hf_xet-1.5.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:7d70fe2ce97b9db73b9c9b9c81fe3693640aec83416a966c446afea54acfae3c", size = 4023383, upload-time = "2026-05-06T06:17:53.947Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6d/9563cfde59b5d8128a9c7ec972a087f4c782e4f7bac5a85234edfd5d5e49/hf_xet-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:73a0dae8c71de3b0633a45c73f4a4a5ed09e94b43441d82981a781d4f12baa42", size = 3792751, upload-time = "2026-05-06T06:17:51.791Z" }, + { url = "https://files.pythonhosted.org/packages/07/a5/ed5a0cf35b49a0571af5a8f53416dad1877a718c021c9937c3a53cb45781/hf_xet-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a60290ec57e9b71767fba7c3645ddafdd0759974b540441510c629c6db6db24a", size = 4456058, upload-time = "2026-05-06T06:17:40.735Z" }, + { url = "https://files.pythonhosted.org/packages/60/fb/3ae8bf2a7a37a4197d0195d7247fd25b3952e15cb8a599e285dfaa6f52b3/hf_xet-1.5.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e5de0f6deada0dada870bb376a11bcd1f08abf3a968a6d118f33e72d1b1eb480", size = 4250783, upload-time = "2026-05-06T06:17:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/8bae40d4d91525085137196e84eb0ed49cf65b5e96e5c3ecdadd8bd0fac2/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c799d49f1a5544a0ef7591c0ee75e0d6b93d6f56dc7a4979f59f7518d2872216", size = 4445594, upload-time = "2026-05-06T06:18:04.219Z" }, + { url = "https://files.pythonhosted.org/packages/13/59/c74efbbd4e8728172b2cc72a2bc014d2947a4b7bdced932fbd3f5da1a4e5/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2baea1b0b989e5c152fe81425f7745ddc8901280ba3d97c98d8cdece7b706c60", size = 4663995, upload-time = "2026-05-06T06:18:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/73/32/8e1e0410af64cda9b139d1dcebdc993a8ff9c8c7c0e2696ae356d75ccc0d/hf_xet-1.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:526345b3ed45f374f6317349df489167606736c876241ba984105afe7fd4839d", size = 3966608, upload-time = "2026-05-06T06:18:19.74Z" }, + { url = "https://files.pythonhosted.org/packages/fc/34/a8febc8f4edbea8b3e21b02ebc8b628679b84ba7e45cde624a7736b51500/hf_xet-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:786d28e2eb8315d5035544b9d137b4a842d600c434bb91bf7d0d953cce906ad4", size = 3796946, upload-time = "2026-05-06T06:18:17.568Z" }, + { url = "https://files.pythonhosted.org/packages/2a/20/8fc8996afe5815fa1a6be8e9e5c02f24500f409d599e905800d498a4e14d/hf_xet-1.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:872d5601e6deea30d15865ede55d29eac6daf5a534ab417b99b6ef6b076dd96c", size = 4023495, upload-time = "2026-05-06T06:18:01.94Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/93d84463c00cecb561a7508aa6303e35ee2894294eac14245526924415fe/hf_xet-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9929561f5abf4581c8ea79587881dfef6b8abb2a0d8a51915936fc2a614f4e73", size = 3792731, upload-time = "2026-05-06T06:18:00.021Z" }, + { url = "https://files.pythonhosted.org/packages/9d/5a/8ec8e0c863b382d00b3c2e2af6ded6b06371be617144a625903a6d562f4b/hf_xet-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f7b7bbae318e583a86fb21e5a4a175d6721d628a2874f4bd022d0e660c32a682", size = 4456738, upload-time = "2026-05-06T06:17:49.574Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ca/f7effa1a67717da2bcc6b6c28f71c6ca648c77acaec4e2c32f40cbe16d85/hf_xet-1.5.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cf7b2dc6f31a4ea754bb50f74cde482dcf5d366d184076d8530b9872787f3761", size = 4251622, upload-time = "2026-05-06T06:17:47.096Z" }, + { url = "https://files.pythonhosted.org/packages/65/f2/19247dba3e231cf77dec59ddfb878f00057635ff773d099c9b59d37812c3/hf_xet-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8dbcbab554c9ef158ef2c991545c3e970ddd8cc7acdcd0a78c5a41095dab4ded", size = 4445667, upload-time = "2026-05-06T06:18:11.983Z" }, + { url = "https://files.pythonhosted.org/packages/7f/64/6f116801a3bcfb6f59f5c251f48cadc47ea54026441c4a385079286a94fa/hf_xet-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5906bf7718d3636dc13402914736abe723492cb730f744834f5f5b67d3a12702", size = 4664619, upload-time = "2026-05-06T06:18:13.771Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e8/069542d37946ed08669b127e1496fa99e78196d71de8d41eda5e9f1b7a58/hf_xet-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5f3dc2248fc01cc0a00cd392ab497f1ca373fcbc7e3f2da1f452480b384e839e", size = 3966802, upload-time = "2026-05-06T06:18:28.162Z" }, + { url = "https://files.pythonhosted.org/packages/f9/91/fc6fdec27b14d04e88c386ac0a0129732b53fa23f7c4a78f4b83a039c567/hf_xet-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b285cea1b5bab46b758772716ba8d6854a1a0310fed1c249d678a8b38601e5a0", size = 3797168, upload-time = "2026-05-06T06:18:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fb/69ff198a82cae7eb1a69fb84d93b3a3e4816564d76817fe541ddc96874eb/hf_xet-1.5.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dad0dc84e941b8ba3c860659fe1fdc35c049d47cce293f003287757e971a8f56", size = 4030814, upload-time = "2026-05-06T06:17:57.933Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/edcc2b40162bef3ff78e14ab637e5f3b89243d6aee72f5949d3bb6a5af83/hf_xet-1.5.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fd6e5a9b0fdac4ed03ed45ef79254a655b1aaab514a02202617fbf643f5fdf7a", size = 3798444, upload-time = "2026-05-06T06:17:55.79Z" }, + { url = "https://files.pythonhosted.org/packages/49/4d/103f76b04310e5e57656696cc184690d20c466af0bca3ca88f8c8ea5d4f3/hf_xet-1.5.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3531b1823a0e6d77d80f9ed15ca0e00f0d115094f8ac033d5cae88f4564cc949", size = 4465986, upload-time = "2026-05-06T06:17:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a2/546f47f464737b3edbab6f8ddb57f2599b93d2cbb66f06abb475ccb48651/hf_xet-1.5.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9a0ee58cd18d5ea799f7ed11290bbccbe56bdd8b1d97ca74b9cc49a3945d7a3b", size = 4259865, upload-time = "2026-05-06T06:17:42.639Z" }, + { url = "https://files.pythonhosted.org/packages/95/7f/1be593c1f28613be2e196473481cd81bfc5910795e30a34e8f744f6cac4f/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e60df5a42e9bed8628b6416af2cba4cba57ae9f02de226a06b020d98e1aab18", size = 4459835, upload-time = "2026-05-06T06:18:08.026Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b2/703569fc881f3284487e68cda7b42179978480da3c438042a6bbbb4a671c/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4b35549ce62601b84da4ff9b24d970032ace3d4430f52d91bcbb26c901d6c690", size = 4672414, upload-time = "2026-05-06T06:18:09.864Z" }, + { url = "https://files.pythonhosted.org/packages/af/37/1b6def445c567286b50aa3b33828158e135b1be44938dde59f11382a500c/hf_xet-1.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:2806c7c17b4d23f8d88f7c4814f838c3b6150773fe339c20af23e1cfaf2797e4", size = 3977238, upload-time = "2026-05-06T06:18:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/62/94/3b66b148778ee100dcfd69c2ca22b57b41b44d3063ceec934f209e9184ce/hf_xet-1.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:b6c9df403040248c76d808d3e047d64db2d923bae593eb244c41e425cf6cd7be", size = 3806916, upload-time = "2026-05-06T06:18:21.7Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/34/18f1c596e677962f040284246f393b10a1f8ce440b3a7e69c637d0f1c7ad/httpcore2-2.3.0.tar.gz", hash = "sha256:07327e251560960eea8e969d92d4c6a325feb13cca39e25340731336c3baf924", size = 64300, upload-time = "2026-06-01T13:15:02.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl", hash = "sha256:477e9e334f74e5240dcac002e890580f36a57d40ff0fb14cc9655731d23b8415", size = 80024, upload-time = "2026-06-01T13:15:00.001Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-aiohttp" +version = "0.1.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/2c/b894861cecf030fb45675ea24aa55b5722e97c602a163d872fca66c5a6d8/httpx_aiohttp-0.1.12.tar.gz", hash = "sha256:81feec51fd82c0ecfa0e9aaf1b1a6c2591260d5e2bcbeb7eb0277a78e610df2c", size = 275945, upload-time = "2025-12-12T10:12:15.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/8d/85c9701e9af72ca132a1783e2a54364a90c6da832304416a30fc11196ab2/httpx_aiohttp-0.1.12-py3-none-any.whl", hash = "sha256:5b0eac39a7f360fa7867a60bcb46bb1024eada9c01cbfecdb54dc1edb3fb7141", size = 6367, upload-time = "2025-12-12T10:12:14.018Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "httpx2" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/9a/cca0b9145f13d8ae34b885ae28d403a1469a433abc78e0f94f4ce94e650b/httpx2-2.3.0.tar.gz", hash = "sha256:227e7c41d95a76d4077a52640564132777215fc3394e07b66a3116c33d668fa9", size = 81115, upload-time = "2026-06-01T13:15:04.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl", hash = "sha256:6f393663bdf6dbe7fe90118e3eb5b2bd024a675cae0390ac08cec9198812d8b7", size = 74538, upload-time = "2026-06-01T13:15:01.566Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/ff/ec7ed2eb43bd7ce8bb2233d109cc235c3e807ffe5e469dc09db261fac05e/huggingface_hub-1.13.0.tar.gz", hash = "sha256:f6df2dac5abe82ce2fe05873d10d5ff47bc677d616a2f521f4ee26db9415d9d0", size = 781788, upload-time = "2026-04-30T11:57:33.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/db/4b1cdae9460ae1f3ca020cd767f013430ce23eb1d9c890ae3a0609b38d26/huggingface_hub-1.13.0-py3-none-any.whl", hash = "sha256:e942cb50d6a08dd5306688b1ac05bda157fd2fcc88b63dae405f7bd0d3234005", size = 660643, upload-time = "2026-04-30T11:57:31.802Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "ipython" +version = "9.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "psutil", marker = "sys_platform != 'emscripten'" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/c2/c0064cf15d026501a1ef70e42efd9c3f818663089399aacc5e37a82901c1/ipython-9.14.0.tar.gz", hash = "sha256:6f27ff0f1d9ea050e0551f71568bc4b34d8aba579e8f111c5b4175f44ac6b4aa", size = 4432601, upload-time = "2026-05-29T15:13:24.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/a3/9e59340f02c1dc8f8c0a05b09244712b8609eb5439f9996e887e2b82f452/ipython-9.14.0-py3-none-any.whl", hash = "sha256:8fd984a3372c14b12790b084ba6b5cff5678c0cb063244a0034f06a51f20d6c2", size = 627457, upload-time = "2026-05-29T15:13:22.942Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "ipywidgets" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "comm" }, + { name = "ipython" }, + { name = "jupyterlab-widgets" }, + { name = "traitlets" }, + { name = "widgetsnbextension" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/ae/c5ce1edc1afe042eadb445e95b0671b03cee61895264357956e61c0d2ac0/ipywidgets-8.1.8.tar.gz", hash = "sha256:61f969306b95f85fba6b6986b7fe45d73124d1d9e3023a8068710d47a22ea668", size = 116739, upload-time = "2025-11-01T21:18:12.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl", hash = "sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e", size = 139808, upload-time = "2025-11-01T21:18:10.956Z" }, +] + +[[package]] +name = "jedi" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jiter" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/b5/55f06bb281d92fb3cc86d14e1def2bd908bb77693183e7cb1f5a3c388b0c/jiter-0.15.0.tar.gz", hash = "sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76", size = 166640, upload-time = "2026-05-19T10:09:48.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/53/4f6bddbcde3c71e56d0aa1337ec95950f3d27dd4153e25aadf0feac71751/jiter-0.15.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0e90a1c315a0226ec822d973817967f9223b7701546c8c2a7913e7ab0926294d", size = 308793, upload-time = "2026-05-19T10:07:35.25Z" }, + { url = "https://files.pythonhosted.org/packages/01/84/c01099b59a285a1ebba64ae93f62bfa036675340fd1b0045ae65890a0442/jiter-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8c9004af7c8d67cce7f1aae1026fb55607f4aa600710d08ede3a3ce4aeefe7e0", size = 309570, upload-time = "2026-05-19T10:07:36.919Z" }, + { url = "https://files.pythonhosted.org/packages/58/64/8fb7f9d45bb98190355454cd04dad8d8f27223d6bd52f83af07f637168a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c210f8b35dc6f30aafd4b4365ca89b9d1189f21ab49b8e68fa6322a847aef138", size = 336783, upload-time = "2026-05-19T10:07:38.694Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b6/f5739011d009b3a30f6a53c5240979030ba29ae46a8c67e3a15759f7c37d/jiter-0.15.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f30bae8bc1c2d613e28e5af3e8cceb09b742f1c8a8a5f839fb67afaffc03b61", size = 363555, upload-time = "2026-05-19T10:07:40.832Z" }, + { url = "https://files.pythonhosted.org/packages/e5/12/98a9d9f766665e8a3b6252454e17cb0c464606a28cf2fa09399b003345fa/jiter-0.15.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e71b6d10cfc284c9bf36bd885e8d44c46f688ce50aa91b5edd90181dea687", size = 452255, upload-time = "2026-05-19T10:07:42.62Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d5/60f972840f79c5e7544fce567c56f1e4e50468f996baba3e78d823dd62a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ab068bce62a45aa3e7367eceaffb5dde60b7eb853be8dece45132e3d0ff4879", size = 373559, upload-time = "2026-05-19T10:07:44.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cf/d46ef1234ba335aabc2f013210db8e0821a22f5e644a2e9449df199ecc23/jiter-0.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa248c9eb220197d363f688818dac2fd4b2f0cd7d843ca7105d652034823427d", size = 346055, upload-time = "2026-05-19T10:07:46.005Z" }, + { url = "https://files.pythonhosted.org/packages/f0/63/4d2749d8d54d230bad9b3a6b0d00cc28c6ff6b2fdffc26a8ccf76cc5a974/jiter-0.15.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2a77aadd57cac1682e4401a72724d2796d89a4ba129b1a5812aa94ee480826eb", size = 351406, upload-time = "2026-05-19T10:07:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b9/9965b990035d8773328e0a8c8b457a87bf2b19f6c4126d9d99296be5d16a/jiter-0.15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2ae901f3a55bfafdde31d289590fa25e3245735a2b1e8c7cc15871710a002871", size = 389357, upload-time = "2026-05-19T10:07:49.665Z" }, + { url = "https://files.pythonhosted.org/packages/2d/55/9ddf903deda1413e87fed792f416b7123daee5b8efbad6a202a7421c36a5/jiter-0.15.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f0b271b462769543716f92d3a4f90527df6ef5ed05ee95ec4137f513e21e1b77", size = 517263, upload-time = "2026-05-19T10:07:51.537Z" }, + { url = "https://files.pythonhosted.org/packages/e8/76/a0c40ad064d3a20a4fde231e35d56e9a01ce82164278180e82d5daf85469/jiter-0.15.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2fb6a5d26af81fc0f00f9360a891e05cf755e149bba391c4d563adc54812973d", size = 548646, upload-time = "2026-05-19T10:07:53.196Z" }, + { url = "https://files.pythonhosted.org/packages/23/4f/eca9b954942916ba2f453891b8593ab444cd872396fe66a3936616f236f3/jiter-0.15.0-cp312-cp312-win32.whl", hash = "sha256:c2f6bb8b5216ab9e7873bc08b5d7bef2b8abbb578a3069bf1cd14a45d71d771d", size = 206427, upload-time = "2026-05-19T10:07:55.307Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/8ead82a87495149542748e828d153fd232a512a22c83b02c4815c1a9c7d8/jiter-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:40b2c7e92c44a84d748d21706c68dc6ff8161d80b59c99d774721a0d2317d7c7", size = 197300, upload-time = "2026-05-19T10:07:56.651Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e4/9b8a78fb2d894471bc344e37f1949bdd784bd914d031dba0ba3a40c71dd7/jiter-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:cc0bc345cf2df9d1c00ac443f50d543c1ccfa8b0422cb85b1ab70d681c0b255b", size = 192702, upload-time = "2026-05-19T10:07:58.307Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f4/f708c900ecee41b2025ef8413d5351e5649eb2125c506f6720cc69b06f5c/jiter-0.15.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1c11465f97e2abf45a014b83b730222f8f1c5335e802c7055a67d50de6f1f4e3", size = 307829, upload-time = "2026-05-19T10:07:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/86/59/db537c0949e83668c38481d426b9f2fd5ab758c4ee53a811dd0a510626a0/jiter-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e7b1776f0797956c509e123d0952d10d293a9492dea9f288ab9570ec01d1a5", size = 308445, upload-time = "2026-05-19T10:08:01.184Z" }, + { url = "https://files.pythonhosted.org/packages/37/38/ea0e13b18c30ef951da0d47d39e7fa9edb82a93a62990ffbd7cea9b622d4/jiter-0.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:351a341c2105aa430b7047e30f1bf7975f6313b00165d3fc07be2edaf741f279", size = 336181, upload-time = "2026-05-19T10:08:02.688Z" }, + { url = "https://files.pythonhosted.org/packages/58/fc/2303901b16c4ba05865588990a420c0b4156270b44379c20931544a1d962/jiter-0.15.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ab395feec8d249ec4044e228e98a7033f043426a265df439dc3698823f0a4e4", size = 362985, upload-time = "2026-05-19T10:08:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/11bace093c52e7d4d26c8e606ccd7ae8c972189622469ec0d9e28161e28b/jiter-0.15.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2a438005b6f22d0273413484d6094d7c2c5d10ec1b3a3bf128e0d1d3ba53258", size = 453292, upload-time = "2026-05-19T10:08:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/987f2f086ca4d7a6582eb4ccd513f9b26b42d9e4243a087609a3137a8fc7/jiter-0.15.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f18f85e4218d1b40f000f42a92239a7a61a902cd42c65e6c360dbd17dcb20894", size = 373501, upload-time = "2026-05-19T10:08:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/89fbcabb2739b7a5b8dc959a1b6c5761f6484f5fed3486854b3c789bb1de/jiter-0.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1aa62e277fc1cbd80e6deacae6f4d983b41b3d7728e0645c5d741a6149bba45", size = 344683, upload-time = "2026-05-19T10:08:09.431Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/6cca7692e7dddfec6d8d76c54dc97f2af2a41df4ac0674b999df1f09a5f3/jiter-0.15.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:6550fa135c7deb8ead6af49ed7ff648532ea8334a1447fe34a36315ef79c5c29", size = 350892, upload-time = "2026-05-19T10:08:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/39/14/0338d6190cb8e6d22e677ab1d4eabd4117f67cca70c54cd04b82ff64e068/jiter-0.15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:066f8f33f18b2419cd8213b2436fa7fbc9c499f315971cfa3ce1f9820c001b1b", size = 388723, upload-time = "2026-05-19T10:08:12.912Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/cc19f4a1bdb6afb09ce6a2f2615aa8d44d994eba0d8e6105ed1af920e736/jiter-0.15.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:75e8a04e91432dde9f1838373cf93d23726c79d3e908d319acf0e796f85592e7", size = 516648, upload-time = "2026-05-19T10:08:14.808Z" }, + { url = "https://files.pythonhosted.org/packages/49/9f/833c541512cd091b63c10c0381973dfe11bc7a503a818c16384417e0c81e/jiter-0.15.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a97261f1fccb8e50ecd2890a96e46efdc3f57c80a197324c6777827231eca712", size = 547382, upload-time = "2026-05-19T10:08:16.927Z" }, + { url = "https://files.pythonhosted.org/packages/d2/11/e7b70e91f90bc4477e8eee9e8a5f7cf3cb41b4525d6394dc98a714eb8f7f/jiter-0.15.0-cp313-cp313-win32.whl", hash = "sha256:c77496cb10bd7549690fbbab3e5ec05857b83e49276f4a9423a766ddd2afcd4c", size = 205845, upload-time = "2026-05-19T10:08:18.401Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/5c20d9ad6f02c493e4023e5d2d09e1c1f15fe2753c9102c544aff068a88e/jiter-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b15741f501469009ae0ae90b7147958a664a7dede40aa7ff174a8a4645f546d0", size = 196842, upload-time = "2026-05-19T10:08:20.131Z" }, + { url = "https://files.pythonhosted.org/packages/6b/11/1eb400ef248e8c925fd883fbe325daf5e42cd1b0d308539dd332bd4f7ffc/jiter-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d6a60072b44c3c2b797a7ddcbcbbf2b34ea3cfd4721580fbfd2a09d9d9b84ba", size = 192212, upload-time = "2026-05-19T10:08:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/8a/60/2fd8d7c79da8acf9b7b277c7616847773779356b92acfc9bb158452174da/jiter-0.15.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ef1fd24d9413f6209e00d3d5a453e67acfe004a25cc6c8e8484faed4311ab9e8", size = 315065, upload-time = "2026-05-19T10:08:23.218Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/008fb7d65e8ac2abf00811651a661e025c4ba80bbc6f378450384ddd3aed/jiter-0.15.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:144f8e72cb53dab146347b91cceac01f5481237f2b93b4a339a1ee8f8878b67c", size = 339444, upload-time = "2026-05-19T10:08:24.701Z" }, + { url = "https://files.pythonhosted.org/packages/00/55/90b0c7b9c6896c0f2a591dd36d36b71d22e09674bfef178fa03ba3f81499/jiter-0.15.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553fcac2ef2cb990877f9fc0833b8b629a3e6a5670b6b5fd58219b41a653ddc4", size = 347779, upload-time = "2026-05-19T10:08:26.408Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/69666cec5000fd57734c118437394516c749ae8dbeea9fb66d6fef9c4775/jiter-0.15.0-cp313-cp313t-win_amd64.whl", hash = "sha256:774f93f65031856bf14ad9f59bdcab8b8cad501e5ceabd51ba3525f76937a25b", size = 200395, upload-time = "2026-05-19T10:08:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/39/04/a6aa62cd27e8149b0d28df5561f10f6cceaf7935a9ccf3f1c5a05f9a0cd8/jiter-0.15.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f1e1754960f38ec40613a07e5e372df67acb3b890fb383b6fb3de3e49ddbf3c7", size = 190516, upload-time = "2026-05-19T10:08:29.35Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/079f350ebf7859d081de30aa890f9e3be68516f754f3ba32366ffff4dcee/jiter-0.15.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ac0d9ddea4350974be7a221fc25895f251a8fee748c889bdced2141c0fec1a49", size = 308884, upload-time = "2026-05-19T10:08:31.667Z" }, + { url = "https://files.pythonhosted.org/packages/04/4e/a2c30a7f69b48c03b20935d647479106fe932f6e63f75faf53937197e05d/jiter-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01a8222cf05ab1128e239421156c207949808acaaea2bdfd33130ae666786e86", size = 310028, upload-time = "2026-05-19T10:08:33.304Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/2e7cdfd3cf8ca967be38c48f5cf474d79f089efaf559a40f15984a77ae69/jiter-0.15.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:182226cbc930c9fab81bc2e41a4da672f89539906dadb05e75670ac07b94f71f", size = 337485, upload-time = "2026-05-19T10:08:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/15a1aa28b120b8ee5b4f1fb894c125046225f09847738bd64233d3b84883/jiter-0.15.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:71683c38c825452999b5717fcae07ea708e8c93003e808be4319c1b02e3d176e", size = 364223, upload-time = "2026-05-19T10:08:36.694Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/f442e8af5f3d0dcf47b39e83a0efd9ee45ea946aa6d04625dc3181eae3b6/jiter-0.15.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30f2218e6a9e5c18bc10fe6d41ac189c442c88eacf11bad9f28ef95a9bef00e6", size = 456387, upload-time = "2026-05-19T10:08:38.143Z" }, + { url = "https://files.pythonhosted.org/packages/da/f4/37f2d2c9f64f49af7da652ed7532bb5a2372e588e6927c3fdd76f911db65/jiter-0.15.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5157de9f76eb4bc5ea74a1219366a25f945ad305641d74e04f59c54087091aa9", size = 374461, upload-time = "2026-05-19T10:08:39.869Z" }, + { url = "https://files.pythonhosted.org/packages/60/28/edcfbbbf0cb15436f36664a8908a0df47ab9006298d4cd937dc08ea932d6/jiter-0.15.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c5db5527c221249a876160663ab891ace358c17f7b9c93ec1478b7f0550e5c", size = 345924, upload-time = "2026-05-19T10:08:41.668Z" }, + { url = "https://files.pythonhosted.org/packages/47/13/89fba6398dab7f202b7278c4b4aac122399d2c0183971c4a57a3b7088df5/jiter-0.15.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:3e4540b8e74e4268811ac05db226a6a128ff572e7e0ce3f1163b693cadb184cd", size = 352283, upload-time = "2026-05-19T10:08:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/1b/da/0f6af8cef2c565a1ab44d970f268c43ccaa72707386ea6388e6fe2b6cd26/jiter-0.15.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:62ebd14e47e9aed9df4472afcb2663668ce4d74891cd54f86bf6e44029d6dc89", size = 389985, upload-time = "2026-05-19T10:08:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ec/b9cb7d6d29e24ee14910266157d2a279d7a8f60ee0df7fa840882976ba64/jiter-0.15.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0be6f5ad41a809f303f416d17cec92a7a725902fb9b4f3de3d19362ac0ef8554", size = 517695, upload-time = "2026-05-19T10:08:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/64/5e/6d1bda880723aae0ad86b4b763f044362448efe31e3e819635d41cb03451/jiter-0.15.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:813dfbb17d65328bf86e5f0905dd277ba2265d3ca20556e86c0c7035b7182e5a", size = 548868, upload-time = "2026-05-19T10:08:48.026Z" }, + { url = "https://files.pythonhosted.org/packages/0c/72/7de501cf38dcacaf35098796f3a50e0f2e338baba18a58946c618544b809/jiter-0.15.0-cp314-cp314-win32.whl", hash = "sha256:50e51156192722a9c58db112837d3f8ef96fb3c5ecc14e95f409134b08b158ec", size = 206380, upload-time = "2026-05-19T10:08:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/1e/a9/e19addf4b0c1bdce52c6da12351e6bc42c340c45e7c09e2158e46d293ccc/jiter-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:30ce1a5d16b5641dc935d50ef775af6a0871e3d14ab05d6fc54dff371b78e558", size = 197687, upload-time = "2026-05-19T10:08:51.088Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c9/776b1db01db25fc6c1d58d1979a37b0a9fe787e5f5b1d062d2eaacb77923/jiter-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:510c8b3c17a0ed9ac69850c0438dada3c9b82d9c4d589fcb62002a5a9cf3a866", size = 192571, upload-time = "2026-05-19T10:08:52.451Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f6/45bb4670bacf300fd2c7abadbfb3af376e5f1b6ae75fd9bc069891d15870/jiter-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7553333dd0930c104a5a0db8df72bf7219fe663d731383b576bb6ed6351c984d", size = 317151, upload-time = "2026-05-19T10:08:53.867Z" }, + { url = "https://files.pythonhosted.org/packages/d7/68/ed635ad5acd7b73e454283083bbb7c8205ad10e88b0d9d7d793b09fe8226/jiter-0.15.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2143ab06181d2b029eedcb6af3cebe95f11bbac62441781860f98ee9330a6a6", size = 341243, upload-time = "2026-05-19T10:08:55.383Z" }, + { url = "https://files.pythonhosted.org/packages/5d/db/3ff4176b817b8ea33879e71e13d8bc2b0d481a7ed3fe9e080f333d415c16/jiter-0.15.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eac374c5c975709b69c10f09afd199df74150172156ad10c8d4fd785b7da995", size = 363629, upload-time = "2026-05-19T10:08:56.928Z" }, + { url = "https://files.pythonhosted.org/packages/ab/24/5f8270e0ba9c883582f96f722f8a0b58015c7ce1f8c6d4571cf394e99b6b/jiter-0.15.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b3b3b775e33d3bfaec9899edc526ae97b0da0bf9d071a46124ba419149a414f8", size = 456198, upload-time = "2026-05-19T10:08:58.618Z" }, + { url = "https://files.pythonhosted.org/packages/45/5b/76fc02b0b5c54c3d18c60653156e2f76fde1816f9b4722db68d6ee2c897e/jiter-0.15.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3071db3346334beae1360b46da4606da57bf3528c167b3c38533afaf9f2c5", size = 373710, upload-time = "2026-05-19T10:09:00.151Z" }, + { url = "https://files.pythonhosted.org/packages/c4/52/4310821b0ea9277994d3e1f49fc6a4b34e4800caebacb2c0af81da59a454/jiter-0.15.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6694a173ecabc12eb60efbc0b474464ead1951ff65cd8b1e72100715c64512b", size = 349901, upload-time = "2026-05-19T10:09:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/93/fe/67648c35b3594fba8854ac64cc8a826d8bcd18324bbdb53d77697c60b6ef/jiter-0.15.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a254e10b593624d230c365b6d616b22ca0ad65e63a16e6631c2b3466022e6ba8", size = 352438, upload-time = "2026-05-19T10:09:03.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/28/0a1879d07ad6b3e025a2750027363452ced93c2d16d1c9d4b153ffd51c91/jiter-0.15.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d8d2955167274e15d79a7a020afdd9b39c990eb80b2d89fca695d92dcfdd38ec", size = 388152, upload-time = "2026-05-19T10:09:04.741Z" }, + { url = "https://files.pythonhosted.org/packages/c1/78/46c6f6b56ba85c90021f4afd72ed42f691f8f84daacb5fe27277070e3858/jiter-0.15.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:acf4ee4d1fc55917239fe72972fb292dd773055d05eb040d36f4326e02cc2c0e", size = 517707, upload-time = "2026-05-19T10:09:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/ca/cb/720662d4c88fcad606e826fef5424365527ba43ce4868a479aed8f8c507e/jiter-0.15.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e7196e56f1cd69af1dbb07dff02dcfb260a50b45a82d409d92a06fedb32473b5", size = 548241, upload-time = "2026-05-19T10:09:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/60/e3/935b8034fd143f21125c87d51404a9e0e1449186a494405721ff5d1d695e/jiter-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:7f6163c0f10b055245f814dcc59f4818da60dfe72f3e72ab89fc24b6bd5e9c52", size = 207950, upload-time = "2026-05-19T10:09:09.616Z" }, + { url = "https://files.pythonhosted.org/packages/93/59/984fd9ece895953dad3e0880a650e766f5a2da2c5514f0eafdaaabbeb5f9/jiter-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c256edb05b78a111b99c4de3b1d32e31634b867fd1fc2cf726e7b7bba9854", size = 200055, upload-time = "2026-05-19T10:09:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/cf8d779feb133a27a2e3bc833bccb9e13aa332cdf820497ebf72c10ce8c3/jiter-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:66b1880df2d01e206e8339769d1c7c1753bcb653efd6289e203f6f24ebada0c0", size = 191244, upload-time = "2026-05-19T10:09:12.74Z" }, + { url = "https://files.pythonhosted.org/packages/73/38/505941b2b092fd5bbbd60a52a880db1173f1690ae6751bed3af1c9ddcb4e/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:631f13a3d04e97d4e083993b10f4b99530e3a10d953e2eb5e196b7dc7f812ce0", size = 303769, upload-time = "2026-05-19T10:09:42.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/a06692b29e77473f286e1ec1f426d3ca44d7b5843be8ad21d7a5f3fcdcc0/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b6c0ffae686c39bf3737be60793783267628783ea42545632c10b291105aee45", size = 305128, upload-time = "2026-05-19T10:09:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/7270d7ad41d6061a25b950c6bf91d638bd9aacb113200a8c8d57a055fd67/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d54fb5b31dea401a41af3f8a7d2512e9b6a6a005491e6166c7e4ffab9639a9c", size = 340459, upload-time = "2026-05-19T10:09:45.452Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8d/302cb2057b7513327b4d575cff6b1d066ee6431a5357fc3f8867cd684406/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a", size = 344469, upload-time = "2026-05-19T10:09:46.864Z" }, +] + +[[package]] +name = "json-log-formatter" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/ef/324f4a28ed0152a32b80685b26316b604218e4ac77487ea82719c3c28bc6/json_log_formatter-1.1.1.tar.gz", hash = "sha256:0815e3b4469e5c79cf3f6dc8a0613ba6601f4a7464f85ba03655cfa6e3e17d10", size = 5896, upload-time = "2025-02-27T22:56:15.643Z" } + +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, +] + +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + +[[package]] +name = "jupyterlab-widgets" +version = "3.0.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/2d/ef58fed122b268c69c0aa099da20bc67657cdfb2e222688d5731bd5b971d/jupyterlab_widgets-3.0.16.tar.gz", hash = "sha256:423da05071d55cf27a9e602216d35a3a65a3e41cdf9c5d3b643b814ce38c19e0", size = 897423, upload-time = "2025-11-01T21:11:29.724Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8", size = 914926, upload-time = "2025-11-01T21:11:28.008Z" }, +] + +[[package]] +name = "kubernetes" +version = "35.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "durationpy" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "six" }, + { name = "urllib3" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/8f/85bf51ad4150f64e8c665daf0d9dfe9787ae92005efb9a4d1cba592bd79d/kubernetes-35.0.0.tar.gz", hash = "sha256:3d00d344944239821458b9efd484d6df9f011da367ecb155dadf9513f05f09ee", size = 1094642, upload-time = "2026-01-16T01:05:27.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/70/05b685ea2dffcb2adbf3cdcea5d8865b7bc66f67249084cf845012a0ff13/kubernetes-35.0.0-py2.py3-none-any.whl", hash = "sha256:39e2b33b46e5834ef6c3985ebfe2047ab39135d41de51ce7641a7ca5b372a13d", size = 2017602, upload-time = "2026-01-16T01:05:25.991Z" }, +] + +[[package]] +name = "langchain-core" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpatch" }, + { name = "langchain-protocol" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" }, +] + +[[package]] +name = "langchain-protocol" +version = "0.0.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/e7/8300ba22d968653051fd06e3117d783872dddf3dcebdd6b1d386836eb43c/langchain_protocol-0.0.16.tar.gz", hash = "sha256:806c7cdd951b1c4f692fa40fce60821ff0f221d4360e27673ddf2c2b99c2b7ff", size = 5969, upload-time = "2026-05-28T23:05:11.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/9c/06dfcc88d02a6364e8d864c421ddd3736305cb0a6c853f75c302c80fe17c/langchain_protocol-0.0.16-py3-none-any.whl", hash = "sha256:3658c142c5d0fb3a023a4be442ce4c15c6d626aab6135eb79a76dc64ad19c3c3", size = 7037, upload-time = "2026-05-28T23:05:10.163Z" }, +] + +[[package]] +name = "langgraph-checkpoint" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "ormsgpack" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/47/886af6f886f0bff2273164a45f008694e48a96ff3cd25ff0228f2aa9480e/langgraph_checkpoint-4.1.1.tar.gz", hash = "sha256:6c2bdb530c91f91d7d9c1bd100925d0fc4f498d418c17f3587d1526279482a25", size = 184020, upload-time = "2026-05-22T16:57:38.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl", hash = "sha256:25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e", size = 56212, upload-time = "2026-05-22T16:57:37.203Z" }, +] + +[[package]] +name = "langsmith" +version = "0.8.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "uuid-utils" }, + { name = "websockets" }, + { name = "xxhash" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/93/28df12b3b3c776077983b92f1299c623592b5999695af2a755fb90ff048b/langsmith-0.8.8.tar.gz", hash = "sha256:9d00e54f54d833c1914003527ff03ad0364741034330da72f0adbeaba852b6cf", size = 4468035, upload-time = "2026-05-31T22:14:57.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/71/94a8f2b573278a0b0b7dfd37663c0ddd36867f9e2bba69addd183de0cd56/langsmith-0.8.8-py3-none-any.whl", hash = "sha256:9d60d724c0d187c036e184b3ffdf9fa5c6822aa0bb88144a5fb898e79be645af", size = 402712, upload-time = "2026-05-31T22:14:55.908Z" }, +] + +[[package]] +name = "litellm" +version = "1.87.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "click" }, + { name = "fastuuid" }, + { name = "httpx" }, + { name = "importlib-metadata" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "tiktoken" }, + { name = "tokenizers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/0d/ccdf682ccfd7f18bf0e179c39d85616b8f8ef05a798588285310412db13d/litellm-1.87.0.tar.gz", hash = "sha256:cafc1882cb0cbab8374c41180af86e4a067796e4524e15f59e99f6e689cd1bd8", size = 15453755, upload-time = "2026-06-02T03:53:29.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/20/88a372fa7e50fc2c33458c6eef94a79afcf7bdfa43610079531b82b484a3/litellm-1.87.0-py3-none-any.whl", hash = "sha256:fbbba7e47ae29b55f878fe1acc80effb92761bc168f6236bd81a0cb6e147d855", size = 17103948, upload-time = "2026-06-02T03:53:25.677Z" }, +] + +[[package]] +name = "logfire-api" +version = "4.35.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/25/016b7d5e0433ae28d8a8bcb18681c48da2a0cdbf0ca8f7b2acdac2f16f4a/logfire_api-4.35.0.tar.gz", hash = "sha256:dcc073c7e337b0005f63075cf89951bacf00944b7c7420c2422b18133c8d2605", size = 83091, upload-time = "2026-06-02T14:55:58.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/3a/861f2040b251aa12b653b104c314b7d140cb44a6ab19cb141535aa72beb9/logfire_api-4.35.0-py3-none-any.whl", hash = "sha256:c8eb8f49c261c09b3d815b22ecba1c5224e8ba9aa9b546b0afcdb13a89fa6bfe", size = 131026, upload-time = "2026-06-02T14:55:55.252Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, +] + +[[package]] +name = "mcp" +version = "1.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/3c/347cf965d313f5d41764e7d46bea6ffe7d9ef13b983cc429b0340962a082/mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef", size = 621116, upload-time = "2026-05-29T17:16:04.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/11/252c6f971dc4f16af1d98a1c469d8ba523aab00d1bb76b4d3bc1ff32eacc/mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5", size = 220498, upload-time = "2026-05-29T17:16:02.442Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/9e/9f61ac18d9c8b475889f32ccfa91c9f59363480613fc807b6e3023d6f60b/multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184", size = 76877, upload-time = "2025-10-06T14:49:20.884Z" }, + { url = "https://files.pythonhosted.org/packages/38/6f/614f09a04e6184f8824268fce4bc925e9849edfa654ddd59f0b64508c595/multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45", size = 45467, upload-time = "2025-10-06T14:49:22.054Z" }, + { url = "https://files.pythonhosted.org/packages/b3/93/c4f67a436dd026f2e780c433277fff72be79152894d9fc36f44569cab1a6/multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa", size = 43834, upload-time = "2025-10-06T14:49:23.566Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f5/013798161ca665e4a422afbc5e2d9e4070142a9ff8905e482139cd09e4d0/multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7", size = 250545, upload-time = "2025-10-06T14:49:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/71/2f/91dbac13e0ba94669ea5119ba267c9a832f0cb65419aca75549fcf09a3dc/multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e", size = 258305, upload-time = "2025-10-06T14:49:26.778Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b0/754038b26f6e04488b48ac621f779c341338d78503fb45403755af2df477/multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546", size = 242363, upload-time = "2025-10-06T14:49:28.562Z" }, + { url = "https://files.pythonhosted.org/packages/87/15/9da40b9336a7c9fa606c4cf2ed80a649dffeb42b905d4f63a1d7eb17d746/multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4", size = 268375, upload-time = "2025-10-06T14:49:29.96Z" }, + { url = "https://files.pythonhosted.org/packages/82/72/c53fcade0cc94dfaad583105fd92b3a783af2091eddcb41a6d5a52474000/multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1", size = 269346, upload-time = "2025-10-06T14:49:31.404Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e2/9baffdae21a76f77ef8447f1a05a96ec4bc0a24dae08767abc0a2fe680b8/multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d", size = 256107, upload-time = "2025-10-06T14:49:32.974Z" }, + { url = "https://files.pythonhosted.org/packages/3c/06/3f06f611087dc60d65ef775f1fb5aca7c6d61c6db4990e7cda0cef9b1651/multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304", size = 253592, upload-time = "2025-10-06T14:49:34.52Z" }, + { url = "https://files.pythonhosted.org/packages/20/24/54e804ec7945b6023b340c412ce9c3f81e91b3bf5fa5ce65558740141bee/multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12", size = 251024, upload-time = "2025-10-06T14:49:35.956Z" }, + { url = "https://files.pythonhosted.org/packages/14/48/011cba467ea0b17ceb938315d219391d3e421dfd35928e5dbdc3f4ae76ef/multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62", size = 251484, upload-time = "2025-10-06T14:49:37.631Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2f/919258b43bb35b99fa127435cfb2d91798eb3a943396631ef43e3720dcf4/multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0", size = 263579, upload-time = "2025-10-06T14:49:39.502Z" }, + { url = "https://files.pythonhosted.org/packages/31/22/a0e884d86b5242b5a74cf08e876bdf299e413016b66e55511f7a804a366e/multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a", size = 259654, upload-time = "2025-10-06T14:49:41.32Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/17e10e1b5c5f5a40f2fcbb45953c9b215f8a4098003915e46a93f5fcaa8f/multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8", size = 251511, upload-time = "2025-10-06T14:49:46.021Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9a/201bb1e17e7af53139597069c375e7b0dcbd47594604f65c2d5359508566/multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4", size = 41895, upload-time = "2025-10-06T14:49:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/e2/348cd32faad84eaf1d20cce80e2bb0ef8d312c55bca1f7fa9865e7770aaf/multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b", size = 46073, upload-time = "2025-10-06T14:49:50.28Z" }, + { url = "https://files.pythonhosted.org/packages/25/ec/aad2613c1910dce907480e0c3aa306905830f25df2e54ccc9dea450cb5aa/multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec", size = 43226, upload-time = "2025-10-06T14:49:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135, upload-time = "2025-10-06T14:49:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117, upload-time = "2025-10-06T14:49:55.82Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472, upload-time = "2025-10-06T14:49:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342, upload-time = "2025-10-06T14:49:58.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082, upload-time = "2025-10-06T14:49:59.89Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704, upload-time = "2025-10-06T14:50:01.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355, upload-time = "2025-10-06T14:50:02.955Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259, upload-time = "2025-10-06T14:50:04.446Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903, upload-time = "2025-10-06T14:50:05.98Z" }, + { url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365, upload-time = "2025-10-06T14:50:07.511Z" }, + { url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062, upload-time = "2025-10-06T14:50:09.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683, upload-time = "2025-10-06T14:50:10.714Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload-time = "2025-10-06T14:50:12.28Z" }, + { url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload-time = "2025-10-06T14:50:14.16Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload-time = "2025-10-06T14:50:15.639Z" }, + { url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload-time = "2025-10-06T14:50:17.066Z" }, + { url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload-time = "2025-10-06T14:50:18.264Z" }, + { url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload-time = "2025-10-06T14:50:19.853Z" }, + { url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload-time = "2025-10-06T14:50:21.223Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload-time = "2025-10-06T14:50:22.871Z" }, + { url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload-time = "2025-10-06T14:50:24.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588, upload-time = "2025-10-06T14:50:25.716Z" }, + { url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966, upload-time = "2025-10-06T14:50:28.192Z" }, + { url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618, upload-time = "2025-10-06T14:50:29.82Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539, upload-time = "2025-10-06T14:50:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345, upload-time = "2025-10-06T14:50:33.26Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934, upload-time = "2025-10-06T14:50:34.808Z" }, + { url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243, upload-time = "2025-10-06T14:50:36.436Z" }, + { url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878, upload-time = "2025-10-06T14:50:37.953Z" }, + { url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452, upload-time = "2025-10-06T14:50:39.574Z" }, + { url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload-time = "2025-10-06T14:50:41.612Z" }, + { url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload-time = "2025-10-06T14:50:43.972Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload-time = "2025-10-06T14:50:45.648Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload-time = "2025-10-06T14:50:47.154Z" }, + { url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload-time = "2025-10-06T14:50:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload-time = "2025-10-06T14:50:50.16Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b1/3da6934455dd4b261d4c72f897e3a5728eba81db59959f3a639245891baa/multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", size = 75128, upload-time = "2025-10-06T14:50:51.92Z" }, + { url = "https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", size = 44410, upload-time = "2025-10-06T14:50:53.275Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", size = 43205, upload-time = "2025-10-06T14:50:54.911Z" }, + { url = "https://files.pythonhosted.org/packages/02/68/6b086fef8a3f1a8541b9236c594f0c9245617c29841f2e0395d979485cde/multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", size = 245084, upload-time = "2025-10-06T14:50:56.369Z" }, + { url = "https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", size = 252667, upload-time = "2025-10-06T14:50:57.991Z" }, + { url = "https://files.pythonhosted.org/packages/02/a5/eeb3f43ab45878f1895118c3ef157a480db58ede3f248e29b5354139c2c9/multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", size = 233590, upload-time = "2025-10-06T14:50:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/76d02f8270b97269d7e3dbd45644b1785bda457b474315f8cf999525a193/multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", size = 264112, upload-time = "2025-10-06T14:51:01.183Z" }, + { url = "https://files.pythonhosted.org/packages/76/0b/c28a70ecb58963847c2a8efe334904cd254812b10e535aefb3bcce513918/multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", size = 261194, upload-time = "2025-10-06T14:51:02.794Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", size = 248510, upload-time = "2025-10-06T14:51:04.724Z" }, + { url = "https://files.pythonhosted.org/packages/93/cd/06c1fa8282af1d1c46fd55c10a7930af652afdce43999501d4d68664170c/multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", size = 248395, upload-time = "2025-10-06T14:51:06.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/ac/82cb419dd6b04ccf9e7e61befc00c77614fc8134362488b553402ecd55ce/multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", size = 239520, upload-time = "2025-10-06T14:51:08.091Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f3/a0f9bf09493421bd8716a362e0cd1d244f5a6550f5beffdd6b47e885b331/multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7", size = 245479, upload-time = "2025-10-06T14:51:10.365Z" }, + { url = "https://files.pythonhosted.org/packages/8d/01/476d38fc73a212843f43c852b0eee266b6971f0e28329c2184a8df90c376/multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", size = 258903, upload-time = "2025-10-06T14:51:12.466Z" }, + { url = "https://files.pythonhosted.org/packages/49/6d/23faeb0868adba613b817d0e69c5f15531b24d462af8012c4f6de4fa8dc3/multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", size = 252333, upload-time = "2025-10-06T14:51:14.48Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/48d02ac22b30fa247f7dad82866e4b1015431092f4ba6ebc7e77596e0b18/multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", size = 243411, upload-time = "2025-10-06T14:51:16.072Z" }, + { url = "https://files.pythonhosted.org/packages/4a/03/29a8bf5a18abf1fe34535c88adbdfa88c9fb869b5a3b120692c64abe8284/multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", size = 40940, upload-time = "2025-10-06T14:51:17.544Z" }, + { url = "https://files.pythonhosted.org/packages/82/16/7ed27b680791b939de138f906d5cf2b4657b0d45ca6f5dd6236fdddafb1a/multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", size = 45087, upload-time = "2025-10-06T14:51:18.875Z" }, + { url = "https://files.pythonhosted.org/packages/cd/3c/e3e62eb35a1950292fe39315d3c89941e30a9d07d5d2df42965ab041da43/multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", size = 42368, upload-time = "2025-10-06T14:51:20.225Z" }, + { url = "https://files.pythonhosted.org/packages/8b/40/cd499bd0dbc5f1136726db3153042a735fffd0d77268e2ee20d5f33c010f/multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", size = 82326, upload-time = "2025-10-06T14:51:21.588Z" }, + { url = "https://files.pythonhosted.org/packages/13/8a/18e031eca251c8df76daf0288e6790561806e439f5ce99a170b4af30676b/multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", size = 48065, upload-time = "2025-10-06T14:51:22.93Z" }, + { url = "https://files.pythonhosted.org/packages/40/71/5e6701277470a87d234e433fb0a3a7deaf3bcd92566e421e7ae9776319de/multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", size = 46475, upload-time = "2025-10-06T14:51:24.352Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6a/bab00cbab6d9cfb57afe1663318f72ec28289ea03fd4e8236bb78429893a/multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", size = 239324, upload-time = "2025-10-06T14:51:25.822Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5f/8de95f629fc22a7769ade8b41028e3e5a822c1f8904f618d175945a81ad3/multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", size = 246877, upload-time = "2025-10-06T14:51:27.604Z" }, + { url = "https://files.pythonhosted.org/packages/23/b4/38881a960458f25b89e9f4a4fdcb02ac101cfa710190db6e5528841e67de/multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", size = 225824, upload-time = "2025-10-06T14:51:29.664Z" }, + { url = "https://files.pythonhosted.org/packages/1e/39/6566210c83f8a261575f18e7144736059f0c460b362e96e9cf797a24b8e7/multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", size = 253558, upload-time = "2025-10-06T14:51:31.684Z" }, + { url = "https://files.pythonhosted.org/packages/00/a3/67f18315100f64c269f46e6c0319fa87ba68f0f64f2b8e7fd7c72b913a0b/multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", size = 252339, upload-time = "2025-10-06T14:51:33.699Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2a/1cb77266afee2458d82f50da41beba02159b1d6b1f7973afc9a1cad1499b/multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", size = 244895, upload-time = "2025-10-06T14:51:36.189Z" }, + { url = "https://files.pythonhosted.org/packages/dd/72/09fa7dd487f119b2eb9524946ddd36e2067c08510576d43ff68469563b3b/multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", size = 241862, upload-time = "2025-10-06T14:51:41.291Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/bc1f8bd0853d8669300f732c801974dfc3702c3eeadae2f60cef54dc69d7/multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", size = 232376, upload-time = "2025-10-06T14:51:43.55Z" }, + { url = "https://files.pythonhosted.org/packages/09/86/ac39399e5cb9d0c2ac8ef6e10a768e4d3bc933ac808d49c41f9dc23337eb/multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", size = 240272, upload-time = "2025-10-06T14:51:45.265Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b6/fed5ac6b8563ec72df6cb1ea8dac6d17f0a4a1f65045f66b6d3bf1497c02/multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", size = 248774, upload-time = "2025-10-06T14:51:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8d/b954d8c0dc132b68f760aefd45870978deec6818897389dace00fcde32ff/multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", size = 242731, upload-time = "2025-10-06T14:51:48.541Z" }, + { url = "https://files.pythonhosted.org/packages/16/9d/a2dac7009125d3540c2f54e194829ea18ac53716c61b655d8ed300120b0f/multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", size = 240193, upload-time = "2025-10-06T14:51:50.355Z" }, + { url = "https://files.pythonhosted.org/packages/39/ca/c05f144128ea232ae2178b008d5011d4e2cea86e4ee8c85c2631b1b94802/multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", size = 48023, upload-time = "2025-10-06T14:51:51.883Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8f/0a60e501584145588be1af5cc829265701ba3c35a64aec8e07cbb71d39bb/multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", size = 53507, upload-time = "2025-10-06T14:51:53.672Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/3148b988a9c6239903e786eac19c889fab607c31d6efa7fb2147e5680f23/multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", size = 44804, upload-time = "2025-10-06T14:51:55.415Z" }, + { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, +] + +[[package]] +name = "mypy" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/e3/034322d5a779685218ed69286c32faa505247f1f096251ef66c8fd203b08/mypy-1.17.0.tar.gz", hash = "sha256:e5d7ccc08ba089c06e2f5629c660388ef1fee708444f1dee0b9203fa031dee03", size = 3352114, upload-time = "2025-07-14T20:34:30.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/e9/e6824ed620bbf51d3bf4d6cbbe4953e83eaf31a448d1b3cfb3620ccb641c/mypy-1.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f986f1cab8dbec39ba6e0eaa42d4d3ac6686516a5d3dccd64be095db05ebc6bb", size = 11086395, upload-time = "2025-07-14T20:34:11.452Z" }, + { url = "https://files.pythonhosted.org/packages/ba/51/a4afd1ae279707953be175d303f04a5a7bd7e28dc62463ad29c1c857927e/mypy-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:51e455a54d199dd6e931cd7ea987d061c2afbaf0960f7f66deef47c90d1b304d", size = 10120052, upload-time = "2025-07-14T20:33:09.897Z" }, + { url = "https://files.pythonhosted.org/packages/8a/71/19adfeac926ba8205f1d1466d0d360d07b46486bf64360c54cb5a2bd86a8/mypy-1.17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3204d773bab5ff4ebbd1f8efa11b498027cd57017c003ae970f310e5b96be8d8", size = 11861806, upload-time = "2025-07-14T20:32:16.028Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/d6120eca3835baf7179e6797a0b61d6c47e0bc2324b1f6819d8428d5b9ba/mypy-1.17.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1051df7ec0886fa246a530ae917c473491e9a0ba6938cfd0ec2abc1076495c3e", size = 12744371, upload-time = "2025-07-14T20:33:33.503Z" }, + { url = "https://files.pythonhosted.org/packages/1f/dc/56f53b5255a166f5bd0f137eed960e5065f2744509dfe69474ff0ba772a5/mypy-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f773c6d14dcc108a5b141b4456b0871df638eb411a89cd1c0c001fc4a9d08fc8", size = 12914558, upload-time = "2025-07-14T20:33:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/69/ac/070bad311171badc9add2910e7f89271695a25c136de24bbafc7eded56d5/mypy-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:1619a485fd0e9c959b943c7b519ed26b712de3002d7de43154a489a2d0fd817d", size = 9585447, upload-time = "2025-07-14T20:32:20.594Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/5f8ab461369b9e62157072156935cec9d272196556bdc7c2ff5f4c7c0f9b/mypy-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c41aa59211e49d717d92b3bb1238c06d387c9325d3122085113c79118bebb06", size = 11070019, upload-time = "2025-07-14T20:32:07.99Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/c49c9e5a2ac0badcc54beb24e774d2499748302c9568f7f09e8730e953fa/mypy-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e69db1fb65b3114f98c753e3930a00514f5b68794ba80590eb02090d54a5d4a", size = 10114457, upload-time = "2025-07-14T20:33:47.285Z" }, + { url = "https://files.pythonhosted.org/packages/89/0c/fb3f9c939ad9beed3e328008b3fb90b20fda2cddc0f7e4c20dbefefc3b33/mypy-1.17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03ba330b76710f83d6ac500053f7727270b6b8553b0423348ffb3af6f2f7b889", size = 11857838, upload-time = "2025-07-14T20:33:14.462Z" }, + { url = "https://files.pythonhosted.org/packages/4c/66/85607ab5137d65e4f54d9797b77d5a038ef34f714929cf8ad30b03f628df/mypy-1.17.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:037bc0f0b124ce46bfde955c647f3e395c6174476a968c0f22c95a8d2f589bba", size = 12731358, upload-time = "2025-07-14T20:32:25.579Z" }, + { url = "https://files.pythonhosted.org/packages/73/d0/341dbbfb35ce53d01f8f2969facbb66486cee9804048bf6c01b048127501/mypy-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c38876106cb6132259683632b287238858bd58de267d80defb6f418e9ee50658", size = 12917480, upload-time = "2025-07-14T20:34:21.868Z" }, + { url = "https://files.pythonhosted.org/packages/64/63/70c8b7dbfc520089ac48d01367a97e8acd734f65bd07813081f508a8c94c/mypy-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:d30ba01c0f151998f367506fab31c2ac4527e6a7b2690107c7a7f9e3cb419a9c", size = 9589666, upload-time = "2025-07-14T20:34:16.841Z" }, + { url = "https://files.pythonhosted.org/packages/e3/fc/ee058cc4316f219078464555873e99d170bde1d9569abd833300dbeb484a/mypy-1.17.0-py3-none-any.whl", hash = "sha256:15d9d0018237ab058e5de3d8fce61b6fa72cc59cc78fd91f1b474bce12abf496", size = 2283195, upload-time = "2025-07-14T20:31:54.753Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, +] + +[[package]] +name = "nbstripout" +version = "0.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nbformat" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/6f/b52c4da26babeb521078c08c78c3187a59197098ffc7a70b0fe76851813a/nbstripout-0.9.1.tar.gz", hash = "sha256:313bbb4217c8e38998567e5d790b6bd6c3a17a8c39073b205b84dadfc5d756dc", size = 32356, upload-time = "2026-02-21T16:19:55.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/16/e777eadfa0c0305878c36fae1d5e6db474fbb15dae202b9ec378809dfb4d/nbstripout-0.9.1-py3-none-any.whl", hash = "sha256:ca027ee45742ee77e4f8e9080254f9a707f1161ba11367b82fdf4a29892c759e", size = 19136, upload-time = "2026-02-21T16:19:54.868Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "nexus-rpc" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/d5/cd1ffb202b76ebc1b33c1332a3416e55a39929006982adc2b1eb069aaa9b/nexus_rpc-1.4.0.tar.gz", hash = "sha256:3b8b373d4865671789cc43623e3dc0bcbf192562e40e13727e17f1c149050fba", size = 82367, upload-time = "2026-02-25T22:01:34.053Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/52/6327a5f4fda01207205038a106a99848a41c83e933cd23ea2cab3d2ebc6c/nexus_rpc-1.4.0-py3-none-any.whl", hash = "sha256:14c953d3519113f8ccec533a9efdb6b10c28afef75d11cdd6d422640c40b3a49", size = 29645, upload-time = "2026-02-25T22:01:33.122Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + +[[package]] +name = "openai" +version = "2.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/9f/136562ec6c3b1a50fe06eb0bb34ed21f0d7426ec0140e5cc43ac785b69a5/openai-2.40.0.tar.gz", hash = "sha256:9a756f91f274a24ad6026cbcb2042fd356c8d4a10e8f347b08d34465e585f7a2", size = 781177, upload-time = "2026-06-01T21:48:23.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/46/180e14be801a75bc13f234cb1b594b232adeb9c84e60a9ab1832e8333591/openai-2.40.0-py3-none-any.whl", hash = "sha256:2b205637ff214477f9ce9ab035e9f494db0e3fa8f1e599008953735fbf6ff1ff", size = 1350935, upload-time = "2026-06-01T21:48:21.462Z" }, +] + +[[package]] +name = "openai-agents" +version = "0.14.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mcp" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "types-requests" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/8a/d36ab647f05e790ec97dda9e4c0eb39d8840269d6a5194887b5dec92bd0d/openai_agents-0.14.8.tar.gz", hash = "sha256:fe1cb58b4150a07292a94f15d8fd5217ee9195bd6bcd8a6a46fdb1d9b08a70b7", size = 5314520, upload-time = "2026-04-29T03:40:07.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/6e/1e9adcedcde7b163579b88a68f765a4915be4ead0713270386d9432cfd2f/openai_agents-0.14.8-py3-none-any.whl", hash = "sha256:2937ef582ccaa45d59e89839ed8948cb2a6d808bc9940f0881793c21f37f7776", size = 817332, upload-time = "2026-04-29T03:40:05.68Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + +[[package]] +name = "ormsgpack" +version = "1.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" }, + { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" }, + { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" }, + { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" }, + { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" }, + { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" }, + { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "parso" +version = "0.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/b2/bb8e495d5262bfec41ab5cb18f522f1012933347fb5d9e62452d446baca2/pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", size = 130841, upload-time = "2026-01-09T15:46:46.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-ai-slim" +version = "1.105.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "genai-prices" }, + { name = "griffelib" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pydantic-graph" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/ae/1b0370f9b9f1ca7ccf2e6b51ec5a8d11da11d9dd621e5eb015c6420c5e9b/pydantic_ai_slim-1.105.0.tar.gz", hash = "sha256:8b4ad8034b40ab3bde8e0c6285082a204ecd203007150a47943f192b474e06e9", size = 772048, upload-time = "2026-06-02T06:20:01.522Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/6e/8afdff693d21c0743ee71d792ce90afc27d4ddbaf7270d969a84452cfd0d/pydantic_ai_slim-1.105.0-py3-none-any.whl", hash = "sha256:1e65561ba9a58a9d8fc3a63b550c3c2b2c4017da275dea78291e526aa06298d8", size = 956108, upload-time = "2026-06-02T06:19:52.821Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "pydantic-graph" +version = "1.105.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "logfire-api" }, + { name = "pydantic" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/98/0361e1eb28f8d107e4e12dcd2d14eabef55f4a8ca18b1a6f185df74934c0/pydantic_graph-1.105.0.tar.gz", hash = "sha256:3f5cf97d544b900098d3cc2dbd6a8cdd79ea59dac610d7651f86c9228d33c0b9", size = 62570, upload-time = "2026-06-02T06:20:05.158Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/1b/13882fd4d70299dc2995bee20f21599cb8d453b27f44e239f82384d4ea3f/pydantic_graph-1.105.0-py3-none-any.whl", hash = "sha256:ba76d77ad21a13f2961fbda9d988f3d5a3d9ffc1817ee912e0ea59b0b5a9e825", size = 80099, upload-time = "2026-06-02T06:19:57.098Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyright" +version = "1.1.399" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/9d/d91d5f6d26b2db95476fefc772e2b9a16d54c6bd0ea6bb5c1b6d635ab8b4/pyright-1.1.399.tar.gz", hash = "sha256:439035d707a36c3d1b443aec980bc37053fbda88158eded24b8eedcf1c7b7a1b", size = 3856954, upload-time = "2025-04-10T04:40:25.703Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/b5/380380c9e7a534cb1783c70c3e8ac6d1193c599650a55838d0557586796e/pyright-1.1.399-py3-none-any.whl", hash = "sha256:55f9a875ddf23c9698f24208c764465ffdfd38be6265f7faf9a176e1dc549f3b", size = 5592584, upload-time = "2025-04-10T04:40:23.502Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/82/c8cd43a6e0719bf5a3b034f6726dd701f75829c08944c83d4b95d02ed0e8/python_multipart-0.0.30.tar.gz", hash = "sha256:0edfe0475c1f46ddd3ff7785a626f6118af32bdcf359bb21260367313bb32118", size = 46316, upload-time = "2026-05-31T19:24:55.198Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/fd/0318007beb234790993d3ec5afd051d1dbceb733e81e3afe2b981ece3f37/python_multipart-0.0.30-py3-none-any.whl", hash = "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b", size = 29730, upload-time = "2026-05-31T19:24:53.814Z" }, +] + +[[package]] +name = "python-on-whales" +version = "0.73.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "requests" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/c3/f57dd3e7d20af8a0399bb87471eac4698e0686b04073eef4bc291204a709/python_on_whales-0.73.0.tar.gz", hash = "sha256:c76bf3633550e5c948fb4215918364f45efaddb2e09df5ddd169132f7ffdc249", size = 112019, upload-time = "2024-09-06T10:23:12.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/e9/ea125eb8954f64e76485aec5c63ca6a5b977e0127a5f3896993f1692166e/python_on_whales-0.73.0-py3-none-any.whl", hash = "sha256:66f31749c2544a0aacb4e3ba03772c2e9227235ea1aecd58aa7a4cdcf26f559a", size = 118125, upload-time = "2024-09-06T10:23:10.856Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + +[[package]] +name = "redis" +version = "7.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "regex" +version = "2026.5.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/9b/6550044bc44e17c84d312c031c2ec42fbdb6a4ec4e29093be3a172d08772/regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06", size = 490451, upload-time = "2026-05-09T23:12:34.72Z" }, + { url = "https://files.pythonhosted.org/packages/1e/95/fc7ba4303b5a0f92446a12ee6778ef2c6c799233f5060042a31bf390cfe9/regex-2026.5.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6", size = 292112, upload-time = "2026-05-09T23:12:36.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/4b/ee27938d1b2c443e89a9a10e00d2d19aa5ee300cd3d61140644e93bb083e/regex-2026.5.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225", size = 289599, upload-time = "2026-05-09T23:12:38.089Z" }, + { url = "https://files.pythonhosted.org/packages/d8/dd/ba103dc19614e25f3880800ca67ce093d6e21b325d72b8383c7bf906e9fa/regex-2026.5.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0", size = 796732, upload-time = "2026-05-09T23:12:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e7/f035b4fd858b050b0080bf302968dc0f59ba34e391872d54936758e6844e/regex-2026.5.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107", size = 865440, upload-time = "2026-05-09T23:12:42.059Z" }, + { url = "https://files.pythonhosted.org/packages/0a/51/8cd301ecc899aea28124357f729f4272f44de7806fc7ca02490bfbe253e8/regex-2026.5.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309", size = 912329, upload-time = "2026-05-09T23:12:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/cc/1e/3fbe2fa1e8cebd62f3bb7d3321cff1640aca2e240b51d9bd624aad949260/regex-2026.5.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8", size = 801239, upload-time = "2026-05-09T23:12:46.268Z" }, + { url = "https://files.pythonhosted.org/packages/17/2f/6f6008682bf2cf98040a0d3153a8e557b6ab728d7713d045cee4ce544ab8/regex-2026.5.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66", size = 777054, upload-time = "2026-05-09T23:12:48.051Z" }, + { url = "https://files.pythonhosted.org/packages/19/2b/eee0d20a6842ba04df4b8847a920b57ef56853f14ef85405473e586b605a/regex-2026.5.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026", size = 785098, upload-time = "2026-05-09T23:12:49.851Z" }, + { url = "https://files.pythonhosted.org/packages/4a/98/6fc1e6410feefb92159edaed5041992bfe390e8d26c721865434acbca558/regex-2026.5.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962", size = 860095, upload-time = "2026-05-09T23:12:51.666Z" }, + { url = "https://files.pythonhosted.org/packages/18/a3/bd855e0f2cb1a978ecf6fa6bb69632dd9c3f6ea3b81cde62fde14c9daec7/regex-2026.5.9-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621", size = 765762, upload-time = "2026-05-09T23:12:53.413Z" }, + { url = "https://files.pythonhosted.org/packages/dc/66/0ae8c092e60b14c79d24f8e0b7f0aea5bfbffdcab00b5483d13404d3c3a5/regex-2026.5.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d", size = 852100, upload-time = "2026-05-09T23:12:55.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/8dfde60fc1b21c946a893ba273403b72617edb261370cb1087099a83f088/regex-2026.5.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce", size = 789479, upload-time = "2026-05-09T23:12:57.573Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1c/bdcc98f9a4af4fdd166c74941174619ccff4726d3ce32faa8e9a2ecd38dd/regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e", size = 266699, upload-time = "2026-05-09T23:12:59.14Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/240d36864f9e48ace85f72e79ced97ceb7f27ce87739a947dcb834b4e6bc/regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e", size = 277783, upload-time = "2026-05-09T23:13:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b5/7b30f312b0669dff5beebe5b0989dc2d1a312b1a44fab852199c387a5b96/regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070", size = 270513, upload-time = "2026-05-09T23:13:02.426Z" }, + { url = "https://files.pythonhosted.org/packages/aa/da/797e91ecec6f84135da778ddce78c20e0af5d2a15c26f87a81bc3eadb6db/regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb", size = 490303, upload-time = "2026-05-09T23:13:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/44/da/bf30abaaa737b58f4a4b8c4a03659e02fd92092c822e0197ed9e0daab917/regex-2026.5.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f", size = 292019, upload-time = "2026-05-09T23:13:06.022Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e7/d0eaf5713828417b9e5648cf81fa9bacd4961f6ab98c380c2034f8716e35/regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c", size = 289468, upload-time = "2026-05-09T23:13:08.214Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9b/b3fdd62b003baa1a9b593cd8c8699c9651c2e80cc21a5c715707983c42d7/regex-2026.5.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed", size = 796749, upload-time = "2026-05-09T23:13:10.573Z" }, + { url = "https://files.pythonhosted.org/packages/d4/30/66ab84588765f5b4b271a9ca09ef7ce2b87caa95176ec3d2ad65d7bc4902/regex-2026.5.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020", size = 865445, upload-time = "2026-05-09T23:13:12.523Z" }, + { url = "https://files.pythonhosted.org/packages/1a/89/f05169e8588aac365f35ffc7f3bc3184f095ef4cfded7cfaa3c7fd5dbd89/regex-2026.5.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2", size = 912322, upload-time = "2026-05-09T23:13:14.281Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/c93444052cf41581f3c884ab3fb5823daf0992f11cd4388d4275ca610558/regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2", size = 801269, upload-time = "2026-05-09T23:13:16.569Z" }, + { url = "https://files.pythonhosted.org/packages/50/fe/0cf96b882f540e62e8b9956599798203d599c44cf4c77917ca27400ff69b/regex-2026.5.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04", size = 777085, upload-time = "2026-05-09T23:13:18.675Z" }, + { url = "https://files.pythonhosted.org/packages/23/5c/d78d4924e7fc875557b9e9b768423925fdfaac5549d06da7810019a9bd26/regex-2026.5.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c", size = 785153, upload-time = "2026-05-09T23:13:20.525Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e0/5214774090e7b4524dcea3e3c4aa74141d43043f8beb49c1599db1c8b53a/regex-2026.5.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f", size = 860164, upload-time = "2026-05-09T23:13:22.263Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e1/4a57a83350319b1271f0d7a249b8672513ed928b237a741631270de6caea/regex-2026.5.9-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8", size = 765731, upload-time = "2026-05-09T23:13:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/12/f4/499e74a20c156fc75836ee04a72a38d1a063978f600937f9760467beb1b0/regex-2026.5.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6", size = 852062, upload-time = "2026-05-09T23:13:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/5b/92/7eebc0d0a01e78629695f342ba17e0deaff8fb45e79cc0d7b98287da6e3e/regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21", size = 789577, upload-time = "2026-05-09T23:13:27.814Z" }, + { url = "https://files.pythonhosted.org/packages/05/a4/018e71f7d2ad48c1ebe6d3ae0026f9b7cb4802fd15c7cc02fdf724355102/regex-2026.5.9-cp313-cp313-win32.whl", hash = "sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127", size = 266691, upload-time = "2026-05-09T23:13:29.549Z" }, + { url = "https://files.pythonhosted.org/packages/e6/1d/861a93719fb9ee7dbfc3761b3797b7a3e112a5d42c6129459d2d741be9b5/regex-2026.5.9-cp313-cp313-win_amd64.whl", hash = "sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca", size = 277747, upload-time = "2026-05-09T23:13:31.859Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c6/0a2436ae4da1ba76e51cb98943c6838a9a721faa40ebe2dce07694ae34e3/regex-2026.5.9-cp313-cp313-win_arm64.whl", hash = "sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6", size = 270500, upload-time = "2026-05-09T23:13:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e9/d21346f7b60ed58789371358ed66b09d00f832e1bd7c06e55d9da5679882/regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3", size = 494172, upload-time = "2026-05-09T23:13:35.935Z" }, + { url = "https://files.pythonhosted.org/packages/c4/43/fd1177a2032037c681baecdb3422ee4e1424aec4e4f470ef47793d325274/regex-2026.5.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6", size = 293952, upload-time = "2026-05-09T23:13:38.307Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/9fbf919768368d3f8a4f6c692cf2aa61e482b2b81ec6a298ace4cbf02480/regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff", size = 292314, upload-time = "2026-05-09T23:13:40.353Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6c/e41bfeecb589716843e7c4df09ba46ff2a42961457afece19059d85caeef/regex-2026.5.9-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88", size = 811681, upload-time = "2026-05-09T23:13:42.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/83/a5c1c525fba0aa656e88ad0face0b1829788ef4c2fb6b26df58aa1151b84/regex-2026.5.9-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178", size = 871135, upload-time = "2026-05-09T23:13:44.326Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/80882e799e440dd878b0979cbebf8fa4d54624a332c83037c7a701649e3f/regex-2026.5.9-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100", size = 917265, upload-time = "2026-05-09T23:13:47.295Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ff/8db60211e2286e396aad7dc7725356c502bff0901ea05bd6cdc2e1a042b9/regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e", size = 816311, upload-time = "2026-05-09T23:13:49.885Z" }, + { url = "https://files.pythonhosted.org/packages/4c/47/742ef579c61730f8d268e5cf1f9ce0e37e2ea041ad0f5644724f2378e463/regex-2026.5.9-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2", size = 785498, upload-time = "2026-05-09T23:13:52.25Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ab/cb0999802dcb0fb95b1ab005e8d4163d8afdd67efc2cb6b6630ac13f8cb1/regex-2026.5.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b", size = 801348, upload-time = "2026-05-09T23:13:54.127Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/8ca59a24c55bc34d166eefaf3717bd77772f329fdbf984d86581e0a3571c/regex-2026.5.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e", size = 866493, upload-time = "2026-05-09T23:13:56.067Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3d/30f2ae62cef3278bb5bb821f467277a55fb73f01032cf85997e15e8289a8/regex-2026.5.9-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041", size = 772811, upload-time = "2026-05-09T23:13:57.867Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ae/7d2089bcd78ad0c0161bc684339df50032acb438a7bd3305e7ddb1193cec/regex-2026.5.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0", size = 856584, upload-time = "2026-05-09T23:13:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/a9/29/92ff47f75990131ea4f24ba17819e5a9d141e10819807e09addd73409af6/regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081", size = 803453, upload-time = "2026-05-09T23:14:01.978Z" }, + { url = "https://files.pythonhosted.org/packages/04/99/eff29f1037dcab36702c9ee5d6858cf1ce2336ea8ea2987f64245b99ea5e/regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5", size = 269951, upload-time = "2026-05-09T23:14:03.661Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9d/8870b8981d27b22cda77bb26a5ac7ebfa9c7d9e0dea195a834a82380e748/regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4", size = 281240, upload-time = "2026-05-09T23:14:05.56Z" }, + { url = "https://files.pythonhosted.org/packages/72/b1/3379415e8f135c13ac551353397cc4fe97b4978f3cac73c5fcbcded548b8/regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de", size = 272383, upload-time = "2026-05-09T23:14:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/13/3e/9c3cd292d8808b3645a2ce517e200179b6d0e903f176300bd8b542e14de5/regex-2026.5.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a", size = 490376, upload-time = "2026-05-09T23:14:09.64Z" }, + { url = "https://files.pythonhosted.org/packages/60/70/d43ee8a2ca0a8b68d167f21658b85520ac0574617c7f320367c5047f7556/regex-2026.5.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4", size = 291964, upload-time = "2026-05-09T23:14:11.424Z" }, + { url = "https://files.pythonhosted.org/packages/21/91/9d50b433828d8e74196904e168a43abf1e6e88b2a15d47ed742456720c37/regex-2026.5.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c", size = 289682, upload-time = "2026-05-09T23:14:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/b835e3cafbb9d977736912436259ff551d60919f7d7b3d37d46659c63564/regex-2026.5.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9", size = 796996, upload-time = "2026-05-09T23:14:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a6/9f992d00019166b9de01c546dd4549bc679f2a68df11b877740b0760b7c2/regex-2026.5.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af", size = 866089, upload-time = "2026-05-09T23:14:17.757Z" }, + { url = "https://files.pythonhosted.org/packages/e0/08/4d32af657e049b19cb62b02e46e38fe1518797bfb2203ee93a510b21b0dc/regex-2026.5.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0", size = 911530, upload-time = "2026-05-09T23:14:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/d9/27/2af43dd1dc201d1fecefda64a45f4ad0995855b92724f795a777b402ee69/regex-2026.5.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4", size = 800643, upload-time = "2026-05-09T23:14:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/a4/dd/23a249047013b5321d4a60c4d2437462086f601b061776a525e5fba2a59f/regex-2026.5.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf", size = 777223, upload-time = "2026-05-09T23:14:24.179Z" }, + { url = "https://files.pythonhosted.org/packages/94/6a/e85ed9538cd19586d0465076a4578a12e093ce776d15f3f8ce92733a8dd6/regex-2026.5.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346", size = 785760, upload-time = "2026-05-09T23:14:26.065Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c4/f25473209438638e947c55f9156fd8f236f74169229028cc99116380868e/regex-2026.5.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676", size = 860891, upload-time = "2026-05-09T23:14:28.17Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f7/f4f86e3c74419c37370e91f150ae0c2ef7d34b2e0e4cdd5da046a02e4022/regex-2026.5.9-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14", size = 765891, upload-time = "2026-05-09T23:14:30.06Z" }, + { url = "https://files.pythonhosted.org/packages/26/70/704d8e13765939146b1cd0ef4e2feb71d7929727d2290f026eed10095955/regex-2026.5.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd", size = 851380, upload-time = "2026-05-09T23:14:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/1a13582a8460038edc38e49f64ceb0dd7c60f5caba77571f4bf6601965d9/regex-2026.5.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e", size = 789350, upload-time = "2026-05-09T23:14:34.799Z" }, + { url = "https://files.pythonhosted.org/packages/73/56/3dcafe34fc72e271d62ad9a291801e88a1457bb251c132f15fcc2e5aad1a/regex-2026.5.9-cp314-cp314-win32.whl", hash = "sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad", size = 272130, upload-time = "2026-05-09T23:14:36.729Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/02eebf0be95efe416c664db7fb8b6b05b7a0b06a7544f2884f2558b0526f/regex-2026.5.9-cp314-cp314-win_amd64.whl", hash = "sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763", size = 280999, upload-time = "2026-05-09T23:14:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/70/5a/1dd1abee76cb7a846a0bcf42fdc87e5720c3c33c24f3e37814310a513d9f/regex-2026.5.9-cp314-cp314-win_arm64.whl", hash = "sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372", size = 273500, upload-time = "2026-05-09T23:14:41.059Z" }, + { url = "https://files.pythonhosted.org/packages/86/c1/c5f619b0057a7965cb78ec559c1d7a45ce8c99a35bea95483d64959a93d9/regex-2026.5.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499", size = 494269, upload-time = "2026-05-09T23:14:42.869Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/5d01f1aee33de4bbe60c8452945bfc8477ca7c5ae4450f6bfe711036cb36/regex-2026.5.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1", size = 293954, upload-time = "2026-05-09T23:14:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/7a/fe/e8988b2ae2108c6ef71bd4aa8d87fbe257976dd0810e826cd75f701c68b6/regex-2026.5.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d", size = 292405, upload-time = "2026-05-09T23:14:47.211Z" }, + { url = "https://files.pythonhosted.org/packages/79/34/d2b0937faa7859263f7f0a3c6b103a1296306be6952dc173d0154e9a2f49/regex-2026.5.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c", size = 811855, upload-time = "2026-05-09T23:14:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/80/fe/daf53a47457a8486db66c66c01ceb9c2303eecee3f87197f1e77eb1a736d/regex-2026.5.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5", size = 871189, upload-time = "2026-05-09T23:14:51.555Z" }, + { url = "https://files.pythonhosted.org/packages/1c/75/058fc4470cbfbf57d800aff1a0022b929a3f9fa553ee10a0cdf2070eb31f/regex-2026.5.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20", size = 917485, upload-time = "2026-05-09T23:14:53.633Z" }, + { url = "https://files.pythonhosted.org/packages/88/e7/179cfda3a28bc843b5c6cfe7f79f23489c791ed95f151083803660878432/regex-2026.5.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0", size = 816369, upload-time = "2026-05-09T23:14:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/41/90/6f0cc422071688266d344fca8462d787cba0a2c144acb25721f9a61ec265/regex-2026.5.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d", size = 785869, upload-time = "2026-05-09T23:14:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/02/67/a31f1760f09c27b251ef39e9beb541f462cf977381d067faa764c2c0e393/regex-2026.5.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b", size = 801427, upload-time = "2026-05-09T23:15:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c4/1a80654597b6bc1e1ea0494824c31200e8a956abe290afae9b19a166a148/regex-2026.5.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a", size = 866482, upload-time = "2026-05-09T23:15:03.384Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/960724e06482c08466ff5611e242e86f80062949cdf6b4b9cc317b9dd93d/regex-2026.5.9-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415", size = 773022, upload-time = "2026-05-09T23:15:05.625Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/a9979c3e7918280e93159ebcab5ef1a65116dd4f3bd6091be0eae4a126e8/regex-2026.5.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2", size = 856642, upload-time = "2026-05-09T23:15:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d4/a9b732f2f0072c0ab12227483abb24fffcb9f73f8a2b203df0a6d0434735/regex-2026.5.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41", size = 803552, upload-time = "2026-05-09T23:15:10.215Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fe/1b3113817447a1d4155e4ac76d2e072f42c0bcba2f43fa8a0e756ea2cd91/regex-2026.5.9-cp314-cp314t-win32.whl", hash = "sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58", size = 275746, upload-time = "2026-05-09T23:15:12.609Z" }, + { url = "https://files.pythonhosted.org/packages/92/73/93d42045302636c91f2e5ef588b65b84b01428f28ec77de256b1dfdfbe5c/regex-2026.5.9-cp314-cp314t-win_amd64.whl", hash = "sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77", size = 285685, upload-time = "2026-05-09T23:15:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/da/80/35b4c33c804a165a7f55289afda3ea9e3eb6d15800341a2d66455c0f1f30/regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa", size = 275713, upload-time = "2026-05-09T23:15:16.98Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "respx" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" }, +] + +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, + { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, + { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, + { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, + { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/0a/1914efb7903174b381ee2ffeebb4253e729de57f114e63595114c8ca451f/ruff-0.14.13.tar.gz", hash = "sha256:83cd6c0763190784b99650a20fec7633c59f6ebe41c5cc9d45ee42749563ad47", size = 6059504, upload-time = "2026-01-15T20:15:16.918Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/ae/0deefbc65ca74b0ab1fd3917f94dc3b398233346a74b8bbb0a916a1a6bf6/ruff-0.14.13-py3-none-linux_armv6l.whl", hash = "sha256:76f62c62cd37c276cb03a275b198c7c15bd1d60c989f944db08a8c1c2dbec18b", size = 13062418, upload-time = "2026-01-15T20:14:50.779Z" }, + { url = "https://files.pythonhosted.org/packages/47/df/5916604faa530a97a3c154c62a81cb6b735c0cb05d1e26d5ad0f0c8ac48a/ruff-0.14.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914a8023ece0528d5cc33f5a684f5f38199bbb566a04815c2c211d8f40b5d0ed", size = 13442344, upload-time = "2026-01-15T20:15:07.94Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/e0e694dd69163c3a1671e102aa574a50357536f18a33375050334d5cd517/ruff-0.14.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d24899478c35ebfa730597a4a775d430ad0d5631b8647a3ab368c29b7e7bd063", size = 12354720, upload-time = "2026-01-15T20:15:09.854Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e8/67f5fcbbaee25e8fc3b56cc33e9892eca7ffe09f773c8e5907757a7e3bdb/ruff-0.14.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9aaf3870f14d925bbaf18b8a2347ee0ae7d95a2e490e4d4aea6813ed15ebc80e", size = 12774493, upload-time = "2026-01-15T20:15:20.908Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ce/d2e9cb510870b52a9565d885c0d7668cc050e30fa2c8ac3fb1fda15c083d/ruff-0.14.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac5b7f63dd3b27cc811850f5ffd8fff845b00ad70e60b043aabf8d6ecc304e09", size = 12815174, upload-time = "2026-01-15T20:15:05.74Z" }, + { url = "https://files.pythonhosted.org/packages/88/00/c38e5da58beebcf4fa32d0ddd993b63dfacefd02ab7922614231330845bf/ruff-0.14.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78d2b1097750d90ba82ce4ba676e85230a0ed694178ca5e61aa9b459970b3eb9", size = 13680909, upload-time = "2026-01-15T20:15:14.537Z" }, + { url = "https://files.pythonhosted.org/packages/61/61/cd37c9dd5bd0a3099ba79b2a5899ad417d8f3b04038810b0501a80814fd7/ruff-0.14.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d0bf87705acbbcb8d4c24b2d77fbb73d40210a95c3903b443cd9e30824a5032", size = 15144215, upload-time = "2026-01-15T20:15:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/56/8a/85502d7edbf98c2df7b8876f316c0157359165e16cdf98507c65c8d07d3d/ruff-0.14.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3eb5da8e2c9e9f13431032fdcbe7681de9ceda5835efee3269417c13f1fed5c", size = 14706067, upload-time = "2026-01-15T20:14:48.271Z" }, + { url = "https://files.pythonhosted.org/packages/7e/2f/de0df127feb2ee8c1e54354dc1179b4a23798f0866019528c938ba439aca/ruff-0.14.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:642442b42957093811cd8d2140dfadd19c7417030a7a68cf8d51fcdd5f217427", size = 14133916, upload-time = "2026-01-15T20:14:57.357Z" }, + { url = "https://files.pythonhosted.org/packages/0d/77/9b99686bb9fe07a757c82f6f95e555c7a47801a9305576a9c67e0a31d280/ruff-0.14.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4acdf009f32b46f6e8864af19cbf6841eaaed8638e65c8dac845aea0d703c841", size = 13859207, upload-time = "2026-01-15T20:14:55.111Z" }, + { url = "https://files.pythonhosted.org/packages/7d/46/2bdcb34a87a179a4d23022d818c1c236cb40e477faf0d7c9afb6813e5876/ruff-0.14.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:591a7f68860ea4e003917d19b5c4f5ac39ff558f162dc753a2c5de897fd5502c", size = 14043686, upload-time = "2026-01-15T20:14:52.841Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a9/5c6a4f56a0512c691cf143371bcf60505ed0f0860f24a85da8bd123b2bf1/ruff-0.14.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:774c77e841cc6e046fc3e91623ce0903d1cd07e3a36b1a9fe79b81dab3de506b", size = 12663837, upload-time = "2026-01-15T20:15:18.921Z" }, + { url = "https://files.pythonhosted.org/packages/fe/bb/b920016ece7651fa7fcd335d9d199306665486694d4361547ccb19394c44/ruff-0.14.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:61f4e40077a1248436772bb6512db5fc4457fe4c49e7a94ea7c5088655dd21ae", size = 12805867, upload-time = "2026-01-15T20:14:59.272Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b3/0bd909851e5696cd21e32a8fc25727e5f58f1934b3596975503e6e85415c/ruff-0.14.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6d02f1428357fae9e98ac7aa94b7e966fd24151088510d32cf6f902d6c09235e", size = 13208528, upload-time = "2026-01-15T20:15:03.732Z" }, + { url = "https://files.pythonhosted.org/packages/3b/3b/e2d94cb613f6bbd5155a75cbe072813756363eba46a3f2177a1fcd0cd670/ruff-0.14.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e399341472ce15237be0c0ae5fbceca4b04cd9bebab1a2b2c979e015455d8f0c", size = 13929242, upload-time = "2026-01-15T20:15:11.918Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c5/abd840d4132fd51a12f594934af5eba1d5d27298a6f5b5d6c3be45301caf/ruff-0.14.13-py3-none-win32.whl", hash = "sha256:ef720f529aec113968b45dfdb838ac8934e519711da53a0456038a0efecbd680", size = 12919024, upload-time = "2026-01-15T20:14:43.647Z" }, + { url = "https://files.pythonhosted.org/packages/c2/55/6384b0b8ce731b6e2ade2b5449bf07c0e4c31e8a2e68ea65b3bafadcecc5/ruff-0.14.13-py3-none-win_amd64.whl", hash = "sha256:6070bd026e409734b9257e03e3ef18c6e1a216f0435c6751d7a8ec69cb59abef", size = 14097887, upload-time = "2026-01-15T20:15:01.48Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e1/7348090988095e4e39560cfc2f7555b1b2a7357deba19167b600fdf5215d/ruff-0.14.13-py3-none-win_arm64.whl", hash = "sha256:7ab819e14f1ad9fe39f246cfcc435880ef7a9390d81a2b6ac7e01039083dd247", size = 13080224, upload-time = "2026-01-15T20:14:45.853Z" }, +] + +[[package]] +name = "scale-gp" +version = "0.1.0a62" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/93/b6a38bfacb8c5d8ada327cda3663dc2d38d10ed33c661aa42c0395137253/scale_gp-0.1.0a62.tar.gz", hash = "sha256:43c0e5843f44ae9ee15c8457e9babe1c4dc8dd9daa0d212c32d168b0d846a8cb", size = 449684, upload-time = "2026-05-14T17:03:46.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/ad/4e34b9dabecb8cc699b30c5bc48658074fea0e21253beecd55ee5a44faf2/scale_gp-0.1.0a62-py3-none-any.whl", hash = "sha256:ef6c943e36cc34a1614ad3131a48660be5eddd962e580945470166e89849d054", size = 600690, upload-time = "2026-05-14T17:03:45.415Z" }, +] + +[[package]] +name = "scale-gp-beta" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/a6/623e122dd271f3c01852a65d57343bafbfd4b712068f63d49144c6210faa/scale_gp_beta-0.5.0.tar.gz", hash = "sha256:9f0de217d7bacd1880a7b9df6cf4f8be5d0620e24c382da14f7c0bd55423977e", size = 480740, upload-time = "2026-08-05T20:57:09.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/95/4580c7e8d5e6d2354b09eb010d666f6359946a177b32dee8511d50a35f73/scale_gp_beta-0.5.0-py3-none-any.whl", hash = "sha256:1b1c6415a2c476c47ce5658bc0d8d2487b9aa61d95adb1002e0d5fbbe0b256c2", size = 472607, upload-time = "2026-08-05T20:57:07.9Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "starlette" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, +] + +[[package]] +name = "temporalio" +version = "1.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nexus-rpc" }, + { name = "protobuf" }, + { name = "types-protobuf" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/62/2bc1a9ad29382a3a99f088907ef2024a94420cfef340be1b33026c632828/temporalio-1.27.2.tar.gz", hash = "sha256:633bf2379492f3db1e887d1e64fdac00d9c2ddc3e9382b831d5af68256912e92", size = 2503041, upload-time = "2026-05-14T02:17:57.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/85/9da14f9fbdfae95435d29353bb1c55891581ad6b23c86ca56e72d83035ed/temporalio-1.27.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:860f706380faafec8f183f9194d0883c8033a4211c5d19c2c962c45b06cf99e9", size = 14602829, upload-time = "2026-05-14T02:17:45.624Z" }, + { url = "https://files.pythonhosted.org/packages/24/51/b7437991e71eea082dc53222da11f064974917cd59063ba57e13e5895fbc/temporalio-1.27.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a8dc0c680e351f3132809861888d8326dbd5030dd4e570663597e7d4768d9502", size = 13997680, upload-time = "2026-05-14T02:17:53.968Z" }, + { url = "https://files.pythonhosted.org/packages/8c/5d/358065040e6f0cedbf669acd333622999eec737ff868ca7829d727b77746/temporalio-1.27.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:805f3de4d193dec52e040e41dbfc9ab44be0206d2e81142ceefaf7b7208058d1", size = 14252199, upload-time = "2026-05-14T02:17:36.972Z" }, + { url = "https://files.pythonhosted.org/packages/72/8a/85d2eab07c3e23fc1124203e76857c69ab9b22d8ccebad0835e294edb754/temporalio-1.27.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bc996cb501b8a918f50037ccee6facb05bb70984acada4c2a3e01f5e7957a38", size = 14779945, upload-time = "2026-05-14T02:18:05.513Z" }, + { url = "https://files.pythonhosted.org/packages/67/81/c9b08609e2a92ecf62c97c59cabfa0608337c8d5cc9941eed5d9a7778840/temporalio-1.27.2-cp310-abi3-win_amd64.whl", hash = "sha256:62a84ae9a60c17932971e4ca3b0f3cd6f32f173b8183e759989376503fb95af6", size = 14981897, upload-time = "2026-05-14T02:17:27.333Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "termcolor" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, +] + +[[package]] +name = "tiktoken" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1", size = 38986, upload-time = "2026-05-15T04:51:27.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/8e/144bde4e01df66b34bb865557c7cd754ed08b036217ebd79c9db5e9048a9/tiktoken-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32ac870a806cfb260a02d0cb70426aef02e038297f8ad50df5040bb5af360791", size = 1034888, upload-time = "2026-05-15T04:50:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/36/18/d4ac9d20956cdebca04841316660ed584c2fecdc2b81722a28bc7ad3b1e4/tiktoken-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d9980f11429ed2d737c463bb1fb78cf330caa026adf002f714aced7849a687b", size = 982970, upload-time = "2026-05-15T04:50:32.961Z" }, + { url = "https://files.pythonhosted.org/packages/74/ed/6bb8d05b9f731f749fee5c6f5ca63e981143c826a5985877330507bd13b7/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3f277ebea5edd7b8bf03c6f9431e1d67d517530115572b2dc1d465326e8f88c7", size = 1115741, upload-time = "2026-05-15T04:50:34.475Z" }, + { url = "https://files.pythonhosted.org/packages/34/de/2ca96b07a82d972b74fe4b46de055b79c904e45c7eab699354a0bfa697dc/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a116178fa7e1b4065bff05214360373a65cac22f965be7b3f73d00a0dbfe7649", size = 1136523, upload-time = "2026-05-15T04:50:35.782Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/9dafec002c2d4424378563cf4cf5c7fb93631d2a55013c8b87554ee4012c/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c397ddda233208345b01bd30f2fca79ff730e55731d0108a603f9bc57f6af3b", size = 1181954, upload-time = "2026-05-15T04:50:36.99Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d0/1f8578c45b2f24759b46f0b50d31878c63c73e6bf0f2227e10ec5c5408dc/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95097e4f89b06403976e498abf61a0ee73a7497e73fb599cb211d8197a054d91", size = 1240069, upload-time = "2026-05-15T04:50:38.221Z" }, + { url = "https://files.pythonhosted.org/packages/aa/90/28d7f154888610aa9237e541986beb62b479df29d193a5a0617dbb1514d0/tiktoken-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8f2d16e7a7c783ad81f36e457d046d1f1c8af70b22aec8a13238efe531977c41", size = 874748, upload-time = "2026-05-15T04:50:39.587Z" }, + { url = "https://files.pythonhosted.org/packages/9c/83/b096c859c2a47c11731bf2f5885f4028b809dfe2396582883eed9cae372f/tiktoken-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5df5d1507bd245f1ccad4a074698240021239e455eb0bb4ced4e3d7181872154", size = 1034228, upload-time = "2026-05-15T04:50:40.988Z" }, + { url = "https://files.pythonhosted.org/packages/53/61/c68e123b6d753e3fc2751e9b18e732c9d8bf1e1926762e736eee935d931c/tiktoken-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fe806a50664e83a6ffd56cbd1e4f5dcc6cd32a3e7538f70dc38b1a271384545", size = 982978, upload-time = "2026-05-15T04:50:42.195Z" }, + { url = "https://files.pythonhosted.org/packages/ef/8b/96cc178cc584e65d363134500f297790b06cd48cdeb1e8fcf7bbe60f4715/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:125bc05005e747f993a83dc67934249932d6e4209854452cd4c0b1d53fba3ba2", size = 1116355, upload-time = "2026-05-15T04:50:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/bab735d2c72ea55404b295d02d092644eb5f7cc6205e34d35eb9abfb9ab2/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5e6358911cab4adee6712da27d65573496a4f68cf8a2b5fca6a4ad10fc5748cf", size = 1135772, upload-time = "2026-05-15T04:50:44.782Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b9/6de04ebdf904edfaad87788011b3735087a0c9ea671b9027e1e4e965e8c8/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:975cbd78d085d75d26b59660e262736dcaed1e35f8f142cd6291025c01d25486", size = 1182415, upload-time = "2026-05-15T04:50:46.422Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9c/470a05f3b1caf038f44880e334d47ab674e0c80d514c66b375d14d5afa10/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ab9bc99fa020a4c283424590ecd7f3afd70c1c281cb3fa3192a6c3af9f9615", size = 1239879, upload-time = "2026-05-15T04:50:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/42/a6/c1936d16055436cb32e6c6128d68629622e00f4768562f55653752d34768/tiktoken-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:6b1615f0ff71953d19729ceb18865429c185b0a23c5353f1bbca34a394bf60f7", size = 874829, upload-time = "2026-05-15T04:50:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/d6/07/acb5992c3772b5a36284f742cfb7a5895aa4471d1848ac31464ad50d7fdf/tiktoken-0.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6eb4a5bfbc6426938026b1a334e898ac53541360d62d8c689870160cc80abd67", size = 1033600, upload-time = "2026-05-15T04:50:50.4Z" }, + { url = "https://files.pythonhosted.org/packages/14/e9/742e9aec30f59b9f161f7ff7cd072e02ea836c9e1c0854a8076dfcd40d5c/tiktoken-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:43cee3e5400573b2046fbf092cc7a5bc30164f9e4c95ce20714da929df48737a", size = 982516, upload-time = "2026-05-15T04:50:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/72/74/ca1541b053e7648254d2e4b42a253e1bb4359f2c91a0a8d49228c794e1a0/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7de52e3f566d19b3b11bd37eea552c6c305ad74081f736882bd44d148ed4c48d", size = 1115518, upload-time = "2026-05-15T04:50:53.543Z" }, + { url = "https://files.pythonhosted.org/packages/46/e3/93825eaf5a4a504795b787e5d5dea07fbeb3dabf97aa7b450be8bde59c89/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:51384448aa508e4df84c0f7c1dc3211c7f7b8096325660ee5fc82f3e11b381ce", size = 1136867, upload-time = "2026-05-15T04:50:55.191Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/002b68de6827091d5ae90b048f326e8aad8d953520950e5ce1508879414f/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e28157350f7ebf35008dd8e9e0fdb621f976e4230c881099c85e8cf07eaa50e2", size = 1181826, upload-time = "2026-05-15T04:50:56.296Z" }, + { url = "https://files.pythonhosted.org/packages/db/c6/d393e3185a276505182f7abd93fe714f3c444a2be9180798fa052347504e/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:165cf1820ea4a354985c2490a5205d4cc74661c934aca79dd0368232fff94e0f", size = 1239489, upload-time = "2026-05-15T04:50:57.918Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4d/bc07d1f1635d4897a202acc0ae11c2886eaa7325c359ba4741b47bf8e225/tiktoken-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6c43a675ca14f6f2749ba7f12075d37456015a24b859f2517b9beb4ef30807ec", size = 873820, upload-time = "2026-05-15T04:50:59.528Z" }, + { url = "https://files.pythonhosted.org/packages/8c/93/0dd6adca026a616c3a92974566b43381eea4b475ce1f36c062b8271a9ac5/tiktoken-0.13.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaaaef47c2406277181d2086484c317bf7fc433e2d5d03ff94f56b0dcec87471", size = 1034977, upload-time = "2026-05-15T04:51:00.957Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5ec6e6bc5b30bed6d93f7f2162d8f6b32437b3ba27cb527cfe004f6109c9/tiktoken-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ca8b310bd93b3772cb1b7922d915446864860f562bdfe4825c63a0aed3fb28cd", size = 983635, upload-time = "2026-05-15T04:51:02.629Z" }, + { url = "https://files.pythonhosted.org/packages/94/b0/c8ae9aff00d625c50659b4513e707a0462c4bf5d4d6cc1b802103225c02e/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:32e0c12305105002c047b3bb1070b0dd9a73b0cb3b2856a8972b810e7a4f5881", size = 1116036, upload-time = "2026-05-15T04:51:04.082Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/6a5dddd1d0a6018ecb389bd0353e6b4a515eb4d2286611bd0ace1937b9e1/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5ba5fd62507a932d1241346179e3b39bc7bf7408f03c272652d93b3bedf5db24", size = 1135544, upload-time = "2026-05-15T04:51:05.229Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b8/585032b4384b2f7dcdaddcb52865c83a701a420d09e3c2b4a2be1c450c57/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d108bc2d470fc53c8ecd24f2c0fd2b5f98c33e87cdb6aa2e9b8c5dced703d273", size = 1182217, upload-time = "2026-05-15T04:51:06.517Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b6/993ff1ded3958215fd341a847b8e5ffeb5de473f435296870d314fc91ac4/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb99cb5127449f58d0a2d5f5ccfb390d8dbdfd919c221246caaee29d8725ed51", size = 1239404, upload-time = "2026-05-15T04:51:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3d/fef7e06e3b33e7538db0ced734cf9fe23b6832d2ac4990c119c377aec55e/tiktoken-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:115c4f26ffa11caac8b54eea35c2ad38c612c20a48d35dd15d70a02ac6f51f58", size = 918686, upload-time = "2026-05-15T04:51:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/c1/82/a7fc44582bc32ab00de988a2299bf77c077f59068b233109e34b7d6ca7e6/tiktoken-0.13.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:472527e9132952f2fbf77cd290658bacf003d4d5a3fabc18e5fbd407cbae4d9b", size = 1034454, upload-time = "2026-05-15T04:51:10.035Z" }, + { url = "https://files.pythonhosted.org/packages/37/d0/24d8a890c14f432a05cea669c17bebeaa99f96a7c79523b590f564246411/tiktoken-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e2f67d27c9626cdd25fe33d9313c5cdb3d8d82da646b68d6eb8e7e9c20e6448", size = 982976, upload-time = "2026-05-15T04:51:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/49/b7/2ab43f62788a9266187a9bfc1d3af99ad83e5eaa25fbef168a69cd5ad14f/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2b920b35805cd64585a37c3dc7ce65fba4d2d36016be01e1d7942482ca29093a", size = 1115526, upload-time = "2026-05-15T04:51:12.608Z" }, + { url = "https://files.pythonhosted.org/packages/64/39/1494321ed323ce7a14d88e3cd6cb9058625977df1c6961ddc492bd10a9f3/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:493af3aa28a4aaf2e3d2600a2ee717252c9bf5ab38fff94eb5a02db5ab77e5ad", size = 1136466, upload-time = "2026-05-15T04:51:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/96/d9/dfd086aa2d918c563a140720e0ce296cada1634efd2783d5cf51e05f984e/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6644c9c2b5cf3916f5a3641d7d12fdb3f006a7b3d9ff6acdaec44e29ab1ff91e", size = 1181863, upload-time = "2026-05-15T04:51:15.025Z" }, + { url = "https://files.pythonhosted.org/packages/2f/68/a18b4f307086954fdae32714cb4f85562e34f9d34ab206e61f1816aa6018/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cb65b60b9408563676d874a3a4ee573370066f0dc4e29d84e82e989c6517424", size = 1239218, upload-time = "2026-05-15T04:51:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/16/5b/f2aa703a4fc5d2dff73460a7d46cc2f3f44aa0f3dd8eeb20d2a0ecf68862/tiktoken-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85b78cc3a2c3d48723ca751fa981f1fedccd54194ca0471b957364353a898b07", size = 918110, upload-time = "2026-05-15T04:51:17.237Z" }, +] + +[[package]] +name = "time-machine" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/fc/37b02f6094dbb1f851145330460532176ed2f1dc70511a35828166c41e52/time_machine-3.2.0.tar.gz", hash = "sha256:a4ddd1cea17b8950e462d1805a42b20c81eb9aafc8f66b392dd5ce997e037d79", size = 14804, upload-time = "2025-12-17T23:33:02.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/8b/080c8eedcd67921a52ba5bd0e075362062509ab63c86fc1a0442fad241a6/time_machine-3.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc4bee5b0214d7dc4ebc91f4a4c600f1a598e9b5606ac751f42cb6f6740b1dbb", size = 19255, upload-time = "2025-12-17T23:31:58.057Z" }, + { url = "https://files.pythonhosted.org/packages/66/17/0e5291e9eb705bf8a5a1305f826e979af307bbeb79def4ddbf4b3f9a81e0/time_machine-3.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ca036304b4460ae2fdc1b52dd8b1fa7cf1464daa427fc49567413c09aa839c1", size = 15360, upload-time = "2025-12-17T23:31:59.048Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/9ab87b71d2e2b62463b9b058b7ae7ac09fb57f8fcd88729dec169d304340/time_machine-3.2.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5442735b41d7a2abc2f04579b4ca6047ed4698a8338a4fec92c7c9423e7938cb", size = 33029, upload-time = "2025-12-17T23:32:00.413Z" }, + { url = "https://files.pythonhosted.org/packages/4b/26/b5ca19da6f25ea905b3e10a0ea95d697c1aeba0404803a43c68f1af253e6/time_machine-3.2.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:97da3e971e505cb637079fb07ab0bcd36e33279f8ecac888ff131f45ef1e4d8d", size = 34579, upload-time = "2025-12-17T23:32:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/79/ca/6ac7ad5f10ea18cc1d9de49716ba38c32132c7b64532430d92ef240c116b/time_machine-3.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3cdda6dee4966e38aeb487309bb414c6cb23a81fc500291c77a8fcd3098832e7", size = 35961, upload-time = "2025-12-17T23:32:02.521Z" }, + { url = "https://files.pythonhosted.org/packages/33/67/390dd958bed395ab32d79a9fe61fe111825c0dd4ded54dbba7e867f171e6/time_machine-3.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:33d9efd302a6998bcc8baa4d84f259f8a4081105bd3d7f7af7f1d0abd3b1c8aa", size = 34668, upload-time = "2025-12-17T23:32:03.585Z" }, + { url = "https://files.pythonhosted.org/packages/da/57/c88fff034a4e9538b3ae7c68c9cfb283670b14d17522c5a8bc17d29f9a4b/time_machine-3.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3a0b0a33971f14145853c9bd95a6ab0353cf7e0019fa2a7aa1ae9fddfe8eab50", size = 32891, upload-time = "2025-12-17T23:32:04.656Z" }, + { url = "https://files.pythonhosted.org/packages/2d/70/ebbb76022dba0fec8f9156540fc647e4beae1680c787c01b1b6200e56d70/time_machine-3.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2d0be9e5f22c38082d247a2cdcd8a936504e9db60b7b3606855fb39f299e9548", size = 34080, upload-time = "2025-12-17T23:32:06.146Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/2ca9e7af3df540dc1c79e3de588adeddb7dcc2107829248e6969c4f14167/time_machine-3.2.0-cp312-cp312-win32.whl", hash = "sha256:3f74623648b936fdce5f911caf386c0a0b579456410975de8c0dfeaaffece1d8", size = 17371, upload-time = "2025-12-17T23:32:07.164Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ce/21d23efc9c2151939af1b7ee4e60d86d661b74ef32b8eaa148f6fe8c899c/time_machine-3.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:34e26a41d994b5e4b205136a90e9578470386749cc9a2ecf51ca18f83ce25e23", size = 18132, upload-time = "2025-12-17T23:32:08.447Z" }, + { url = "https://files.pythonhosted.org/packages/2f/34/c2b70be483accf6db9e5d6c3139bce3c38fe51f898ccf64e8d3fe14fbf4d/time_machine-3.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:0615d3d82c418d6293f271c348945c5091a71f37e37173653d5c26d0e74b13a8", size = 16930, upload-time = "2025-12-17T23:32:09.477Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cd/43ad5efc88298af3c59b66769cea7f055567a85071579ed40536188530c1/time_machine-3.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c421a8eb85a4418a7675a41bf8660224318c46cc62e4751c8f1ceca752059090", size = 19318, upload-time = "2025-12-17T23:32:10.518Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f6/084010ef7f4a3f38b5a4900923d7c85b29e797655c4f6ee4ce54d903cca8/time_machine-3.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f4e758f7727d0058c4950c66b58200c187072122d6f7a98b610530a4233ea7b", size = 15390, upload-time = "2025-12-17T23:32:11.625Z" }, + { url = "https://files.pythonhosted.org/packages/25/aa/1cabb74134f492270dc6860cb7865859bf40ecf828be65972827646e91ad/time_machine-3.2.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:154bd3f75c81f70218b2585cc12b60762fb2665c507eec5ec5037d8756d9b4e0", size = 33115, upload-time = "2025-12-17T23:32:13.219Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/78c5d7dfa366924eb4dbfcc3fc917c39a4280ca234b12819cc1f16c03d88/time_machine-3.2.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50cfe5ebea422c896ad8d278af9648412b7533b8ea6adeeee698a3fd9b1d3b7", size = 34705, upload-time = "2025-12-17T23:32:14.29Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/d5e877c24541f674c6869ff6e9c56833369796010190252e92c9d7ae5f0f/time_machine-3.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:636576501724bd6a9124e69d86e5aef263479e89ef739c5db361469f0463a0a1", size = 36104, upload-time = "2025-12-17T23:32:15.354Z" }, + { url = "https://files.pythonhosted.org/packages/22/1c/d4bae72f388f67efc9609f89b012e434bb19d9549c7a7b47d6c7d9e5c55d/time_machine-3.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:40e6f40c57197fcf7ec32d2c563f4df0a82c42cdcc3cab27f688e98f6060df10", size = 34765, upload-time = "2025-12-17T23:32:16.434Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c3/ac378cf301d527d8dfad2f0db6bad0dfb1ab73212eaa56d6b96ee5d9d20b/time_machine-3.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a1bcf0b846bbfc19a79bc19e3fa04d8c7b1e8101c1b70340ffdb689cd801ea53", size = 33010, upload-time = "2025-12-17T23:32:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/06/35/7ce897319accda7a6970b288a9a8c52d25227342a7508505a2b3d235b649/time_machine-3.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae55a56c179f4fe7a62575ad5148b6ed82f6c7e5cf2f9a9ec65f2f5b067db5f5", size = 34185, upload-time = "2025-12-17T23:32:18.566Z" }, + { url = "https://files.pythonhosted.org/packages/bf/28/f922022269749cb02eee2b62919671153c4088994fa955a6b0e50327ff81/time_machine-3.2.0-cp313-cp313-win32.whl", hash = "sha256:a66fe55a107e46916007a391d4030479df8864ec6ad6f6a6528221befc5c886e", size = 17397, upload-time = "2025-12-17T23:32:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/fd87cde397f4a7bea493152f0aca8fd569ec709cad9e0f2ca7011eb8c7f7/time_machine-3.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:30c9ce57165df913e4f74e285a8ab829ff9b7aa3e5ec0973f88f642b9a7b3d15", size = 18139, upload-time = "2025-12-17T23:32:20.991Z" }, + { url = "https://files.pythonhosted.org/packages/75/81/b8ce58233addc5d7d54d2fabc49dcbc02d79e3f079d150aa1bec3d5275ef/time_machine-3.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:89cad7e179e9bdcc84dcf09efe52af232c4cc7a01b3de868356bbd59d95bd9b8", size = 16964, upload-time = "2025-12-17T23:32:22.075Z" }, + { url = "https://files.pythonhosted.org/packages/67/e7/487f0ba5fe6c58186a5e1af2a118dfa2c160fedb37ef53a7e972d410408e/time_machine-3.2.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:59d71545e62525a4b85b6de9ab5c02ee3c61110fd7f636139914a2335dcbfc9c", size = 20000, upload-time = "2025-12-17T23:32:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/e1/17/eb2c0054c8d44dd42df84ccd434539249a9c7d0b8eb53f799be2102500ab/time_machine-3.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:999672c621c35362bc28e03ca0c7df21500195540773c25993421fd8d6cc5003", size = 15657, upload-time = "2025-12-17T23:32:24.125Z" }, + { url = "https://files.pythonhosted.org/packages/43/21/93443b5d1dd850f8bb9442e90d817a9033dcce6bfbdd3aabbb9786251c80/time_machine-3.2.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5faf7397f0580c7b9d67288522c8d7863e85f0cffadc0f1fccdb2c3dfce5783e", size = 39216, upload-time = "2025-12-17T23:32:25.542Z" }, + { url = "https://files.pythonhosted.org/packages/9f/9e/18544cf8acc72bb1dc03762231c82ecc259733f4bb6770a7bbe5cd138603/time_machine-3.2.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3dd886ec49f1fa5a00e844f5947e5c0f98ce574750c24b7424c6f77fc1c3e87", size = 40764, upload-time = "2025-12-17T23:32:26.643Z" }, + { url = "https://files.pythonhosted.org/packages/27/f7/9fe9ce2795636a3a7467307af6bdf38bb613ddb701a8a5cd50ec713beb5e/time_machine-3.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da0ecd96bc7bbe450acaaabe569d84e81688f1be8ad58d1470e42371d145fb53", size = 43526, upload-time = "2025-12-17T23:32:27.693Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/a93e975ba9dec22e87ec92d18c28e67d36bd536f9119ffa439b2892b0c9c/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:158220e946c1c4fb8265773a0282c88c35a7e3bb5d78e3561214e3b3231166f3", size = 41727, upload-time = "2025-12-17T23:32:28.985Z" }, + { url = "https://files.pythonhosted.org/packages/5f/fb/e3633e5a6bbed1c76bb2e9810dabc2f8467532ffcd29b9aed404b473061a/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c1aee29bc54356f248d5d7dfdd131e12ca825e850a08c0ebdb022266d073013", size = 38952, upload-time = "2025-12-17T23:32:30.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/3d/02e9fb2526b3d6b1b45bc8e4d912d95d1cd699d1a3f6df985817d37a0600/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8ed2224f09d25b1c2fc98683613aca12f90f682a427eabb68fc824d27014e4a", size = 39829, upload-time = "2025-12-17T23:32:31.075Z" }, + { url = "https://files.pythonhosted.org/packages/85/c8/c14265212436da8e0814c45463987b3f57de3eca4de023cc2eabb0c62ef3/time_machine-3.2.0-cp313-cp313t-win32.whl", hash = "sha256:3498719f8dab51da76d29a20c1b5e52ee7db083dddf3056af7fa69c1b94e1fe6", size = 17852, upload-time = "2025-12-17T23:32:32.079Z" }, + { url = "https://files.pythonhosted.org/packages/1d/bc/8acb13cf6149f47508097b158a9a8bec9ec4530a70cb406124e8023581f5/time_machine-3.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e0d90bee170b219e1d15e6a58164aa808f5170090e4f090bd0670303e34181b1", size = 18918, upload-time = "2025-12-17T23:32:33.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/87/c443ee508c2708fd2514ccce9052f5e48888783ce690506919629ebc8eb0/time_machine-3.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:051de220fdb6e20d648111bbad423d9506fdbb2e44d4429cef3dc0382abf1fc2", size = 17261, upload-time = "2025-12-17T23:32:34.446Z" }, + { url = "https://files.pythonhosted.org/packages/61/70/b4b980d126ed155c78d1879c50d60c8dcbd47bd11cb14ee7be50e0dfc07f/time_machine-3.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1398980c017fe5744d66f419e0115ee48a53b00b146d738e1416c225eb610b82", size = 19303, upload-time = "2025-12-17T23:32:35.796Z" }, + { url = "https://files.pythonhosted.org/packages/73/73/eaa33603c69a68fe2b6f54f9dd75481693d62f1d29676531002be06e2d1c/time_machine-3.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f8f4e35f4191ef70c2ab8ff490761ee9051b891afce2bf86dde3918eb7b537b", size = 15431, upload-time = "2025-12-17T23:32:37.244Z" }, + { url = "https://files.pythonhosted.org/packages/76/10/b81e138e86cc7bab40cdb59d294b341e172201f4a6c84bb0ec080407977a/time_machine-3.2.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6db498686ecf6163c5aa8cf0bcd57bbe0f4081184f247edf3ee49a2612b584f9", size = 33206, upload-time = "2025-12-17T23:32:38.713Z" }, + { url = "https://files.pythonhosted.org/packages/d3/72/4deab446b579e8bd5dca91de98595c5d6bd6a17ce162abf5c5f2ce40d3d8/time_machine-3.2.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:027c1807efb74d0cd58ad16524dec94212fbe900115d70b0123399883657ac0f", size = 34792, upload-time = "2025-12-17T23:32:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/2c/39/439c6b587ddee76d533fe972289d0646e0a5520e14dc83d0a30aeb5565f7/time_machine-3.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92432610c05676edd5e6946a073c6f0c926923123ce7caee1018dc10782c713d", size = 36187, upload-time = "2025-12-17T23:32:41.705Z" }, + { url = "https://files.pythonhosted.org/packages/4b/db/2da4368db15180989bab83746a857bde05ad16e78f326801c142bb747a06/time_machine-3.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c25586b62480eb77ef3d953fba273209478e1ef49654592cd6a52a68dfe56a67", size = 34855, upload-time = "2025-12-17T23:32:42.817Z" }, + { url = "https://files.pythonhosted.org/packages/88/84/120a431fee50bc4c241425bee4d3a4910df4923b7ab5f7dff1bf0c772f08/time_machine-3.2.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6bf3a2fa738d15e0b95d14469a0b8ea42635467408d8b490e263d5d45c9a177f", size = 33222, upload-time = "2025-12-17T23:32:43.94Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ea/89cfda82bb8c57ff91bb9a26751aa234d6d90e9b4d5ab0ad9dce0f9f0329/time_machine-3.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ce76b82276d7ad2a66cdc85dad4df19d1422b69183170a34e8fbc4c3f35502f7", size = 34270, upload-time = "2025-12-17T23:32:45.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/aa/235357da4f69a51a8d35fcbfcfa77cdc7dc24f62ae54025006570bda7e2d/time_machine-3.2.0-cp314-cp314-win32.whl", hash = "sha256:14d6778273c543441863dff712cd1d7803dee946b18de35921eb8df10714539d", size = 17544, upload-time = "2025-12-17T23:32:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/6c8405a7276be79693b792cff22ce41067ec05db26a7d02f2d5b06324434/time_machine-3.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbf821da96dbc80d349fa9e7c36e670b41d68a878d28c8850057992fed430eef", size = 18423, upload-time = "2025-12-17T23:32:47.468Z" }, + { url = "https://files.pythonhosted.org/packages/d9/03/a3cf419e20c35fc203c6e4fed48b5b667c1a2b4da456d9971e605f73ecef/time_machine-3.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:71c75d71f8e68abc8b669bca26ed2ddd558430a6c171e32b8620288565f18c0e", size = 17050, upload-time = "2025-12-17T23:32:48.91Z" }, + { url = "https://files.pythonhosted.org/packages/86/a1/142de946dc4393f910bf4564b5c3ba819906e1f49b06c9cb557519c849e4/time_machine-3.2.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4e374779021446fc2b5c29d80457ec9a3b1a5df043dc2aae07d7c1415d52323c", size = 19991, upload-time = "2025-12-17T23:32:49.933Z" }, + { url = "https://files.pythonhosted.org/packages/ee/62/7f17def6289901f94726921811a16b9adce46e666362c75d45730c60274f/time_machine-3.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:122310a6af9c36e9a636da32830e591e7923e8a07bdd0a43276c3a36c6821c90", size = 15707, upload-time = "2025-12-17T23:32:50.969Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d3/3502fb9bd3acb159c18844b26c43220201a0d4a622c0c853785d07699a92/time_machine-3.2.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba3eeb0f018cc362dd8128befa3426696a2e16dd223c3fb695fde184892d4d8c", size = 39207, upload-time = "2025-12-17T23:32:52.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/be/8b27f4aa296fda14a5a2ad7f588ddd450603c33415ab3f8e85b2f1a44678/time_machine-3.2.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:77d38ba664b381a7793f8786efc13b5004f0d5f672dae814430445b8202a67a6", size = 40764, upload-time = "2025-12-17T23:32:53.167Z" }, + { url = "https://files.pythonhosted.org/packages/42/cd/fe4c4e5c8ab6d48fab3624c32be9116fb120173a35fe67e482e5cf68b3d2/time_machine-3.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f09abeb8f03f044d72712207e0489a62098ad3ad16dac38927fcf80baca4d6a7", size = 43508, upload-time = "2025-12-17T23:32:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/28/5a3ba2fce85b97655a425d6bb20a441550acd2b304c96b2c19d3839f721a/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6b28367ce4f73987a55e230e1d30a57a3af85da8eb1a140074eb6e8c7e6ef19f", size = 41712, upload-time = "2025-12-17T23:32:55.781Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/e38084be7fdabb4835db68a3a47e58c34182d79fc35df1ecbe0db2c5359f/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:903c7751c904581da9f7861c3015bed7cdc40047321291d3694a3cdc783bbca3", size = 38939, upload-time = "2025-12-17T23:32:56.867Z" }, + { url = "https://files.pythonhosted.org/packages/40/d0/ad3feb0a392ef4e0c08bc32024950373ddc0669002cbdcbb9f3bf0c2d114/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:528217cad85ede5f85c8bc78b0341868d3c3cfefc6ecb5b622e1cacb6c73247b", size = 39837, upload-time = "2025-12-17T23:32:58.283Z" }, + { url = "https://files.pythonhosted.org/packages/5b/9e/5f4b2ea63b267bd78f3245e76f5528836611b5f2d30b5e7300a722fe4428/time_machine-3.2.0-cp314-cp314t-win32.whl", hash = "sha256:75724762ffd517e7e80aaec1fad1ff5a7414bd84e2b3ee7a0bacfeb67c14926e", size = 18091, upload-time = "2025-12-17T23:32:59.403Z" }, + { url = "https://files.pythonhosted.org/packages/39/6f/456b1f4d2700ae02b19eba830f870596a4b89b74bac3b6c80666f1b108c5/time_machine-3.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2526abbd053c5bca898d1b3e7898eec34626b12206718d8c7ce88fd12c1c9c5c", size = 19208, upload-time = "2025-12-17T23:33:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2f/22/8063101427ecd3d2652aada4d21d0876b07a3dc789125bca2ee858fec3ed/time_machine-3.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7f2fb6784b414edbe2c0b558bfaab0c251955ba27edd62946cce4a01675a992c", size = 17359, upload-time = "2025-12-17T23:33:01.54Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/60/21f715d9faba5f5407ff759472ade058ec4a507ad62bcea47cb847239a73/tokenizers-0.23.1.tar.gz", hash = "sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa", size = 365748, upload-time = "2026-04-27T14:43:25.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/39/b87a87d5bb9470610b80a2d31df42fcffeaf35118b8b97952b2aff598cc7/tokenizers-0.23.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e03d6ffcbe0d56ee9c1ccd070e70a13fa750727c0277e138152acbc0252c2224", size = 3146732, upload-time = "2026-04-27T14:43:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6a/068ed9f6e444c9d7e9d55ce134181325700f3d7f30410721bdc8f848d727/tokenizers-0.23.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e0948bbb1ac1d7cdfc9fb6d62c596e3b7550036ad60ecd654a66ad273326324e", size = 3054954, upload-time = "2026-04-27T14:43:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/6c/36/e006edf031154cba92b8416057d92c3abe3635e4c4b0aa0b5b9bb39dde70/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bf13402aff9bc533c89cb849ec3b412dc3fbeacc9744840e423d7bf3f7dc0e3", size = 3374081, upload-time = "2026-04-27T14:43:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ef/7735d226f9c7f874a6bee5e3f27fb25ecabdf207d37b8cf45286d0795893/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f836ca703b89ae07919a309f9651f7a88fd5a33d5f718ba5ad0870ec0256bad6", size = 3247641, upload-time = "2026-04-27T14:43:03.856Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d9/24827036f6e21297bfffda0768e58eb6096a4f411e932964a01707857931/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae848657742035523fdf261773630cb819a26995fcd3d9ecae0c1daf6e5a4959", size = 3585624, upload-time = "2026-04-27T14:43:10.664Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/22f3582b3a4f49358293a5206e25317621ee4526bfe9cdaa0f07a12e770e/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53b09e85775d5187941e7bab30e941b4134ab4a7dd8c68e783d231fb7ca27c51", size = 3844062, upload-time = "2026-04-27T14:43:05.643Z" }, + { url = "https://files.pythonhosted.org/packages/7e/65/b8f8814eef95800f20721384136d9a1d22241d50b2874357cb70542c392f/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea5a0ce170074329faaa8ea3f6400ecde604b6678192688533af80980daae71a", size = 3460098, upload-time = "2026-04-27T14:43:08.854Z" }, + { url = "https://files.pythonhosted.org/packages/0d/d5/1353e5f677ec27c2494fb6a6725e82d56c985f53e90ec511369e7e4f02c6/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b405006415ea148a992d093699c66eb01952bf59f4d5727089a98bda45a4", size = 3346235, upload-time = "2026-04-27T14:43:12.377Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/39b6b8fc073fb6d413d0147aa333dc7eff7be65639ac9d19930a0b21bf33/tokenizers-0.23.1-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:56f3a77de629917652f876294dc9fe6bad4a0c43bc229dc72e59bb23a0f4729a", size = 3426398, upload-time = "2026-04-27T14:43:07.264Z" }, + { url = "https://files.pythonhosted.org/packages/0f/80/127c854da64827e5b79264ce524993a90dddcb320e5cd42412c5c02f9e8a/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d10a6d957ef01896dc274e890eee27d41bd0e74ef31e60616f0fc311345184e", size = 9823279, upload-time = "2026-04-27T14:43:17.222Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ba/44c2502feb1a058f096ddfb4e0996ef3225a01a388e1a9b094e91689fe93/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1974288a609c343774f1b897c8b482c791ab17b75ab5c8c2b1737565c1d82288", size = 9644986, upload-time = "2026-04-27T14:43:19.45Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c1/464019a9fb059870bfe4eebb4ba12208f3042035e258bf5e782906bd3847/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:120468fb4c24faf0543c835a4fabafa4deb3f20a035c9b6e83d0b553a97615d4", size = 9976181, upload-time = "2026-04-27T14:43:21.463Z" }, + { url = "https://files.pythonhosted.org/packages/79/94/3ac1432bda31626071e9b6a12709b97ae05131c804b94c8f3ac622c5da32/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3d8f40ea6268047de7046906326abed5134f27d4e8447b23763afe5808c8a96", size = 10113853, upload-time = "2026-04-27T14:43:23.617Z" }, + { url = "https://files.pythonhosted.org/packages/6a/dd/631b21433c771b1382535326f0eca80b9c9cee2e64961dd993bc9ac4669e/tokenizers-0.23.1-cp310-abi3-win32.whl", hash = "sha256:93120a930b919416da7cd10a2f606ac9919cc69cacae7980fa2140e277660948", size = 2536263, upload-time = "2026-04-27T14:43:29.888Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/2553f72aaf65a2797d4229e37fa7fbe38ffbf3e32912d31bdd78b3323e59/tokenizers-0.23.1-cp310-abi3-win_amd64.whl", hash = "sha256:e7bfaf995c1bdbbd21d13539decb6650967013759318627d85daeb7881af16b7", size = 2798223, upload-time = "2026-04-27T14:43:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2b/2be299bab55fc595e3d38567edb1a87f86e594842968fa9515a07bdcf422/tokenizers-0.23.1-cp310-abi3-win_arm64.whl", hash = "sha256:a26197957d8e4425dfba746315f3c425ea00cfa8367c5fbc4ec73447893dcea9", size = 2664127, upload-time = "2026-04-27T14:43:26.949Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "traitlets" +version = "5.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/22/40f55b26baeab80c2d7b3f1db0682f8954e4617fee7d90ce634022ef05c6/traitlets-5.15.0.tar.gz", hash = "sha256:4fead733f81cf1c4c938e06f8ca4633896833c9d89eff878159457f4d4392971", size = 163197, upload-time = "2026-05-06T08:05:58.016Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/98/a9937a969d018a23badfea0b381f66783649d48e0ea6c41923265c3cbeb3/traitlets-5.15.0-py3-none-any.whl", hash = "sha256:fb36a18867a6803deab09f3c5e0fa81bb7b26a5c9e82501c9933f759166eff40", size = 85877, upload-time = "2026-05-06T08:05:55.853Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "typer" +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/78/d90f616bf5f88f8710ad067c1f8705bf7618059836ca084e5bb2a0855d75/typer-0.16.1.tar.gz", hash = "sha256:d358c65a464a7a90f338e3bb7ff0c74ac081449e53884b12ba658cbd72990614", size = 102836, upload-time = "2025-08-18T19:18:22.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/76/06dbe78f39b2203d2a47d5facc5df5102d0561e2807396471b5f7c5a30a1/typer-0.16.1-py3-none-any.whl", hash = "sha256:90ee01cb02d9b8395ae21ee3368421faf21fa138cb2a541ed369c08cec5237c9", size = 46397, upload-time = "2025-08-18T19:18:21.663Z" }, +] + +[[package]] +name = "types-protobuf" +version = "6.32.1.20260221" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/e2/9aa4a3b2469508bd7b4e2ae11cbedaf419222a09a1b94daffcd5efca4023/types_protobuf-6.32.1.20260221.tar.gz", hash = "sha256:6d5fb060a616bfb076cbb61b4b3c3969f5fc8bec5810f9a2f7e648ee5cbcbf6e", size = 64408, upload-time = "2026-02-21T03:55:13.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/e8/1fd38926f9cf031188fbc5a96694203ea6f24b0e34bd64a225ec6f6291ba/types_protobuf-6.32.1.20260221-py3-none-any.whl", hash = "sha256:da7cdd947975964a93c30bfbcc2c6841ee646b318d3816b033adc2c4eb6448e4", size = 77956, upload-time = "2026-02-21T03:55:12.894Z" }, +] + +[[package]] +name = "types-requests" +version = "2.33.0.20260518" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/01/c5a19253fe1ac159159ddf9a3a07cec8bb5e486ec4d9002ad2821da0e5d2/types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e", size = 24752, upload-time = "2026-05-18T06:07:37.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/bc/b139710a3b6018f7fb2b9508b35c8af564e61bf2bf4fa619d088f3e16f85/types_requests-2.33.0.20260518-py3-none-any.whl", hash = "sha256:626d697d1adaaff76e2044dc8c5c051d8f21abc157bdfe204a75558076fe0bf0", size = 21391, upload-time = "2026-05-18T06:07:37.044Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uuid-utils" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/a1/822ceef22d1c139cffebe4b1b660cfaa10253d5c770aa2598dc8e9497593/uuid_utils-0.16.0.tar.gz", hash = "sha256:d6902d4375dfba4c9902c736bb82d3c040417b67f7d0fa48910ddfdb1ac95de7", size = 42596, upload-time = "2026-05-19T07:44:23.28Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/4c/b4cf43a5d22bcdb91727acdf54be0d78e83e595b73c5a9a8a4291875f059/uuid_utils-0.16.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:727fae3f0682191ec9c8ce1cd0f71e81b471a2e26b7c5fd66712fc0f11640aa0", size = 562183, upload-time = "2026-05-19T07:45:02.683Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fb/4b0d1c4b5e9f8679ca41b9cdbce5749e1d5db3d3d42a07060d6ce61ac583/uuid_utils-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:66a9c8cedf7695c28e700f6a66bde0809c3b2e0d8a70968be7bfd47c908952e5", size = 289018, upload-time = "2026-05-19T07:44:07.726Z" }, + { url = "https://files.pythonhosted.org/packages/de/43/2dc6c7401c8fab86e46b0b33ada6dcfde949b2fd48877ba6f880862be80e/uuid_utils-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9152bff801ec2ccf630df06d67389090a2c612dea87fbf9a887ab4b222929f6f", size = 326171, upload-time = "2026-05-19T07:45:25.186Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f5/48f11fb91f36453611ca148bc441436f279870b1ec6b576dc5167fb6e680/uuid_utils-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:06fc7db470c37e5c1ab3fd2cd159697d6f8b279d7d23b5b96bd418b115f8caa9", size = 332222, upload-time = "2026-05-19T07:45:09.036Z" }, + { url = "https://files.pythonhosted.org/packages/30/cb/b2b49528521e4a097f129e8bf7850a26f00af46afba778832cf3458a5c00/uuid_utils-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e1a1f57fe3631e164dad27b24aa81267810e20575f705af3b0fa734f3a21247", size = 444801, upload-time = "2026-05-19T07:45:37.517Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b3/a28d9c6f7c701dfe01c8020b30e33899a28eb9e4d056b07e7388f50ebf67/uuid_utils-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ee392fe59808a731b7b6bf4d453fb6e833774921331cceae5f254d1e9c5b97d", size = 325594, upload-time = "2026-05-19T07:44:44.682Z" }, + { url = "https://files.pythonhosted.org/packages/cf/65/e1ff41dc44966e396ead86e104ba21b35ddb07ff7a64bb55013074ee77fe/uuid_utils-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b2e981b1258db444df4cf4bf4c79673570d081d48d35f22d0f86471e0ad795c5", size = 349312, upload-time = "2026-05-19T07:45:15.582Z" }, + { url = "https://files.pythonhosted.org/packages/ed/57/fb19b7951f66a46e03bd1943a61ee9d59c83e994e56e8c97d79aff1f0e47/uuid_utils-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbb92feb4db08cd76e27b4d3b1a82bfde708447317150c614eb9f761a43b387e", size = 502115, upload-time = "2026-05-19T07:43:38.756Z" }, + { url = "https://files.pythonhosted.org/packages/2f/8e/9a129c469b7b77afb62da5c6b7e92591073b845bd0c3108c0d0aa65389fb/uuid_utils-0.16.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c3c5afaaa68b1d6393d653e9fc93a2fde9da1681da01f74b4593f41d31fb5f1", size = 607433, upload-time = "2026-05-19T07:44:11.675Z" }, + { url = "https://files.pythonhosted.org/packages/4a/56/2ef71fad168cc3d894f7094fa458086c093635d7835381c91470b19c9ad3/uuid_utils-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:38126b353527c5f001e4b24db9e62351eb768d0367febcd68100a4b39a035109", size = 566076, upload-time = "2026-05-19T07:44:35.453Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/68e60ea053ca30f35df877b96001331398140d5c4983561affa1350331b1/uuid_utils-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41a67e546d9adf11c4e4cb5c8e81f000f8b1f000c17912ced089b499855719a5", size = 530645, upload-time = "2026-05-19T07:45:49.278Z" }, + { url = "https://files.pythonhosted.org/packages/42/19/b521f7d73094fca4c0c44002f4a42bfcbcf0b770fdc3c4b9a596dda25734/uuid_utils-0.16.0-cp312-cp312-win32.whl", hash = "sha256:52d2cc8c12a3466cd1727883e0746d8bad5dddd670369eb553ba17fdc3b565ca", size = 168887, upload-time = "2026-05-19T07:45:45.502Z" }, + { url = "https://files.pythonhosted.org/packages/87/1f/4126c3ccbc2d98a613664e55f6ab6d7bd4b98424a04486e4fcc76549af15/uuid_utils-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97625e5edfda8b118160ce1e88756f92b1635775f836c168be7bf10928d97fa", size = 174607, upload-time = "2026-05-19T07:43:52.938Z" }, + { url = "https://files.pythonhosted.org/packages/74/62/b83ccc8446ae39dcc0bda2cb3b525b6af6a2036383afe1d1d5fe7b234c2c/uuid_utils-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:baf79c8050eb784b252dd34807df73f61130fe8676b61231baccab62530f20ec", size = 173021, upload-time = "2026-05-19T07:45:10.204Z" }, + { url = "https://files.pythonhosted.org/packages/60/9b/74c1f47a9b4f138a254e51528e5ffaeba6bf99ecead9f0c4b6fccccfbfcb/uuid_utils-0.16.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d34cf9681e8892fad2a63e393068e544505408748cd8bf0c3517d753a01528d4", size = 563166, upload-time = "2026-05-19T07:44:10.494Z" }, + { url = "https://files.pythonhosted.org/packages/7c/1c/009e37b70f1f0ff17e7103a36bafde33d503d9ea7fe739761aa3e3c9fde6/uuid_utils-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0681d1bdb7956e0c6d581e7601dabcfb2b08c25d2a65189f4e9b102c94f5ff46", size = 289529, upload-time = "2026-05-19T07:43:54.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5e/e0323d54321166639eb2be5e8a464f5cb0fc04d72d91f3e78944bb6a1da8/uuid_utils-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed45fb8732d216426227096b55accbb87cba57febc86a044d90780b090eb99d0", size = 326328, upload-time = "2026-05-19T07:45:31.901Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a3/046f6cb958467c3bf4a163a8a53b178b64a62e21ed8ad5b2c1dacb3a2cfc/uuid_utils-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b617a334bb01ef2ff8c22900f5a14125eb9063f602131494cc9dc59519beaa5b", size = 332322, upload-time = "2026-05-19T07:43:41.284Z" }, + { url = "https://files.pythonhosted.org/packages/67/80/01914e3949744db7acd0006885e5542fbebb6e39114857d007d29b3265c2/uuid_utils-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a750d8aeb8ae880aa9a2529606bde0e994bcc7448730c953107f357a28e6102e", size = 445787, upload-time = "2026-05-19T07:45:36.102Z" }, + { url = "https://files.pythonhosted.org/packages/14/ef/f6908f41279f205d70c8a0d5dcb25dd6802741d7f88e3f0123453c3584d3/uuid_utils-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a250e111903c4368745fce5ac2aa607bd477c62d3307e45347338fdb64b38e0", size = 324678, upload-time = "2026-05-19T07:45:12.77Z" }, + { url = "https://files.pythonhosted.org/packages/11/4a/bf841ba90f829c7779d82155e0f4b88ef6726ccc25507d064d50ac2cd329/uuid_utils-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:95b7f480010ea98a29ee809857a98aa923008c68129af1b39244adccff7377fb", size = 349704, upload-time = "2026-05-19T07:44:47.172Z" }, + { url = "https://files.pythonhosted.org/packages/e6/31/3b5c60172b8c57bf4ca485484b8e4edef550ca324f9287f1183be97422e2/uuid_utils-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:420aa3ca403cedb73490b6ea3aeefeea7e0455f5ce60bbf856390ee872ae3306", size = 502456, upload-time = "2026-05-19T07:45:00.821Z" }, + { url = "https://files.pythonhosted.org/packages/88/bf/3da8d497af80fd51d8bf85551c77ede67f07825924ec5987bf9b6031014a/uuid_utils-0.16.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b8a9a7b1065a12d40f2cc25b7d705ab34954cc57095034367bca39ebcf4a876b", size = 607727, upload-time = "2026-05-19T07:44:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4e/7c8cf03ec15cd6f40e4cbab81b2b4a625461327f68c7971e54723280ec3e/uuid_utils-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f235ac5827d74ac630cc87f29278cdaa5d2f273613a6e05bbd96df7aa4170776", size = 566204, upload-time = "2026-05-19T07:44:51.225Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5f/af955feae69cce7fd2121ca3f790ff4b85ad2e17b2149546f50753e1a047/uuid_utils-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c8083284488b84ad178e74add64cfd1e74e8be5e30821e5acbc5019281c658b0", size = 529986, upload-time = "2026-05-19T07:45:57.85Z" }, + { url = "https://files.pythonhosted.org/packages/10/cf/3fec757e51bef10eb41ae8075f5442c60e85ff456b42d16a3063f5dc6c80/uuid_utils-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:27a071a899ba46a551d6524dbbc5a98b88be176d0f55ddf72cf71c005326ac10", size = 98683, upload-time = "2026-05-19T07:44:16.369Z" }, + { url = "https://files.pythonhosted.org/packages/40/a7/cd1adbea7ef882a70db064c00cd93b12e11027b4cdd7ffd79e95c35fc3e3/uuid_utils-0.16.0-cp313-cp313-win32.whl", hash = "sha256:924a8de04460e4cf65998ad0b6568084f7c51740ebd3254d07a0bcde35a84af6", size = 168822, upload-time = "2026-05-19T07:44:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/74/99/617ceb9e3a95b23837012740979baf71afad723b70daf34862da3f7c17a1/uuid_utils-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:5279bc7ab3c6683f1c67314695bee14d869015acbbc677bdb0015190fe753d16", size = 174967, upload-time = "2026-05-19T07:44:56.022Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d8/148ae707bfc36d482e39db679c86b81bdce264d4feb9df5d40a03b7687e3/uuid_utils-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:61a9c4c26ad12ac66fa4bfd0fdb8494724fe7a5b98a9fcd43e78e2b388663dbb", size = 173142, upload-time = "2026-05-19T07:43:50.171Z" }, + { url = "https://files.pythonhosted.org/packages/21/05/ca6d60705e71fdeaa3431dad94e279a8213c5573cb2925e1aabf3dc0330a/uuid_utils-0.16.0-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:73486b6aa3f755a6c97000f5ea67e7ac78d6df89bf22980789a1e943e24b74f0", size = 564408, upload-time = "2026-05-19T07:44:38.351Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8c/b9a0462c38535c1662acb1025768e2d626bee5ce9e1790bad6b5381162ea/uuid_utils-0.16.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f1614572fd9345cdc3dde3f40c237345719fabca1aa87d2d87b321d523cfa34d", size = 289923, upload-time = "2026-05-19T07:45:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/f2/33/a53afeef1a56051551a0f5a801e4bce411dd73c6a8c99bad16902651256d/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9346ce6eb1fbd8b03a6b331d66016afcb4edcdff6eac708e21391600529a016a", size = 325762, upload-time = "2026-05-19T07:45:18.261Z" }, + { url = "https://files.pythonhosted.org/packages/72/ca/4462a4f36365d7ee72d41e05e6bcfe127e861b073ab37c25b2c8a518317c/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a0fc6eb3fd821466fbab69cf356c6ec2b7327266bbbc740a2eb57c77c4bef965", size = 332359, upload-time = "2026-05-19T07:45:34.886Z" }, + { url = "https://files.pythonhosted.org/packages/c5/67/9d3373fa7c5a746fdecc64e30caf915c29eb632203508d87676f9243ed03/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:13a797e5e8f0dadc18351a5aa013815ddac25dce6864072a539d510910c95f71", size = 445483, upload-time = "2026-05-19T07:44:49.598Z" }, + { url = "https://files.pythonhosted.org/packages/57/08/ce01aa6d897fc7f875844fe58cad0a542c8ebf089d9242b654b56260ecb8/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57c3583b1f1c00a94f59726a5e2b988fa209221143919a1af5c2fc24e318fc98", size = 326281, upload-time = "2026-05-19T07:44:59.677Z" }, + { url = "https://files.pythonhosted.org/packages/76/ef/2c719b2c26bb5b5e5061a1435c11ad2bd33ac3cd6d4cd0c7c3ac1d3396ed/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:caac9c8b1d50e8fbddc76e93bfefbef472978eb45adbfdb6289d578816992953", size = 350809, upload-time = "2026-05-19T07:45:28.076Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9b/c1ed447328b32229cca38ac4c62d309eab006e5e9c4020e2056a175bc607/uuid_utils-0.16.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:91db59bad97ed2b9d2c6ed25082fe9762b2c422e694fe06786b28cf4e776ac4c", size = 502088, upload-time = "2026-05-19T07:44:09.208Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e0/8442f4efe7bde72f0b4ae5f675d0c7fbe209ad0b54718b8ddf43c46c6fae/uuid_utils-0.16.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:41985e342a30e76366a8becc60bbdb07d72cd1b86ec657b1f31654e9fb1baada", size = 607631, upload-time = "2026-05-19T07:44:19.384Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1e/9a9fa261edf4c972f28ae83421377e3ab8dbd0bd7db58fd316e782d09a3b/uuid_utils-0.16.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:1b0dcedf9266bf34a54d5cbe78648eaa627e02352f2a6923ed647530aea2f661", size = 567618, upload-time = "2026-05-19T07:43:58.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f7/1bcfdb9d539bd42736dd6076470a42fbb5db23f79712c0a06aa0a3752f7b/uuid_utils-0.16.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:26fe23ab60f05de4ad70aaa5b6a4c2a7bbd43055e3dd6f6b31efba0532ac9c71", size = 530971, upload-time = "2026-05-19T07:45:06.348Z" }, + { url = "https://files.pythonhosted.org/packages/24/0c/18945f417d6bb4d0dd2b7652fe36c58c4e83bcf593b9b326b83aa40b853a/uuid_utils-0.16.0-cp313-cp313t-win32.whl", hash = "sha256:7f8cf49c05d58523a0f977cb7f11afc05791a0fa164d7303b8365a34750638e7", size = 169369, upload-time = "2026-05-19T07:44:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/c0eb0c3fab2ed80d706369b750029143b53126809b77b36bcbb77da66bab/uuid_utils-0.16.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e99f9a8b2420b228faba23a637e96efaf5c6a678b2e225870f24431c82707f50", size = 175384, upload-time = "2026-05-19T07:45:56.623Z" }, + { url = "https://files.pythonhosted.org/packages/b7/77/50ac87b6e18b1c686f700aa38c9471a990683c6a955f71ac1a6677ed8145/uuid_utils-0.16.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6853b627983aa1b4fd95aa52d9e87136eb94a7b3b7de0fbb1db8a498d457eeec", size = 564108, upload-time = "2026-05-19T07:43:55.609Z" }, + { url = "https://files.pythonhosted.org/packages/83/16/65046676de246bb5334d9f58aa96d2feb9fc347fda3556aaff7da1c2fc7a/uuid_utils-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:f44b65ae0c329843817d9c90e36a7a3c677b413bf407c99e67db874dac49dad3", size = 289967, upload-time = "2026-05-19T07:45:38.886Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/54fa988606a15dfd2028e925d8eb9c3ee6edbf1eb7692a67b37282880b56/uuid_utils-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de8a365795a76f347f5622621c2bee543cffa0c70949f3ee093bdefc9d926dcc", size = 325835, upload-time = "2026-05-19T07:44:42.02Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1b/50622f967ceacea1f89fd065d9bfd395b51acb02cfb0a4ddc8fa9ff0c983/uuid_utils-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:426a8c9af90242d879706ccf29da56f0b0712e7739fb0bbe16baacabc75596e2", size = 332607, upload-time = "2026-05-19T07:43:42.42Z" }, + { url = "https://files.pythonhosted.org/packages/12/f5/4059706be6617e2787e375ea52994ce3c3fa3920b7d4a9c8ebf7895681a5/uuid_utils-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:833bc4b3c3fc24be541f67b01b4a75b6b9942a9b7137395b4eb35435948bd6da", size = 444287, upload-time = "2026-05-19T07:43:37.106Z" }, + { url = "https://files.pythonhosted.org/packages/65/d5/f44b2710563da687a368f0ce4dcbd462dfb6708bcd46439d831991d595c7/uuid_utils-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efb5252d7c00d586077f10e169d6e6d0b0d0f806d8a085073f0d19b4737aef4e", size = 324949, upload-time = "2026-05-19T07:45:33.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a7/a69e859e37d26c5603f0bc0ae481860f691224f140e5a832f325b804770d/uuid_utils-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b3377ce388fd7bf8d231ec9d1d4f58c8e87888ddea93581f60ed6f878a4f722", size = 349651, upload-time = "2026-05-19T07:43:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/db/73/4139cd3ca7b81ea283c1c8769373e9b2008241c0744a8ffb25f0a1b31325/uuid_utils-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:12b6310beb38adc173ec5dc89e98812fd7e3d98f87f3ef01d2ea6ecb5d87994f", size = 502326, upload-time = "2026-05-19T07:45:40.292Z" }, + { url = "https://files.pythonhosted.org/packages/cb/8c/858101583fbad1b3fa04da88b1f7170836aa0f00b4cb712063325c44466d/uuid_utils-0.16.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a49b5a75497643479c919e2e537a4a36224ac3aaa0fada61b75d87024021ac3e", size = 607689, upload-time = "2026-05-19T07:44:48.355Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/8f3d54a4763dd91ebd0f3d7b0c2ec434e4e0b1fc667b03a44d611a465ec6/uuid_utils-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:63bfdf00be51b6b3b79275d6767d034ea5c7a0caa067a35d72861284100cb60a", size = 566214, upload-time = "2026-05-19T07:44:53.519Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/4c9a8d9baaa243c7902d84dbba4d51b1ab51c379c66d3fd6368ff6933ecf/uuid_utils-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7525bc59ac4579c32317d2493dd42cf134b9bb50cd0bc6a41dd9f77e4740dde6", size = 529989, upload-time = "2026-05-19T07:44:43.141Z" }, + { url = "https://files.pythonhosted.org/packages/6d/13/d32cea997f880cedde415730ce0e872ebfd7a040155ae0bbda70eccd208e/uuid_utils-0.16.0-cp314-cp314-win32.whl", hash = "sha256:fbcac6e6710aa2e4bfbb81762758e01470dc56d5048ba4253acc77c9833568ff", size = 169146, upload-time = "2026-05-19T07:45:46.655Z" }, + { url = "https://files.pythonhosted.org/packages/1c/19/9fc55172d8fe59e1f27a14d598b427fa508a7ebb35fa7b7b99c24fa0ef13/uuid_utils-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:d23fcaf37368a1647319187ef6f8b741bf079f033065899bc2d00a44b0a1214a", size = 175364, upload-time = "2026-05-19T07:45:55.335Z" }, + { url = "https://files.pythonhosted.org/packages/89/5d/fcd9226b715c5aa0638fcdd6deaf0de6c6c3c451c692cd76bfca810c6512/uuid_utils-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:ea3265f8e2b452a4870f3298cb1d183dc4e36a3682cbb264dbe46af31267e706", size = 173268, upload-time = "2026-05-19T07:44:31.19Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/97ec9af95e58b8187f2934008ffab26e1604d149e34fe01c388b0543a24f/uuid_utils-0.16.0-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:99f8420c3ed59f89a086782ac197e257f4b1debb4545dffa90cf5db23f96c892", size = 564464, upload-time = "2026-05-19T07:44:40.856Z" }, + { url = "https://files.pythonhosted.org/packages/3e/6d/e4082f407484ac28923c0bf8e861e71d277118d8b7542d0a350340e45350/uuid_utils-0.16.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:259bab73c241743d684dcc3507feb76f484d720545e4e4805582aeff8e19700b", size = 290087, upload-time = "2026-05-19T07:44:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/8c/43/c5c5f273c0ff889f20f10344784f9197dd00eb81ccc294330d4b949fea7e/uuid_utils-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:897e8ef0dc5e4ac0b17cf9cae84bb41e560d806280ec5b93db7475b504022105", size = 325532, upload-time = "2026-05-19T07:43:47.508Z" }, + { url = "https://files.pythonhosted.org/packages/13/7f/669aa899ab5378374d28a28231e6978f739921a1af394c7ebd6cc86e2639/uuid_utils-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c5af79cde16a7600dfccb7d431aec0afd3088ff170b6a09887bf3f7ab3cc7c81", size = 332209, upload-time = "2026-05-19T07:43:51.528Z" }, + { url = "https://files.pythonhosted.org/packages/2b/57/a2a32406d79a222794ef98a19254fd9a81a029a0f32d7740fba9873bff1f/uuid_utils-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bece1a6f677ca36047442c465d8166643eed9818b9e43e0bf42d3cf73e92dcff", size = 445507, upload-time = "2026-05-19T07:44:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/26/6b/85459a35bfa7d73e79acbc4eab1cf6aa6e4d9d022c3260ed9dea539c7f0b/uuid_utils-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb3444498e7b099499c8a607d7771377020fa55f7274e46f54106af19f752d7", size = 326154, upload-time = "2026-05-19T07:45:23.587Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/e965efdbb503ed14d6e57aec1a22b98326ed24cc2fb48e750c4d192267a0/uuid_utils-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:542098f6cb6874aebeff98715f3ab7646fbe0f2ffb24509ca372828c68c4ed0e", size = 350905, upload-time = "2026-05-19T07:44:36.957Z" }, + { url = "https://files.pythonhosted.org/packages/23/ae/4321867888a783d03b7c053c0b68ca45d03974d86fcebf44d4ec268db397/uuid_utils-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7207b25fe534bcf4d57e0110f90670e61c1c38b6f4598ba855af69ab428fc118", size = 502098, upload-time = "2026-05-19T07:44:17.696Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/914a47bf42479bff0ce3e1fa1cbe3585354708edc928e27687cf91de9c26/uuid_utils-0.16.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:16dc5c6e439f75b0456114e955983e2156c1f38887733e54d54205d3005223e4", size = 607032, upload-time = "2026-05-19T07:44:22.151Z" }, + { url = "https://files.pythonhosted.org/packages/85/4c/2abacd6badba61a047eaa39c8347656229d12843bd9bbe4906daa6dc752c/uuid_utils-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6d3ee32c57898d8415242b08d5dd086bc4f7bcbbb3fc102ef257f3d793eb294", size = 567664, upload-time = "2026-05-19T07:45:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/53/1f/9d1a09521276424da19dc0d74456aed3311170fec181b28fa6acba45d963/uuid_utils-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7555f120a2282d1901c9a632c2398a614101af4fe3f7c8114aa0f1d8c1978855", size = 530996, upload-time = "2026-05-19T07:45:44.229Z" }, + { url = "https://files.pythonhosted.org/packages/b4/22/14dbedb6b61f492d5524077fd10bbfb137583b0f0aafa6cd870ccb43f39a/uuid_utils-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:756575d082ea4cb7d2f923d5b640c0efe7c82573aab49220c4e09b62d13737ff", size = 169358, upload-time = "2026-05-19T07:45:05.146Z" }, + { url = "https://files.pythonhosted.org/packages/25/f4/a636806c98401a1108f2456e9cc3fa39a618145bfb1d0860c57203159cfe/uuid_utils-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:aa50261a83991dbb570a00573741455bd8f3249444f7329e5bdcd494799d1504", size = 174813, upload-time = "2026-05-19T07:45:59.579Z" }, + { url = "https://files.pythonhosted.org/packages/75/12/3823742459d87a100deb24bb6b41692aa961b267abd130fa7739cdf7d409/uuid_utils-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:22a17e93a371d850ffce8fcdbacc2239f890efe73aa3262b6170c1febc08afe1", size = 171733, upload-time = "2026-05-19T07:45:29.283Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" }, +] + +[[package]] +name = "watchfiles" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/27/2ba23c8cc85796e2d41976439b08d52f691655fdb9401362099502d1f0cf/watchfiles-0.24.0.tar.gz", hash = "sha256:afb72325b74fa7a428c009c1b8be4b4d7c2afedafb2982827ef2156646df2fe1", size = 37870, upload-time = "2024-08-28T16:21:37.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/82/92a7bb6dc82d183e304a5f84ae5437b59ee72d48cee805a9adda2488b237/watchfiles-0.24.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7211b463695d1e995ca3feb38b69227e46dbd03947172585ecb0588f19b0d87a", size = 374137, upload-time = "2024-08-28T16:20:23.055Z" }, + { url = "https://files.pythonhosted.org/packages/87/91/49e9a497ddaf4da5e3802d51ed67ff33024597c28f652b8ab1e7c0f5718b/watchfiles-0.24.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b8693502d1967b00f2fb82fc1e744df128ba22f530e15b763c8d82baee15370", size = 367733, upload-time = "2024-08-28T16:20:24.543Z" }, + { url = "https://files.pythonhosted.org/packages/0d/d8/90eb950ab4998effea2df4cf3a705dc594f6bc501c5a353073aa990be965/watchfiles-0.24.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdab9555053399318b953a1fe1f586e945bc8d635ce9d05e617fd9fe3a4687d6", size = 437322, upload-time = "2024-08-28T16:20:25.572Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a2/300b22e7bc2a222dd91fce121cefa7b49aa0d26a627b2777e7bdfcf1110b/watchfiles-0.24.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:34e19e56d68b0dad5cff62273107cf5d9fbaf9d75c46277aa5d803b3ef8a9e9b", size = 433409, upload-time = "2024-08-28T16:20:26.628Z" }, + { url = "https://files.pythonhosted.org/packages/99/44/27d7708a43538ed6c26708bcccdde757da8b7efb93f4871d4cc39cffa1cc/watchfiles-0.24.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:41face41f036fee09eba33a5b53a73e9a43d5cb2c53dad8e61fa6c9f91b5a51e", size = 452142, upload-time = "2024-08-28T16:20:28.003Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ec/c4e04f755be003129a2c5f3520d2c47026f00da5ecb9ef1e4f9449637571/watchfiles-0.24.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5148c2f1ea043db13ce9b0c28456e18ecc8f14f41325aa624314095b6aa2e9ea", size = 469414, upload-time = "2024-08-28T16:20:29.55Z" }, + { url = "https://files.pythonhosted.org/packages/c5/4e/cdd7de3e7ac6432b0abf282ec4c1a1a2ec62dfe423cf269b86861667752d/watchfiles-0.24.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e4bd963a935aaf40b625c2499f3f4f6bbd0c3776f6d3bc7c853d04824ff1c9f", size = 472962, upload-time = "2024-08-28T16:20:31.314Z" }, + { url = "https://files.pythonhosted.org/packages/27/69/e1da9d34da7fc59db358424f5d89a56aaafe09f6961b64e36457a80a7194/watchfiles-0.24.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c79d7719d027b7a42817c5d96461a99b6a49979c143839fc37aa5748c322f234", size = 425705, upload-time = "2024-08-28T16:20:32.427Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c1/24d0f7357be89be4a43e0a656259676ea3d7a074901f47022f32e2957798/watchfiles-0.24.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:32aa53a9a63b7f01ed32e316e354e81e9da0e6267435c7243bf8ae0f10b428ef", size = 612851, upload-time = "2024-08-28T16:20:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/c7/af/175ba9b268dec56f821639c9893b506c69fd999fe6a2e2c51de420eb2f01/watchfiles-0.24.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ce72dba6a20e39a0c628258b5c308779b8697f7676c254a845715e2a1039b968", size = 594868, upload-time = "2024-08-28T16:20:34.639Z" }, + { url = "https://files.pythonhosted.org/packages/44/81/1f701323a9f70805bc81c74c990137123344a80ea23ab9504a99492907f8/watchfiles-0.24.0-cp312-none-win32.whl", hash = "sha256:d9018153cf57fc302a2a34cb7564870b859ed9a732d16b41a9b5cb2ebed2d444", size = 264109, upload-time = "2024-08-28T16:20:35.692Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0b/32cde5bc2ebd9f351be326837c61bdeb05ad652b793f25c91cac0b48a60b/watchfiles-0.24.0-cp312-none-win_amd64.whl", hash = "sha256:551ec3ee2a3ac9cbcf48a4ec76e42c2ef938a7e905a35b42a1267fa4b1645896", size = 277055, upload-time = "2024-08-28T16:20:36.849Z" }, + { url = "https://files.pythonhosted.org/packages/4b/81/daade76ce33d21dbec7a15afd7479de8db786e5f7b7d249263b4ea174e08/watchfiles-0.24.0-cp312-none-win_arm64.whl", hash = "sha256:b52a65e4ea43c6d149c5f8ddb0bef8d4a1e779b77591a458a893eb416624a418", size = 266169, upload-time = "2024-08-28T16:20:38.149Z" }, + { url = "https://files.pythonhosted.org/packages/30/dc/6e9f5447ae14f645532468a84323a942996d74d5e817837a5c8ce9d16c69/watchfiles-0.24.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3d2e3ab79a1771c530233cadfd277fcc762656d50836c77abb2e5e72b88e3a48", size = 373764, upload-time = "2024-08-28T16:20:39.263Z" }, + { url = "https://files.pythonhosted.org/packages/79/c0/c3a9929c372816c7fc87d8149bd722608ea58dc0986d3ef7564c79ad7112/watchfiles-0.24.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:327763da824817b38ad125dcd97595f942d720d32d879f6c4ddf843e3da3fe90", size = 367873, upload-time = "2024-08-28T16:20:40.399Z" }, + { url = "https://files.pythonhosted.org/packages/2e/11/ff9a4445a7cfc1c98caf99042df38964af12eed47d496dd5d0d90417349f/watchfiles-0.24.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd82010f8ab451dabe36054a1622870166a67cf3fce894f68895db6f74bbdc94", size = 438381, upload-time = "2024-08-28T16:20:41.371Z" }, + { url = "https://files.pythonhosted.org/packages/48/a3/763ba18c98211d7bb6c0f417b2d7946d346cdc359d585cc28a17b48e964b/watchfiles-0.24.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d64ba08db72e5dfd5c33be1e1e687d5e4fcce09219e8aee893a4862034081d4e", size = 432809, upload-time = "2024-08-28T16:20:42.504Z" }, + { url = "https://files.pythonhosted.org/packages/30/4c/616c111b9d40eea2547489abaf4ffc84511e86888a166d3a4522c2ba44b5/watchfiles-0.24.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1cf1f6dd7825053f3d98f6d33f6464ebdd9ee95acd74ba2c34e183086900a827", size = 451801, upload-time = "2024-08-28T16:20:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/b6/be/d7da83307863a422abbfeb12903a76e43200c90ebe5d6afd6a59d158edea/watchfiles-0.24.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:43e3e37c15a8b6fe00c1bce2473cfa8eb3484bbeecf3aefbf259227e487a03df", size = 468886, upload-time = "2024-08-28T16:20:44.847Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d3/3dfe131ee59d5e90b932cf56aba5c996309d94dafe3d02d204364c23461c/watchfiles-0.24.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88bcd4d0fe1d8ff43675360a72def210ebad3f3f72cabfeac08d825d2639b4ab", size = 472973, upload-time = "2024-08-28T16:20:45.991Z" }, + { url = "https://files.pythonhosted.org/packages/42/6c/279288cc5653a289290d183b60a6d80e05f439d5bfdfaf2d113738d0f932/watchfiles-0.24.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:999928c6434372fde16c8f27143d3e97201160b48a614071261701615a2a156f", size = 425282, upload-time = "2024-08-28T16:20:47.579Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/58afe5e85217e845edf26d8780c2d2d2ae77675eeb8d1b8b8121d799ce52/watchfiles-0.24.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:30bbd525c3262fd9f4b1865cb8d88e21161366561cd7c9e1194819e0a33ea86b", size = 612540, upload-time = "2024-08-28T16:20:48.915Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/b96eeb9fe3fda137200dd2f31553670cbc731b1e13164fd69b49870b76ec/watchfiles-0.24.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:edf71b01dec9f766fb285b73930f95f730bb0943500ba0566ae234b5c1618c18", size = 593625, upload-time = "2024-08-28T16:20:50.543Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e5/c326fe52ee0054107267608d8cea275e80be4455b6079491dfd9da29f46f/watchfiles-0.24.0-cp313-none-win32.whl", hash = "sha256:f4c96283fca3ee09fb044f02156d9570d156698bc3734252175a38f0e8975f07", size = 263899, upload-time = "2024-08-28T16:20:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8b/8a7755c5e7221bb35fe4af2dc44db9174f90ebf0344fd5e9b1e8b42d381e/watchfiles-0.24.0-cp313-none-win_amd64.whl", hash = "sha256:a974231b4fdd1bb7f62064a0565a6b107d27d21d9acb50c484d2cdba515b9366", size = 276622, upload-time = "2024-08-28T16:20:52.82Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "widgetsnbextension" +version = "4.0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b7404b7aefcd7569a9c0d6bd071299bf4198ae7a5d95/widgetsnbextension-4.0.15.tar.gz", hash = "sha256:de8610639996f1567952d763a5a41af8af37f2575a41f9852a38f947eb82a3b9", size = 1097402, upload-time = "2025-11-01T21:15:55.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, +] + +[[package]] +name = "wrapt" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/0c/bfae7b9401583b6d05938cd16dedc43857d96da2f8a3d50d78cc515bf6ff/wrapt-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ffad790d9d11d8ecf9f17c4bb671a5b4089e4d8b575c46c5129597f41f836b0", size = 81021, upload-time = "2026-05-22T14:48:00.313Z" }, + { url = "https://files.pythonhosted.org/packages/26/58/80f6a6599f933f4caecc1cb3ee88a04faf81e8b9bddbd6109c688dd63e0f/wrapt-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:628f5220c7a904d5fc78f7075c8d7871433eb6d035c94728a22fdf85f193d2a8", size = 81692, upload-time = "2026-05-22T14:48:01.49Z" }, + { url = "https://files.pythonhosted.org/packages/17/93/fb357cc7847c58a8ae790be718903afa81a28d23e642c843dc4129e8a0b2/wrapt-2.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61acce4257a9883669703c525447c5b4c392edf0f987ae77ec32668440158f0e", size = 169364, upload-time = "2026-05-22T14:48:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0b/76b601ee309a8bd556af0eecb184394c20b3c49aa9c8e085aa1ffacc2568/wrapt-2.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727ab4244622cd6ad2390f322642090c877d2e83a608d2653a7643ae5368d926", size = 171079, upload-time = "2026-05-22T14:48:04.22Z" }, + { url = "https://files.pythonhosted.org/packages/cd/87/ee3f32d5658e3e26d3e0e457922b47a36dd3bfbdfee7f97bb3e802344a66/wrapt-2.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03df9ebed4c73ab93fa8c07e3d41d818dfca1852b15731a3de59457b27814624", size = 160205, upload-time = "2026-05-22T14:48:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d0/ae2fd64277a67f5d7bffcf2d05eea1e476263fb2a072baf0b0129ab85984/wrapt-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9ff006f420b2ec8296aa56ade43ea7da3e997e85769f0aafc5e0661aacb710", size = 168922, upload-time = "2026-05-22T14:48:07.132Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f3/2d541a060c5bbafb9400bca4917e4d78bfd1f239f404782c86831a8f6b29/wrapt-2.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:844c858fc3bb7eacc0ba8efa904935d16aac6a4470948ad1e7e55c9f5a2a665f", size = 158388, upload-time = "2026-05-22T14:48:08.629Z" }, + { url = "https://files.pythonhosted.org/packages/1d/68/8d92c8800c57e93cb116ae9e9d6cbafc34fade5ee9f9107b6f203fb4dc35/wrapt-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87bacdaf225117a342a20d9c03438d701c02112f6e3f351ce9b7f32354f14797", size = 167682, upload-time = "2026-05-22T14:48:10.042Z" }, + { url = "https://files.pythonhosted.org/packages/30/72/83ea3790ea352439442349388e29ff07b76e0686265f9088bbb505d1608d/wrapt-2.2.1-cp312-cp312-win32.whl", hash = "sha256:2f8c90c8afde51969487be4e1343ae049b268854877d415c2510baf833775052", size = 77857, upload-time = "2026-05-22T14:48:11.782Z" }, + { url = "https://files.pythonhosted.org/packages/ef/cb/99450668dd3502d62a54a1c8aa56e44f34cb8c1261b381cfe2e7926c3b75/wrapt-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ce32763ac31ce94fe9aada947e479b1975012bff166da409b4b9e4e376cf7e5", size = 80825, upload-time = "2026-05-22T14:48:13.046Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/87512881be64e743f9ee4c66f4cbe8e884974bef2a5989af71f999653ac7/wrapt-2.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d1b4d0e0c2119587a31f5c029abd547e0c81d93b89d394566fe1588659eb579", size = 79087, upload-time = "2026-05-22T14:48:14.323Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/a1b08f8f4fac8cbb156fa51cf64ee2c7f7f74f9875ba3cf70b3c58368694/wrapt-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d2beb1c7cab10603aecdc42f8edd6ff013f9a32e4543474e38e6b77ce9975aeb", size = 80831, upload-time = "2026-05-22T14:48:15.598Z" }, + { url = "https://files.pythonhosted.org/packages/54/ce/57890814991446a845e09b3445ce8b694f27eb0577004f2c2a36a9772ed4/wrapt-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0cb7e4dd71f4c32e5e84843cd3c4cd65dda034314004bbe1d7f99af2426ab80", size = 81375, upload-time = "2026-05-22T14:48:17.071Z" }, + { url = "https://files.pythonhosted.org/packages/38/65/08d7a6c76ac4493bdb668205ee9c1de1bd5daca61717c3e9aa49b4c01499/wrapt-2.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95821352042722cd9f1108874579a47989d0a7e12a37d87d2fc4af20fd99ab8a", size = 167417, upload-time = "2026-05-22T14:48:18.303Z" }, + { url = "https://files.pythonhosted.org/packages/62/ce/f1ccbee7a1bfe5cdc6b3da6bab4b45713d628b9294da32a39f563d648140/wrapt-2.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd621552ede77c4c69be7fac44ba911225b0c812b6ba604e5964cf98085b474", size = 166948, upload-time = "2026-05-22T14:48:19.768Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/f85d48d1cd4869aee6704028d257d740a47c1c467b457ce396b4b5b55d07/wrapt-2.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3677c7146ce694874941ba82b57092cc4875445aadf29d72807351023105143", size = 158148, upload-time = "2026-05-22T14:48:21.96Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5c/93939ad11d4a12358ab1aab219a2ef5efa5612e0db6b9fc65af8af1a891b/wrapt-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9a5934eaea872e17936b5f45501eba5ab0bce9a74122e172b663d7c28c459c4a", size = 165905, upload-time = "2026-05-22T14:48:23.373Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/b8c2aa89862ff58605934d7abf4b70e6a5a1c33df96656f49035ccdf1c8a/wrapt-2.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f5b9daf6b629fce418e0cc3dd0436eac045188fa35deadb7a7f3941d5b8203f9", size = 156712, upload-time = "2026-05-22T14:48:24.767Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/bf00a7b02239c12bb02ddcc3c0b971bfcc36e578c5a44f1ccfef5b458545/wrapt-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f53ac9f3ef573326d009ed809beff4efcac6451931c2b8132586da4b9e53ff31", size = 166560, upload-time = "2026-05-22T14:48:26.83Z" }, + { url = "https://files.pythonhosted.org/packages/fe/93/6390ca9c5b787683cef588d04f57c8d41b9a2323b5597a65f18638c90ef2/wrapt-2.2.1-cp313-cp313-win32.whl", hash = "sha256:1ffa9cfd4bdb581539951b14ae661ff20ed0c3599b3e911a131ee0ec5ac11337", size = 77817, upload-time = "2026-05-22T14:48:28.221Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/ce10f0e71c0cfaa1a65faadb8efd4852028b3bb9ba28932b8889df769d38/wrapt-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:368eac1e20fd0bb03dd3cc42bf9887154c3861b60989389ccb5fac032617d215", size = 80736, upload-time = "2026-05-22T14:48:30.139Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4c/89f4a6818fafbbd840330e4fa3873073e1bfc166133a64cac7f8fde7a5e3/wrapt-2.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:c754dafdf5aaf0b401b644a90a30046929a0dd1a536e0ff0ec959a59155d9c7f", size = 79099, upload-time = "2026-05-22T14:48:31.405Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f2/9a8741c46f8c208ac0a45b25ba170bcb4fb72a2781d5fb97dbd7b6be73cb/wrapt-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ed928d0fda15fc0adc8d13305c8b3c0f2fba5b0669950c9e6d019d9162a3b3e8", size = 82802, upload-time = "2026-05-22T14:48:33.307Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0d/e9c855716a3705eef1416456bdf062b60620726fdc59428ff670fc3c60dc/wrapt-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fafb4e739e43544d12cb4abd1605fd4683b6ca6a9ad682b7fd8f4d21973eafa8", size = 83329, upload-time = "2026-05-22T14:48:34.593Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d6/a88f1c13112b7831adac75cea65d8310e0d696d570c8961844c90a57b865/wrapt-2.2.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:74d6a0c31472fe5d814917266b9f46495d7c61ed890af08b468acea92fb89a8d", size = 202937, upload-time = "2026-05-22T14:48:35.859Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/e29d54aef06a4d898a5b8a25589a0b3769bde454f922fad8f6f89fbfb650/wrapt-2.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab5be648d5a0b86b7438864f8df3c705a65cef35a2fd3e5561e3e203167e0f27", size = 209997, upload-time = "2026-05-22T14:48:38.153Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/e4454263516cf0e12640912fbca9a83654e424f0a6ddb79f5cd7ce14bf33/wrapt-2.2.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d8f204c8e3a8bf9ece17e0a83d137fd807440977f8a5e762d59306795011440", size = 194856, upload-time = "2026-05-22T14:48:39.69Z" }, + { url = "https://files.pythonhosted.org/packages/de/d0/fe0ee202286afdf4a7f77dd29f195703145764d572aec209c5086e57d924/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d047f6498c973874ba08ac3f97c69a2c4b2211c8de6f4c205f75cb1c9522596e", size = 205654, upload-time = "2026-05-22T14:48:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/23/b6/87d860dfc6460c246af70b1fd5c8b76df77571b42a493459423ded94fd7d/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:7a4fdb9326aab4a5a477a1640e5ad786a8495901009d7e7b038371edd23a9d2b", size = 192206, upload-time = "2026-05-22T14:48:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/46/3eea8cde077d985f239a38c0257087b8064fd9ee9b1a99e282d2c86da4ef/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8cc5094b08abeae52da9c73c8a32003623be691a5193df2f4e3eac3d557c394", size = 198428, upload-time = "2026-05-22T14:48:46.319Z" }, + { url = "https://files.pythonhosted.org/packages/18/dc/b927ee9c7fc67adc3a5658f246a0d275425eb840ba36e7b702e70f18bde8/wrapt-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562", size = 79448, upload-time = "2026-05-22T14:48:47.901Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b3/fd30b473fe498c70e6b9a5f328b8d3fbaf1b8c3c481465f59724bba8eb70/wrapt-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53", size = 83021, upload-time = "2026-05-22T14:48:49.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/96c39153a8737a6e9aa85adef254ac4195bea3f2d24efc60472ccc3c9e2e/wrapt-2.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e", size = 80295, upload-time = "2026-05-22T14:48:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a3/11d7f34ebbf3231bc907a3e6d5ee051b14d034c1bc7b65a97d5cc00516df/wrapt-2.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab", size = 80879, upload-time = "2026-05-22T14:48:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/b74cfd984cef560b900fb1a727af20352d89e1f06bf2e1114dd3f00f5f5a/wrapt-2.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c", size = 81462, upload-time = "2026-05-22T14:48:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/15/a3/7c8f704b8dc07dfe0a5d01c2edbfd88317aa8e5e3fa7c743eb7a085ae767/wrapt-2.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c", size = 167251, upload-time = "2026-05-22T14:48:54.562Z" }, + { url = "https://files.pythonhosted.org/packages/80/85/a34d1888d97247da6c2ff6118c3a721c73ed8cc4dd198c00208bb73b6f80/wrapt-2.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e", size = 166316, upload-time = "2026-05-22T14:48:56.065Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d7/72ffaeb01eebc704afe3fb99e840480f4bda45f0fa66e3381b6a39251c8f/wrapt-2.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f", size = 157952, upload-time = "2026-05-22T14:48:57.924Z" }, + { url = "https://files.pythonhosted.org/packages/24/5b/36f5d6b024e4edfdd90b140742d11ebcf7836daf5c9daf326c55c24db412/wrapt-2.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508", size = 166130, upload-time = "2026-05-22T14:48:59.384Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/9296d9e97bfdef5483dfcc859d57b095b257144b2bc5300ab521e06f4bc7/wrapt-2.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5", size = 156604, upload-time = "2026-05-22T14:49:00.921Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/16953929ed6776175720e58fc966e779926d8d71e2c7b2273230590ca71f/wrapt-2.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283", size = 166007, upload-time = "2026-05-22T14:49:02.332Z" }, + { url = "https://files.pythonhosted.org/packages/b9/73/20ee58c0612dae7c31131a7095345812ed2c7b389019e175f68cde34e5b4/wrapt-2.2.1-cp314-cp314-win32.whl", hash = "sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243", size = 78327, upload-time = "2026-05-22T14:49:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/22/b3/ef7c3295d02e0448a71c639a36a057f46d524d057c9486291a7a3039e65c/wrapt-2.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b", size = 81144, upload-time = "2026-05-22T14:49:05.093Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dc/7bdf336953f99f4ceb0a584bb8870e42c8f26f93ea10c87834dad62f1668/wrapt-2.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36", size = 79569, upload-time = "2026-05-22T14:49:06.413Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/6dfae80150ff1919c356d1dd528f049bcdfaae29b4d284bc957e022caef4/wrapt-2.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188", size = 82892, upload-time = "2026-05-22T14:49:07.925Z" }, + { url = "https://files.pythonhosted.org/packages/82/7b/4e34766a7d7804ffce9e71befe47e9b3225dc350c49c94493c4ab39fd3a5/wrapt-2.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199", size = 83333, upload-time = "2026-05-22T14:49:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/9d/57/0b34db3e8de44ccfece62d7b337abd1631dd810f5adc5f3db571727836b5/wrapt-2.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413", size = 202899, upload-time = "2026-05-22T14:49:10.572Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/ac0c459f154b99d92789a6cba7ca727185b83513b986f8ec7fe2aacddcbf/wrapt-2.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956", size = 209986, upload-time = "2026-05-22T14:49:12.229Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/77e37ff33ad018fa81ade52c25fa327b80b56f81d734279a63614fcb4cbc/wrapt-2.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e", size = 194893, upload-time = "2026-05-22T14:49:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9d/7ea651d1ab032fc5fa222fbec91d0f8a1397f6ae04ebb93fa7219aa921d7/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85", size = 205636, upload-time = "2026-05-22T14:49:15.714Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8e88031a701275b9085c54e64bc88c0b1cd55c77eadd400691c371cd76c4/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181", size = 192267, upload-time = "2026-05-22T14:49:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a8/e657ca876b06710194f243d81c4b0896ade646e244bdbec2d87c8c56a8bd/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a", size = 198378, upload-time = "2026-05-22T14:49:18.785Z" }, + { url = "https://files.pythonhosted.org/packages/c8/59/822efe4ea722a3961331bfa35b7d90937790d2c20f0616de1997ccc3aebd/wrapt-2.2.1-cp314-cp314t-win32.whl", hash = "sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85", size = 80226, upload-time = "2026-05-22T14:49:20.264Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/2a7dc5f6abb2fca0b6e1610e120419f603650aceb4f1d3ac4cae0354e162/wrapt-2.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50", size = 83835, upload-time = "2026-05-22T14:49:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722, upload-time = "2026-05-22T14:49:23.59Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, +] + +[[package]] +name = "xxhash" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/2f/e183a1b407002f5af81822bee18b61cdb94b8670208ef34734d8d2b8ebe9/xxhash-3.7.0.tar.gz", hash = "sha256:6cc4eefbb542a5d6ffd6d70ea9c502957c925e800f998c5630ecc809d6702bae", size = 82022, upload-time = "2026-04-25T11:10:32.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/8a/51a14cdef4728c6c2337db8a7d8704422cc65676d9199d77215464c880af/xxhash-3.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:082c87bfdd2b9f457606c7a4a53457f4c4b48b0cdc48de0277f4349d79bb3d7a", size = 33357, upload-time = "2026-04-25T11:06:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1b/0c2c933809421ffd9bf42b59315552c143c755db5d9a816b2f1ae273e884/xxhash-3.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5e7ce913b61f35b0c1c839a49ac9c8e75dd8d860150688aed353b0ce1bf409d8", size = 30869, upload-time = "2026-04-25T11:06:21.989Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/89d5fdd6ee12d70ba99451de46dd0e8010167468dcd913ec855653f4dd50/xxhash-3.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3beb1de3b1e9694fcdd853e570ee64c631c7062435d2f8c69c1adf809bc086f0", size = 194100, upload-time = "2026-04-25T11:06:23.586Z" }, + { url = "https://files.pythonhosted.org/packages/87/ee/2f9f2ed993e77206d1e66991290a1ebe22e843351ca3ebec8e49e01ba186/xxhash-3.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3e7b689c3bce16699efcf736066f5c6cc4472c3840fe4b22bd8279daf4abdac", size = 212977, upload-time = "2026-04-25T11:06:25.019Z" }, + { url = "https://files.pythonhosted.org/packages/de/60/5a91644615a9e9d4e42c2e9925f1908e3a24e4e691d9de7340d565bea024/xxhash-3.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a6545e6b409e3d5cbafc850fb84c55a1ca26ed15a6b11e3bf07a0e0cd84517c8", size = 236373, upload-time = "2026-04-25T11:06:26.482Z" }, + { url = "https://files.pythonhosted.org/packages/22/c0/f3a9384eaaed9d14d4d062a5d953aa0da489bfe9747877aa994caa87cd0b/xxhash-3.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:31ab1461c77a11461d703c88eb949e132a1c6515933cf675d97ec680f4bd18de", size = 212229, upload-time = "2026-04-25T11:06:28.065Z" }, + { url = "https://files.pythonhosted.org/packages/2e/67/02f07a9fd79726804190f2172c4894c3ed9a4ebccaca05653c84beb58025/xxhash-3.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c4d596b7676f811172687ec567cbafb9e4dea2f9be1bbb4f622410cb7f40f40", size = 445462, upload-time = "2026-04-25T11:06:30.048Z" }, + { url = "https://files.pythonhosted.org/packages/40/37/558f5a90c0672fc9b4402dc25d87ac5b7406616e8969430c9ca4e52ee74d/xxhash-3.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13805f0461cba0a857924e70ff91ae6d52d2598f79a884e788db80532614a4a1", size = 193932, upload-time = "2026-04-25T11:06:31.857Z" }, + { url = "https://files.pythonhosted.org/packages/d5/90/aaa09cd58661d32044dbbad7df55bbe22a623032b810e7ed3b8c569a2a6f/xxhash-3.7.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d398f372496152f1c6933a33566373f8d1b37b98b8c9d608fa6edc0976f23b2", size = 284807, upload-time = "2026-04-25T11:06:33.697Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f3/53df3719ab127a02c174f0c1c74924fcd110866e89c966bc7909cfa8fa84/xxhash-3.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d610aa62cdb7d4d497740741772a24a794903bf3e79eaa51d2e800082abe11e5", size = 210445, upload-time = "2026-04-25T11:06:35.488Z" }, + { url = "https://files.pythonhosted.org/packages/72/33/d219975c0e8b6fa2eb9ccd486fe47e21bf1847985b878dd2fbc3126e0d5c/xxhash-3.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:073c23900a9fbf3d26616c17c830db28af9803677cd5b33aea3224d824111514", size = 241273, upload-time = "2026-04-25T11:06:37.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/50/49b1afe610eb3964cedcb90a4d4c3d46a261ee8669cbd4f060652619ae3c/xxhash-3.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:418a463c3e6a590c0cdc890f8be19adb44a8c8acd175ca5b2a6de77e61d0b386", size = 197950, upload-time = "2026-04-25T11:06:39.148Z" }, + { url = "https://files.pythonhosted.org/packages/c6/75/5f42a1a4c78717d906a4b6a140c6dbf837ab1f547a54d23c4e2903310936/xxhash-3.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:03f8ff4474ee61c845758ce00711d7087a770d77efb36f7e74a6e867301000b8", size = 210709, upload-time = "2026-04-25T11:06:40.958Z" }, + { url = "https://files.pythonhosted.org/packages/8a/85/237e446c25abced71e9c53d269f2cef5bab8a82b3f88a12e00c5368e7368/xxhash-3.7.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:44fba4a5f1d179b7ddc7b3dc40f56f9209046421679b57025d4d8821b376fd8d", size = 275345, upload-time = "2026-04-25T11:06:42.525Z" }, + { url = "https://files.pythonhosted.org/packages/62/34/c2c26c0a6a9cc739bc2a5f0ae03ba8b87deb12b8bce35f7ac495e790dc6d/xxhash-3.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31e3516a0f829d06ded4a2c0f3c7c5561993256bfa1c493975fb9dc7bfa828a1", size = 414056, upload-time = "2026-04-25T11:06:44.343Z" }, + { url = "https://files.pythonhosted.org/packages/a0/aa/5c58e9bc8071b8afd8dcf297ff362f723c4892168faba149f19904132bf4/xxhash-3.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b59ee2ac81de57771a09ecad09191e840a1d2fae1ef684208320591055768f83", size = 191485, upload-time = "2026-04-25T11:06:46.262Z" }, + { url = "https://files.pythonhosted.org/packages/d4/69/a929cf9d1e2e65a48b818cdce72cb6b69eab2e6877f21436d0a1942aff43/xxhash-3.7.0-cp312-cp312-win32.whl", hash = "sha256:74bbd92f8c7fcc397ba0a11bfdc106bc72ad7f11e3a60277753f87e7532b4d81", size = 30671, upload-time = "2026-04-25T11:06:48.039Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1b/104b41a8947f4e1d4a66ce1e628eea752f37d1890bfd7453559ca7a3d950/xxhash-3.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:7bd7bc82dd4f185f28f35193c2e968ef46131628e3cac62f639dadf321cba4d1", size = 31514, upload-time = "2026-04-25T11:06:49.279Z" }, + { url = "https://files.pythonhosted.org/packages/98/a0/1fd0ea1f1b886d9e7c73f0397571e22333a7d79e31da6d7127c2a4a71d75/xxhash-3.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:7d7148180ec99ba36585b42c8c5de25e9b40191613bc4be68909b4d25a77a852", size = 27761, upload-time = "2026-04-25T11:06:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ca/d5174b4c36d10f64d4ca7050563138c5a599efb01a765858ddefc9c1202a/xxhash-3.7.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:4b6d6b33f141158692bd4eafbb96edbc5aa0dabdb593a962db01a91983d4f8fa", size = 36813, upload-time = "2026-04-25T11:06:51.73Z" }, + { url = "https://files.pythonhosted.org/packages/41/d0/abc6c9d347ba1f1e1e1d98125d0881a0452c7f9a76a9dd03a7b5d2197f23/xxhash-3.7.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:845d347df254d6c619f616afa921331bada8614b8d373d58725c663ba97c3605", size = 35121, upload-time = "2026-04-25T11:06:53.048Z" }, + { url = "https://files.pythonhosted.org/packages/bf/11/4cc834eb3d79f2f2b3a6ef7324195208bcdfbdcf7534d2b17267aa5f3a8f/xxhash-3.7.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:fddbbb69a6fff4f421e7a0d1fa28f894b20112e9e3fab306af451e2dfd0e459b", size = 29624, upload-time = "2026-04-25T11:06:54.311Z" }, + { url = "https://files.pythonhosted.org/packages/23/83/e97d3e7b635fe73a1dfb1e91f805324dd6d930bb42041cbf18f183bc0b6d/xxhash-3.7.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:54876a4e45101cec2bf8f31a973cda073a23e2e108538dad224ba07f85f22487", size = 30638, upload-time = "2026-04-25T11:06:55.864Z" }, + { url = "https://files.pythonhosted.org/packages/f4/40/d84951d80c35db1f4c40a29a64a8520eea5d56e764c603906b4fe763580f/xxhash-3.7.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:0c72fe9c7e3d6dfd7f1e21e224a877917fa09c465694ba4e06464b9511b65544", size = 33323, upload-time = "2026-04-25T11:06:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/c7dc6558d97e9ab023f663d69ab28b340ed9bf4d2d94f2c259cf896bb354/xxhash-3.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a6d73a830b17ef49bc04e00182bd839164c1b3c59c127cd7c54fcb10c7ed8ee8", size = 33362, upload-time = "2026-04-25T11:06:58.656Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6e/46b84017b1301d54091430353d4ad5901654a3e0871649877a416f7f1644/xxhash-3.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91c3b07cf3362086d8f126c6aecd8e5e9396ad8b2f2219ea7e49a8250c318acd", size = 30874, upload-time = "2026-04-25T11:06:59.834Z" }, + { url = "https://files.pythonhosted.org/packages/df/5e/8f9158e3ab906ad3fec51e09b5ea0093e769f12207bfa42a368ca204e7ab/xxhash-3.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50e879ebbac351c81565ca108db766d7832f5b8b6a5b14b8c0151f7190028e3d", size = 194185, upload-time = "2026-04-25T11:07:01.658Z" }, + { url = "https://files.pythonhosted.org/packages/f3/29/a804ded9f5d3d3758292678d23e7528b08fda7b7e750688d08b052322475/xxhash-3.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:921c14e93817842dd0dd9f372890a0f0c72e534650b6ab13c5be5cd0db11d47e", size = 213033, upload-time = "2026-04-25T11:07:03.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/91/1ce5a7d2fdc975267320e2c78fc1cecfe7ab735ccbcf6993ec5dd541cb2c/xxhash-3.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e64a7c9d7dfca3e0fafcbc5e455519090706a3e36e95d655cec3e04e79f95aaa", size = 236140, upload-time = "2026-04-25T11:07:05.396Z" }, + { url = "https://files.pythonhosted.org/packages/34/04/fd595a4fd8617b05fa27bd9b684ecb4985bfed27917848eea85d54036d06/xxhash-3.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2220af08163baf5fa36c2b8af079dc2cbe6e66ae061385267f9472362dfd53c6", size = 212291, upload-time = "2026-04-25T11:07:06.966Z" }, + { url = "https://files.pythonhosted.org/packages/03/fb/f1a379cbc372ae5b9f4ab36154c48a849ca6ebe3ac477067a57865bf3bc6/xxhash-3.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f14bb8b22a4a91325813e3d553b8963c10cf8c756cff65ee50c194431296c655", size = 445532, upload-time = "2026-04-25T11:07:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/65/59/172424b79f8cfd4b6d8a122b2193e6b8ad4b11f7159bb3b6f9b3191329bb/xxhash-3.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:496736f86a9bedaf64b0dc70e3539d0766df01c71ea22032698e88f3f04a1ce9", size = 193990, upload-time = "2026-04-25T11:07:10.315Z" }, + { url = "https://files.pythonhosted.org/packages/b9/19/aeac22161d953f139f07ba5586cb4a17c5b7b6dff985122803bb12933500/xxhash-3.7.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0ff71596bd79816975b3de7130ab1ff4541410285a3c084584eeb1c8239996fd", size = 284876, upload-time = "2026-04-25T11:07:12.15Z" }, + { url = "https://files.pythonhosted.org/packages/77/d5/4fd0b59e7a02242953da05ff679fbb961b0a4368eac97a217e11dae110c1/xxhash-3.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1ad86695c19b1d46fe106925db3c7a37f16be37669dcf58dcc70a9dd6e324676", size = 210495, upload-time = "2026-04-25T11:07:13.952Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fb/976a3165c728c7faf74aa1b5ab3cf6a85e6d731612894741840524c7d28c/xxhash-3.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:970f9f8c50961d639cbd0d988c96f80ddf66006de93641719282c4fe7a87c5e6", size = 241331, upload-time = "2026-04-25T11:07:15.557Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2c/6763d5901d53ac9e6ba296e5717ae599025c9d268396e8faa8b4b0a8e0ac/xxhash-3.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5886ad85e9e347911783760a1d16cb6b393e8f9e3b52c982568226cb56927bdc", size = 198037, upload-time = "2026-04-25T11:07:17.563Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/876e722d533833f5f9a83473e6ba993e48745701096944e77bbecf29b2c3/xxhash-3.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6e934bbae1e0ec74e27d5f0d7f37ef547ce5ff9f0a7e63fb39e559fc99526734", size = 210744, upload-time = "2026-04-25T11:07:19.055Z" }, + { url = "https://files.pythonhosted.org/packages/21/e6/d7e7baef7ce24166b4668d3c48557bb35a23b92ecadcac7e7718d099ab69/xxhash-3.7.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:3b6b3d28228af044ebcded71c4a3dd86e1dbd7e2f4645bf40f7b5da65bb5fb5a", size = 275406, upload-time = "2026-04-25T11:07:20.908Z" }, + { url = "https://files.pythonhosted.org/packages/92/fe/198b3763b2e01ca908f2154969a2352ec99bda892b574a11a9a151c5ede4/xxhash-3.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:6be4d70d9ab76c9f324ead9c01af6ff52c324745ea0c3731682a0cf99720f1fe", size = 414125, upload-time = "2026-04-25T11:07:23.037Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6d/019a11affd5a5499137cacca53808659964785439855b5aa40dfd3412916/xxhash-3.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:151d7520838d4465461a0b7f4ae488b3b00de16183dd3214c1a6b14bf89d7fb6", size = 191555, upload-time = "2026-04-25T11:07:24.991Z" }, + { url = "https://files.pythonhosted.org/packages/76/21/b96d58568df2d01533244c3e0e5cbdd0c8b2b25c4bec4d72f19259a292d7/xxhash-3.7.0-cp313-cp313-win32.whl", hash = "sha256:d798c1e291bffb8e37b5bbe0dda77fc767cd19e89cadaf66e6ed5d0ff88c9fe6", size = 30668, upload-time = "2026-04-25T11:07:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/99/57/d849a8d3afa1f8f4bc6a831cd89f49f9706fbbad94d2975d6140a171988c/xxhash-3.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:875811ba23c543b1a1c3143c926e43996eb27ebb8f52d3500744aa608c275aed", size = 31524, upload-time = "2026-04-25T11:07:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/81/52/bacc753e92dee78b058af8dcef0a50815f5f860986c664a92d75f965b6a5/xxhash-3.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:54a675cb300dda83d71daae2a599389d22db8021a0f8db0dd659e14626eb3ecc", size = 27768, upload-time = "2026-04-25T11:07:29.113Z" }, + { url = "https://files.pythonhosted.org/packages/1c/47/ddbd683b7fc7e592c1a8d9d65f73ce9ab513f082b3967eee2baf549b8fc6/xxhash-3.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a3b19a42111c4057c1547a4a1396a53961dca576a0f6b82bfa88a2d1561764b2", size = 33576, upload-time = "2026-04-25T11:07:30.469Z" }, + { url = "https://files.pythonhosted.org/packages/07/f2/36d3310161db7f72efb4562aadde0ed429f1d0531782dd6345b12d2da527/xxhash-3.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8f4608a06e4d61b7a3425665a46d00e0579122e1a2fae97a0c52953a3aad9aa3", size = 31123, upload-time = "2026-04-25T11:07:31.989Z" }, + { url = "https://files.pythonhosted.org/packages/0d/3f/75937a5c69556ed213021e43cbedd84c8e0279d0d74e7d41a255d84ba4b1/xxhash-3.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ad37c7792479e49cf96c1ab25517d7003fe0d93687a772ba19a097d235bbe41e", size = 196491, upload-time = "2026-04-25T11:07:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/f10d7ff8c7a733d4403a43b9de18c8fabc005f98cec054644f04418659ee/xxhash-3.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc026e3b89d98e30a8288c95cb696e77d150b3f0fb7a51f73dcd49ee6b5577fa", size = 215793, upload-time = "2026-04-25T11:07:34.919Z" }, + { url = "https://files.pythonhosted.org/packages/8b/fd/778f60aa295f58907938f030a8b514611f391405614a525cccd2ffc00eb5/xxhash-3.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9b31ab1f28b078a6a1ac1a54eb35e7d5390deddd56870d0be3a0a733d1c321c", size = 237993, upload-time = "2026-04-25T11:07:36.638Z" }, + { url = "https://files.pythonhosted.org/packages/70/f5/736db5de387b4a540e37a05b84b40dc58a1ce974bfd2b4e5754ce29b68c3/xxhash-3.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3bb5fd680c038fd5229e44e9c493782f90df9bef632fd0499d442374688ff70b", size = 214887, upload-time = "2026-04-25T11:07:38.564Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/09a095f22fdb9a27fbb716841fbff52119721f9ca4261952d07a912f7839/xxhash-3.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:030c0fd688fce3569fbb49a2feefd4110cbb0b650186fb4610759ecfac677548", size = 448407, upload-time = "2026-04-25T11:07:40.552Z" }, + { url = "https://files.pythonhosted.org/packages/74/8a/b745efeeca9e34a91c26fdc97ad8514c43d5a81ac78565cba80a1353870a/xxhash-3.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b1bde10324f4c31812ae0d0502e92d916ae8917cad7209353f122b8b8f610c3", size = 196119, upload-time = "2026-04-25T11:07:42.101Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5c/0cfceb024af90c191f665c7933b1f318ee234f4797858383bebd1881d52f/xxhash-3.7.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:503722d52a615f2604f5e7611de7d43878df010dc0053094ef91cb9a9ac3d987", size = 286751, upload-time = "2026-04-25T11:07:43.568Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0a/0793e405dc3cf8f4ebe2c1acec1e4e4608cd9e7e50ea691dabbc2a95ccbb/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c72500a3b6d6c30ebfc135035bcace9eb5884f2dc220804efcaaba43e9f611dd", size = 212961, upload-time = "2026-04-25T11:07:45.388Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7e/721118ffc63bfff94aa565bcf2555a820f9f4bdb0f001e0d609bdfad70de/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:43475925a766d01ca8cd9a857fd87f3d50406983c8506a4c07c4df12adcc867f", size = 243703, upload-time = "2026-04-25T11:07:47.053Z" }, + { url = "https://files.pythonhosted.org/packages/6e/18/16f6267160488b8276fd3d449d425712512add292ba545c1b6946bfdb7dd/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8d09dfd2ab135b985daf868b594315ebe11ad86cd9fea46e6c69f19b28f7d25a", size = 200894, upload-time = "2026-04-25T11:07:48.657Z" }, + { url = "https://files.pythonhosted.org/packages/2d/94/80ba841287fd97e3e9cac1d228788c8ef623746f570404961eec748ecb5c/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c50269d0055ac1faecfd559886d2cbe4b730de236585aba0e873f9d9dadbe585", size = 213357, upload-time = "2026-04-25T11:07:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7e/106d4067130c59f1e18a55ffadcd876d8c68534883a1e02685b29d3d8153/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1910df4756a5ab58cfad8744fc2d0f23926e3efcc346ee76e87b974abab922f4", size = 277600, upload-time = "2026-04-25T11:07:51.745Z" }, + { url = "https://files.pythonhosted.org/packages/c5/86/a081dd30da71d720b2612a792bfd55e45fa9a07ac76a0507f60487473c25/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d006faf3b491957efcb433489be3c149efe4787b7063d5cddb8ddaefdc60e0c1", size = 416980, upload-time = "2026-04-25T11:07:53.504Z" }, + { url = "https://files.pythonhosted.org/packages/35/29/1a95221a029a3c1293773869e1ab47b07cbbdd82444a42809e8c60156626/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:abb65b4e947e958f7b3b0d71db3ce447d1bc5f37f5eab871ce7223bda8768a04", size = 193840, upload-time = "2026-04-25T11:07:55.103Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/db909dd0823285de2286f67e10ee4d81e96ad35d7d8e964ecb07fccd8af9/xxhash-3.7.0-cp313-cp313t-win32.whl", hash = "sha256:178959906cb1716a1ce08e0d69c82886c70a15a6f2790fc084fdd146ca30cd49", size = 30966, upload-time = "2026-04-25T11:07:56.524Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ff/d705b15b22f21ee106adce239cb65d35067a158c630b240270f09b17c2e6/xxhash-3.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2524a1e20d4c231d13b50f7cf39e44265b055669a64a7a4b9a2a44faa03f19b6", size = 31784, upload-time = "2026-04-25T11:07:57.758Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1f/b2cf83c3638fd0588e0b17f22e5a9400bdfb1a3e3755324ac0aee2250b88/xxhash-3.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:37d994d0ffe81ef087bb330d392caa809bb5853c77e22ea3f71db024a0543dba", size = 27932, upload-time = "2026-04-25T11:07:59.109Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cc/431db584f6fbb9312e40a173af027644e5580d39df1f73603cbb9dca4d6b/xxhash-3.7.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:8c5fcfd806c335bfa2adf1cd0b3110a44fc7b6995c3a648c27489bae85801465", size = 36644, upload-time = "2026-04-25T11:08:00.658Z" }, + { url = "https://files.pythonhosted.org/packages/bc/01/255ec513e0a705d1f9a61413e78dfce4e3235203f0ed525a24c2b4b56345/xxhash-3.7.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:506a0b488f190f0a06769575e30caf71615c898ed93ab18b0dbcb6dec5c3713c", size = 35003, upload-time = "2026-04-25T11:08:02.338Z" }, + { url = "https://files.pythonhosted.org/packages/68/70/c55fc33c93445b44d8fc5a17b41ed99e3cebe92bcf8396809e63fc9a1165/xxhash-3.7.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:ec68dbba21532c0173a9872298e65c89749f7c9d21538c3a78b5bb6105871568", size = 29655, upload-time = "2026-04-25T11:08:03.701Z" }, + { url = "https://files.pythonhosted.org/packages/c2/72/ff8de73df000d74467d12a59ce6d6e2b2a368b978d41ab7b1fba5ed442be/xxhash-3.7.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:fa77e7ec1450d415d20129961814787c9abd9a07f98872f070b1fe96c5084611", size = 30664, upload-time = "2026-04-25T11:08:05.011Z" }, + { url = "https://files.pythonhosted.org/packages/b6/91/08416d9bd9bc3bf39d831abe8a5631ac2db5141dfd6fe81c3fe59a1f9264/xxhash-3.7.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:fe32736295ea38e43e7d9424053c8c47c9f64fecfc7c895fb3da9b30b131c9ee", size = 33317, upload-time = "2026-04-25T11:08:06.413Z" }, + { url = "https://files.pythonhosted.org/packages/0e/3b/86b1caa4dee10a99f4bf9521e623359341c5e50d05158fa10c275b2bd079/xxhash-3.7.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ab9dd2c83c4bbd63e422181a76f13502d049d3ddcac9a1bdc29196263d692bb8", size = 33457, upload-time = "2026-04-25T11:08:08.099Z" }, + { url = "https://files.pythonhosted.org/packages/ed/38/98ea14ad1517e1461292a65906951458d520689782bfbae111050145bdba/xxhash-3.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3afec3a336a2286601a437cb07562ab0227685e6fbb9ec17e8c18457ff348ecf", size = 30894, upload-time = "2026-04-25T11:08:09.429Z" }, + { url = "https://files.pythonhosted.org/packages/61/a2/074654d0b893606541199993c7db70067d9fc63b748e0d60020a52a1bd36/xxhash-3.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:565df64437a9390f84465dcca33e7377114c7ede8d05cd2cf20081f831ea788e", size = 194409, upload-time = "2026-04-25T11:08:10.91Z" }, + { url = "https://files.pythonhosted.org/packages/e2/26/6d2a1afc468189f77ca28c32e1c83e1b9da1178231e05641dbc1b350e332/xxhash-3.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12eca820a5d558633d423bf8bb78ce72a55394823f64089247f788a7e0ae691e", size = 213135, upload-time = "2026-04-25T11:08:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0e/d8aecf95e09c42547453137be74d2f7b8b14e08f5177fa2fab6144a19061/xxhash-3.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f262b8f7599516567e070abf607b9af649052b2c4bd6f9be02b0cb41b7024805", size = 236379, upload-time = "2026-04-25T11:08:14.206Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/8140e8210536b3dd0cc816c4faaeb5ba6e63e8125ab25af4bcddd6a037b3/xxhash-3.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1598916cb197681e03e601901e4ab96a9a963de398c59d0964f8a6f44a2b361", size = 212447, upload-time = "2026-04-25T11:08:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/462001d2903b4bee5a5689598a0a55e5e7cd1ac7f4247a5545cff10d3ebb/xxhash-3.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:322b2f0622230f526aeb1738149948a7ae357a9e2ceb1383c6fd1fdaecdafa16", size = 445660, upload-time = "2026-04-25T11:08:17.441Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/2bd1ed7f8689b20e51727952cac8329d50c694dc32b2eba06ba5bc742b37/xxhash-3.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24cc22070880cc57b830a65cde4e65fa884c6d9b28ae4803b5ee05911e7bafba", size = 194076, upload-time = "2026-04-25T11:08:19.134Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6e/692302cd0a5f4ac4e6289f37fa888dc2e1e07750b68fe3e4bfe939b8cea3/xxhash-3.7.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb5a888a968b2434abf9ecda357b5d43f10d7b5a6da6fdbbe036208473aff0e2", size = 284990, upload-time = "2026-04-25T11:08:20.618Z" }, + { url = "https://files.pythonhosted.org/packages/05/d9/e54b159b3d9df7999d2a7c676ce7b323d1b5588a64f8f51ed8172567bd87/xxhash-3.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a999771ff97bec27d18341be4f3a36b163bb1ac41ec17bef6d2dabd84acd33c7", size = 210590, upload-time = "2026-04-25T11:08:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/0e0df1a3a196ced4ca71de76d65ead25d8e87bbfb87b64306ea47a40c00d/xxhash-3.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ed4a6efe2dee1655adb73e7ad40c6aa955a6892422b1e3b95de6a34de56e3cbb", size = 241442, upload-time = "2026-04-25T11:08:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a9/d917a7a814e90b218f8a0d37967105eea91bf752c3303683c99a1f7bfc1f/xxhash-3.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9fd17f14ac0faa12126c2f9ca774a8cf342957265ec3c8669c144e5e6cdb478c", size = 198356, upload-time = "2026-04-25T11:08:25.99Z" }, + { url = "https://files.pythonhosted.org/packages/89/5e/f2ba1877c39469abbefc72991d6ebdcbd4c0880db01ae8cb1f553b0c537d/xxhash-3.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:05fd1254268c59b5cb2a029dfc204275e9fc52de2913f1e53aa8d01442c96b4d", size = 210898, upload-time = "2026-04-25T11:08:27.608Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/be56b58e73de531f39a10de1355bb77ceb663900dc4bf2d6d3002a9c3f9e/xxhash-3.7.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a2eae53197c6276d5b317f75a1be226bbf440c20b58bf525f36b5d0e1f657ca6", size = 275519, upload-time = "2026-04-25T11:08:29.301Z" }, + { url = "https://files.pythonhosted.org/packages/92/e2/17ddc85d5765b9c709f192009ed8f5a1fc876f4eb35bba7c307b5b1169f9/xxhash-3.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bfe6f92e3522dcbe8c4281efd74fa7542a336cb00b0e3272c4ec0edabeaeaf67", size = 414191, upload-time = "2026-04-25T11:08:31.16Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/85f5b79f4bf1ec7ba052491164adfd4f4e9519f5dc7246de4fbd64a1bd56/xxhash-3.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7ab9a49c410d8c6c786ab99e79c529938d894c01433130353dd0fe999111077a", size = 191604, upload-time = "2026-04-25T11:08:32.862Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d0/6127b623aa4cca18d8b7743592b048d689fd6c6e37ff26a22cddf6cd9d7f/xxhash-3.7.0-cp314-cp314-win32.whl", hash = "sha256:040ea63668f9185b92bc74942df09c7e65703deed71431333678fc6e739a9955", size = 31271, upload-time = "2026-04-25T11:08:34.651Z" }, + { url = "https://files.pythonhosted.org/packages/64/4f/44fc4788568004c43921701cbc127f48218a1eede2c9aea231115323564d/xxhash-3.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2a61e2a3fb23c892496d587b470dee7fa1b58b248a187719c65ea8e94ec13257", size = 32284, upload-time = "2026-04-25T11:08:35.987Z" }, + { url = "https://files.pythonhosted.org/packages/6d/77/18bb895eb60a49453d16e17d67990e5caff557c78eafc90ad4e2eabf4570/xxhash-3.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:c7741c7524961d8c0cb4d4c21b28957ff731a3fd5b5cd8b856dc80a40e9e5acc", size = 28701, upload-time = "2026-04-25T11:08:37.767Z" }, + { url = "https://files.pythonhosted.org/packages/45/a0/46f72244570c550fbbb7db1ef554183dd5ebe9136385f30e032b781ae8f6/xxhash-3.7.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fc84bf7aa7592f31ec63a3e7b11d624f468a3f19f5238cec7282a42e838ab1d7", size = 33646, upload-time = "2026-04-25T11:08:39.109Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3a/453846a7eceea11e75def361eed01ec6a0205b9822c19927ed364ccae7cc/xxhash-3.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9f1563fdc8abfc389748e6932c7e4e99c89a53e4ec37d4563c24fc06f5e5644b", size = 31125, upload-time = "2026-04-25T11:08:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3e/49434aba738885d512f9e486db1bdd19db28dfa40372b56da26ef7a4e738/xxhash-3.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d415f18becf6f153046ab6adc97da77e3643a0ee205dae61c4012604113a020", size = 196633, upload-time = "2026-04-25T11:08:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e9/006cb6127baeb9f8abe6d15e62faa01349f09b34e2bfd65175b2422d026b/xxhash-3.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb16aa13ed175bc9be5c2491ba031b85a9b51c4ed90e0b3d4ebe63cf3fb54f8e", size = 215899, upload-time = "2026-04-25T11:08:43.645Z" }, + { url = "https://files.pythonhosted.org/packages/27/e4/cc57d72e66df0ae29b914335f1c6dcf61e8f3746ddf0ae3c471aa4f15e00/xxhash-3.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f9fd595f1e5941b3d7863e4774e4b30caa6731fc34b9277da032295aa5656ee5", size = 238116, upload-time = "2026-04-25T11:08:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/af/78/3531d4a3fd8a0038cc6be1f265a69c1b3587f557a10b677dd736de2202c1/xxhash-3.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1295325c5a98d552333fa53dc2b026b0ef0ec9c8e73ca3a952990b4c7d65d459", size = 215012, upload-time = "2026-04-25T11:08:47.355Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f6/259fb1eaaec921f59b17203b0daee69829761226d3b980d5191d7723dd83/xxhash-3.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3573a651d146912da9daa9e29e5fbc45994420daaa9ef1e2fa5823e1dc485513", size = 448534, upload-time = "2026-04-25T11:08:49.149Z" }, + { url = "https://files.pythonhosted.org/packages/7b/16/a66d0eaf6a7e68532c07714361ddc904c663ec940f3b028c1ae4a21a7b9d/xxhash-3.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ec1e080a3d02d94ea9335bfab0e3374b877e25411422c18f51a943fa4b46381", size = 196217, upload-time = "2026-04-25T11:08:50.805Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ef/d2efc7fc51756dc52509109d1a25cefc859d74bc4b19a167b12dbd8c2786/xxhash-3.7.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84415265192072d8638a3afc3c1bc5995e310570cd9acb54dc46d3939e364fe0", size = 286906, upload-time = "2026-04-25T11:08:52.418Z" }, + { url = "https://files.pythonhosted.org/packages/fc/67/25decd1d4a4018582ec4db2a868a2b7e40640f4adb20dfeb19ac923aa825/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d4dea659b57443989ef32f4295104fd6912c73d0bf26d1d148bb88a9f159b02", size = 213057, upload-time = "2026-04-25T11:08:54.105Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5d/17651eb29d06786cdc40c60ae3d27d645aa5d61d2eca6237a7ba0b94789b/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:05ece0fe4d9c9c2728912d1981ae1566cfc83a011571b24732cbf76e1fb70dca", size = 243886, upload-time = "2026-04-25T11:08:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d4/174d9cf7502243d586e6a9ae842b1ae23026620995114f85f1380e588bc9/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fd880353cf1ffaf321bc18dd663e111976dbd0d3bbd8a66d58d2b470dfa7f396", size = 201015, upload-time = "2026-04-25T11:08:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/91/8c/2254e2d06c3ac5e6fe22eaf3da791b87ea823ae9f2c17b4af66755c5752d/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4e15cc9e2817f6481160f930c62842b3ff419e20e13072bcbab12230943092bc", size = 213457, upload-time = "2026-04-25T11:08:59.826Z" }, + { url = "https://files.pythonhosted.org/packages/79/a2/e3daa762545921173e3360f3b4ff7fc63c2d27359f7230ec1a7a74e117f6/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:90b9d1a8bd37d768ffc92a1f651ec69afc532a96fa1ac2ea7abbed5d630b3237", size = 277738, upload-time = "2026-04-25T11:09:01.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4c/e186da2c46b87f5204640e008d42730bf3c1ee9f0efb71ae1ebcdfeac681/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:157c49475b34ecea8809e51123d9769a534e139d1247942f7a4bc67710bb2533", size = 417127, upload-time = "2026-04-25T11:09:03.592Z" }, + { url = "https://files.pythonhosted.org/packages/17/28/3798e15007a3712d0da3d3fe70f8e11916569858b5cc371053bc26270832/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5a6ddec83325685e729ca119d1f5c518ec39294212ecd770e60693cdc5f7eb79", size = 193962, upload-time = "2026-04-25T11:09:06.228Z" }, + { url = "https://files.pythonhosted.org/packages/ad/95/a26baa93b5241fd7630998816a4ec47a5a0bad193b3f8fc8f3593e1a4a67/xxhash-3.7.0-cp314-cp314t-win32.whl", hash = "sha256:a04a6cab47e2166435aaf5b9e5ee41d1532cc8300efdef87f2a4d0acb7db19ed", size = 31643, upload-time = "2026-04-25T11:09:08.153Z" }, + { url = "https://files.pythonhosted.org/packages/44/36/5454f13c447e395f9b06a3e91274c59f503d31fad84e1836efe3bdb71f6a/xxhash-3.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8653dd7c2eda020545bb2c71c7f7039b53fe7434d0fc1a0a9deb79ab3f1a4fc1", size = 32522, upload-time = "2026-04-25T11:09:09.534Z" }, + { url = "https://files.pythonhosted.org/packages/74/35/698e7e3ff38e22992ea24870a511d8762474fb6783627a2910ff22a185c2/xxhash-3.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:468f0fc114faaa4b36699f8e328bbc3bb11dc418ba94ac52c26dd736d4b6c637", size = 28807, upload-time = "2026-04-25T11:09:11.234Z" }, +] + +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, + { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, + { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, + { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, + { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, + { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, + { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, + { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, + { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, + { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, + { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, + { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, + { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, + { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, + { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, + { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, + { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, + { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, + { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +] + +[[package]] +name = "yaspin" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "termcolor" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/c5/826a862dcfcb9e85321f96d6f1b4b96b3b9bf37df6f63dce9cffd0b17053/yaspin-3.4.0.tar.gz", hash = "sha256:a83a81ac7a9d161e116fb668a7e4d10d87fb18d02b4b08a17b7e472f465f3c90", size = 42396, upload-time = "2025-12-06T12:33:51.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/6f/7403e6ae864a0a7f1cdd8814d39690062766e141339127f2b3469201ff6f/yaspin-3.4.0-py3-none-any.whl", hash = "sha256:2a40572a38d39846d0df0a421733459481b7da17789f7a2618c3181bb0a82819", size = 21822, upload-time = "2025-12-06T12:33:50.633Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +] From 567abffbfbeade629fc10bc5d460f37331d68e5c Mon Sep 17 00:00:00 2001 From: Thi Quynh Nhu Nguyen Date: Mon, 21 Sep 2026 14:57:47 -0700 Subject: [PATCH 08/15] Build SDK Stainless-Generated-From: 2e4f6f5436ddf0453e52a56c1a75d6a2e7292091 --- .circleci/config.yml | 0 .github/workflows/output-template.json | 13 - .gitignore | 61 -- .stats.yml | 3 - .trufflehog-exclude.txt | 13 - CODEOWNERS | 0 adk/README.md | 20 - adk/pyproject.toml | 20 +- scripts/utils/upload-artifact.sh | 2 +- src/agentex/_client.py | 2 +- src/agentex/lib/adk/utils/_modules/client.py | 44 -- src/agentex/lib/cli/debug/debug_handlers.py | 3 - .../lib/cli/handlers/deploy_handlers.py | 10 - src/agentex/lib/cli/handlers/run_handlers.py | 60 +- .../lib/cli/templates/PRIVATE_INDEX.md | 74 --- .../default-claude-code/Dockerfile-uv.j2 | 20 - .../default-claude-code/Dockerfile.j2 | 13 +- .../templates/default-codex/Dockerfile-uv.j2 | 20 - .../cli/templates/default-codex/Dockerfile.j2 | 13 +- .../default-langgraph/Dockerfile-uv.j2 | 20 - .../templates/default-langgraph/Dockerfile.j2 | 13 +- .../default-openai-agents/Dockerfile-uv.j2 | 20 - .../default-openai-agents/Dockerfile.j2 | 13 +- .../default-openai-agents/project/acp.py.j2 | 17 +- .../default-pydantic-ai/Dockerfile-uv.j2 | 20 - .../default-pydantic-ai/Dockerfile.j2 | 13 +- .../cli/templates/default/Dockerfile-uv.j2 | 20 - .../lib/cli/templates/default/Dockerfile.j2 | 13 +- .../sync-claude-code/Dockerfile-uv.j2 | 20 - .../templates/sync-claude-code/Dockerfile.j2 | 13 +- .../cli/templates/sync-codex/Dockerfile-uv.j2 | 20 - .../cli/templates/sync-codex/Dockerfile.j2 | 13 +- .../templates/sync-langgraph/Dockerfile-uv.j2 | 20 - .../templates/sync-langgraph/Dockerfile.j2 | 13 +- .../Dockerfile-uv.j2 | 20 - .../Dockerfile.j2 | 13 +- .../project/agent.py.j2 | 17 +- .../sync-openai-agents/Dockerfile-uv.j2 | 20 - .../sync-openai-agents/Dockerfile.j2 | 13 +- .../sync-openai-agents/project/acp.py.j2 | 19 +- .../sync-pydantic-ai/Dockerfile-uv.j2 | 20 - .../templates/sync-pydantic-ai/Dockerfile.j2 | 13 +- .../lib/cli/templates/sync/Dockerfile-uv.j2 | 20 - .../lib/cli/templates/sync/Dockerfile.j2 | 13 +- .../temporal-claude-code/Dockerfile-uv.j2 | 20 - .../temporal-claude-code/Dockerfile.j2 | 13 +- .../project/workflow.py.j2 | 4 +- .../templates/temporal-codex/Dockerfile-uv.j2 | 20 - .../templates/temporal-codex/Dockerfile.j2 | 13 +- .../temporal-codex/project/workflow.py.j2 | 4 +- .../temporal-langgraph/Dockerfile-uv.j2 | 20 - .../temporal-langgraph/Dockerfile.j2 | 13 +- .../temporal-langgraph/project/workflow.py.j2 | 4 +- .../temporal-openai-agents/Dockerfile-uv.j2 | 20 - .../temporal-openai-agents/Dockerfile.j2 | 13 +- .../project/workflow.py.j2 | 23 +- .../temporal-pydantic-ai/Dockerfile-uv.j2 | 20 - .../temporal-pydantic-ai/Dockerfile.j2 | 13 +- .../project/workflow.py.j2 | 4 +- .../cli/templates/temporal/Dockerfile-uv.j2 | 20 - .../lib/cli/templates/temporal/Dockerfile.j2 | 13 +- .../templates/temporal/project/workflow.py.j2 | 4 +- src/agentex/lib/cli/tests/__init__.py | 0 .../lib/cli/tests/test_template_tracing.py | 57 -- src/agentex/lib/cli/utils/cli_utils.py | 12 - .../lib/core/adapters/llm/_genai_metrics.py | 301 --------- .../lib/core/adapters/llm/adapter_litellm.py | 21 +- .../lib/core/adapters/llm/tests/__init__.py | 0 .../adapters/llm/tests/test_genai_metrics.py | 413 ------------ .../lib/core/observability/sgp_obs_setup.py | 420 ------------ .../observability/tests/test_sgp_obs_setup.py | 613 ------------------ src/agentex/lib/core/temporal/logging.py | 34 - .../interceptors/context_interceptor.py | 4 +- .../lib/core/temporal/workers/worker.py | 40 +- .../lib/core/temporal/workflows/workflow.py | 4 +- src/agentex/lib/core/tracing/code_revision.py | 35 +- .../core/tracing/tracing_processor_manager.py | 104 --- src/agentex/lib/environment_variables.py | 10 +- .../lib/sdk/fastacp/base/base_acp_server.py | 97 --- .../lib/sdk/fastacp/base/tests/__init__.py | 0 .../fastacp/base/tests/test_shutdown_hooks.py | 344 ---------- src/agentex/lib/types/agent_card.py | 11 +- src/agentex/lib/utils/build_provenance.py | 3 +- src/agentex/lib/utils/logging.py | 159 +---- src/agentex/lib/utils/metadata_filters.py | 58 -- src/agentex/lib/utils/registration.py | 33 +- src/agentex/lib/utils/tests/__init__.py | 0 .../lib/utils/tests/test_logging_handover.py | 236 ------- tests/lib/cli/test_deploy_handlers.py | 64 -- tests/lib/cli/test_run_handlers_streaming.py | 180 ----- .../core/temporal/test_workflow_logging.py | 102 --- .../temporal/test_workflow_logging_replay.py | 82 --- .../temporal/workers/test_worker_tracing.py | 103 --- .../workers/test_worker_version_guard.py | 2 +- .../processors/test_sgp_tracing_processor.py | 12 +- tests/lib/core/tracing/test_code_revision.py | 34 +- tests/lib/test_agent_card.py | 60 -- tests/lib/test_agentex_worker.py | 163 ----- tests/lib/test_build_provenance.py | 3 +- tests/lib/test_client_timeout_env.py | 102 --- tests/lib/test_metadata_filters.py | 112 ---- tests/lib/utils/test_logging_level.py | 66 -- tests/lib/utils/test_registration.py | 49 -- tests/test_client.py | 4 +- tests/test_request_id_correlation.py | 150 ----- 105 files changed, 121 insertions(+), 5224 deletions(-) delete mode 100644 .circleci/config.yml delete mode 100644 .github/workflows/output-template.json delete mode 100644 .trufflehog-exclude.txt delete mode 100644 CODEOWNERS delete mode 100644 src/agentex/lib/cli/templates/PRIVATE_INDEX.md delete mode 100644 src/agentex/lib/cli/tests/__init__.py delete mode 100644 src/agentex/lib/cli/tests/test_template_tracing.py delete mode 100644 src/agentex/lib/core/adapters/llm/_genai_metrics.py delete mode 100644 src/agentex/lib/core/adapters/llm/tests/__init__.py delete mode 100644 src/agentex/lib/core/adapters/llm/tests/test_genai_metrics.py delete mode 100644 src/agentex/lib/core/observability/sgp_obs_setup.py delete mode 100644 src/agentex/lib/core/observability/tests/test_sgp_obs_setup.py delete mode 100644 src/agentex/lib/core/temporal/logging.py delete mode 100644 src/agentex/lib/sdk/fastacp/base/tests/__init__.py delete mode 100644 src/agentex/lib/sdk/fastacp/base/tests/test_shutdown_hooks.py delete mode 100644 src/agentex/lib/utils/metadata_filters.py delete mode 100644 src/agentex/lib/utils/tests/__init__.py delete mode 100644 src/agentex/lib/utils/tests/test_logging_handover.py delete mode 100644 tests/lib/cli/test_deploy_handlers.py delete mode 100644 tests/lib/cli/test_run_handlers_streaming.py delete mode 100644 tests/lib/core/temporal/test_workflow_logging.py delete mode 100644 tests/lib/core/temporal/test_workflow_logging_replay.py delete mode 100644 tests/lib/core/temporal/workers/test_worker_tracing.py delete mode 100644 tests/lib/test_client_timeout_env.py delete mode 100644 tests/lib/test_metadata_filters.py delete mode 100644 tests/lib/utils/test_logging_level.py delete mode 100644 tests/lib/utils/test_registration.py delete mode 100644 tests/test_request_id_correlation.py diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index e69de29bb..000000000 diff --git a/.github/workflows/output-template.json b/.github/workflows/output-template.json deleted file mode 100644 index e6303bcf9..000000000 --- a/.github/workflows/output-template.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "source": "github", - "organization": "\($organization)", - "timestamp": "\($time)", - "action": "\($action)", - "meta": { - "repository": "\($repository)", - "commit": "\($sha)", - "branch": "\($branch)", - "link": "\($link)" - }, - "results": [] -} diff --git a/.gitignore b/.gitignore index c437a2077..e4f254d86 100644 --- a/.gitignore +++ b/.gitignore @@ -22,64 +22,3 @@ Brewfile.lock.json # Claude Code local scheduled-task lock .claude/scheduled_tasks.lock - -# --------------------------------------------------------------------------- -# Local additions (not Stainless-generated) -# --------------------------------------------------------------------------- - -# Logs -*.log - -# Python build & packaging artifacts -*.py[cod] -*$py.class -*.so -build/ -sdist/ -wheels/ -*.egg -*.egg-info/ -.eggs/ - -# Test, lint & type-check caches -.pytest_cache/ -.ruff_cache/ -.nox/ -.tox/ -.hypothesis/ -.coverage -.coverage.* -coverage.xml -htmlcov/ -junit.xml -.dmypy.json -dmypy.json - -# Virtual environments (uv/rye/venv) -.venv*/ -venv/ -env/ -ENV/ - -# Jupyter (examples/ tutorials & demos) -.ipynb_checkpoints/ - -# Local env files -- keep the CLI template examples tracked -.env.* -!.env.example -!.env.example.* -!.env.template - -# Local databases -*.db -*.sqlite -*.sqlite3 - -# Editors -**/.idea -*.iml -*.code-workspace -*.sw[op] - -# Claude Code local overrides -.claude/settings.local.json diff --git a/.stats.yml b/.stats.yml index 955f7e2ac..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-ee0c521f0612c31b874bd595b90cd9209545bab603983552a1a7a87f38ed931e.yml -openapi_spec_hash: 917a1ffe9e353bed2740524dec786ed2 -config_hash: 593e89b291976a5e84e4c3c3f8324354 diff --git a/.trufflehog-exclude.txt b/.trufflehog-exclude.txt deleted file mode 100644 index bf4a81277..000000000 --- a/.trufflehog-exclude.txt +++ /dev/null @@ -1,13 +0,0 @@ -# Package manager lock files (contain integrity hashes that trigger false positives) -yarn\.lock -package-lock\.json -pnpm-lock\.yaml -Pipfile\.lock -uv\.lock - -# Other common false positive sources -go\.sum -Cargo\.lock -Gemfile\.lock -poetry\.lock -composer\.lock diff --git a/CODEOWNERS b/CODEOWNERS deleted file mode 100644 index e69de29bb..000000000 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 f129bb18c..b0b22ea51 100644 --- a/adk/pyproject.toml +++ b/adk/pyproject.toml @@ -4,7 +4,7 @@ # (agentex/{__init__.py, _*.py, types/, resources/}) ships from the slim # sibling package `agentex-client` which is pinned as a runtime dep. name = "agentex-sdk" -version = "0.28.0" +version = "0.25.0" description = "Agent Development Kit (ADK) overlay for the Agentex API — FastACP server, Temporal workflows, LLM provider integrations, observability" license = "Apache-2.0" authors = [ @@ -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/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 From 90387d275ba419ce5d8a2ccbfbda455c80a4708d Mon Sep 17 00:00:00 2001 From: Thi Quynh Nhu Nguyen Date: Thu, 24 Sep 2026 18:25:03 -0700 Subject: [PATCH 09/15] chore(release): point release-please config at the stock schema Defuses a latent break in the stlc migration. `stlc build` emits a stock (googleapis-flavoured) release-please config, and when the branch's copy carries a Stainless-fork marker stlc deliberately OVERWRITES it rather than preserving it -- the exception exists because upstream release-please hard-fails on fork configs, so preserving one forever would be worse. The problem is what the stock generator emits for this target. Verified by generating into a scratch directory rather than assuming: packages: { ".": {} } include-component-in-tag: false That drops the `adk` -> `agentex-sdk` package entirely, drops `component: agentex-client` from `.`, drops the linked-versions plugin, and turns component tags off. The resulting tag would be `v0.28.2`, which matches neither `agentex-client-v*` nor `agentex-sdk-v*` in bin/publish-pypi's `case` -- so it exits 1 and BOTH python packages stop publishing, not just the ADK. There is no config key that can declare the second package: the typescript generator builds `packages` from `subPackagePaths`, and the python target type has no equivalent field. So rather than defend the fork config, make it stock. The only fork-specific thing in it is this `$schema` URL -- `packages`, `plugins: [linked-versions]`, `include-component-in-tag` and `versioning: prerelease` are all upstream features. With no marker left, stlc's override returns early and ordinary scaffold-once preservation protects the two-package shape from here on. Deliberately a one-line change. `prerelease` stays as it is: this repo ships plain 0.28.x with non-prerelease GitHub Releases today, and whether that key should flip is a separate question that deserves its own test rather than riding along with a fix. --- release-please-config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 40ab658cfcb7050d1135974b6fa2fea816e70b2d Mon Sep 17 00:00:00 2001 From: "agentex-sdk-sync[bot]" <333044712+agentex-sdk-sync[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:23:54 +0000 Subject: [PATCH 10/15] ci(stlc): stand up Promote and Release Stainless-Generated-From: 6d9e61168b2e0b503f1491e7efdd748ea411f9dc --- .github/workflows/release-please.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/release-please.yml diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 000000000..bd33ae2b5 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,20 @@ +name: Release Please +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + if: github.repository == 'scaleapi/scale-agentex-python' + runs-on: ubuntu-latest + + steps: + - uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1 + id: release + with: + token: ${{ secrets.RELEASE_PLEASE_TOKEN }} From 24aa92b6f503ad4711d14d00ff18728acb40e135 Mon Sep 17 00:00:00 2001 From: Thi Quynh Nhu Nguyen Date: Fri, 25 Sep 2026 13:17:58 -0700 Subject: [PATCH 11/15] ci(release): wire release-please to the App token and the CLI Hand-edit of the stlc-generated release-please.yml, closing three gaps that would each break the release on its own. `.github/workflows/*.yml` is scaffold-once, so this survives later builds -- upstream's source cites exactly this PAT-to-App swap as why that preservation exists. Reapply if anyone ever runs `stlc build --rewrite-scaffold`. The generated file referenced secrets.RELEASE_PLEASE_TOKEN, which exists in neither production repo and which we do not want to create -- eliminating PATs was the point of the App migration. Replaced with an App-token mint. Not GITHUB_TOKEN: releases it creates do not trigger other workflows, so publish-pypi.yml / publish-npm.yml would never fire and the release would stop one hop short of the registry. It also used googleapis/release-please-action, which scale-agentex-typescript does not permit (`allowed_actions: selected`). The npx CLI form needs only actions/-owned steps, which `github_owned_allowed: true` covers on both production repos. No checkout is required -- release-please reads the config and manifest over the API. And it omitted `issues: write`, which release-please needs to drive its autorelease:pending -> autorelease:tagged labels. Without it the symptom is duplicate release pull requests, and nothing says why. Inert here: the `if: github.repository ==` guard means it only runs on production, which is where it lands via promote. It needs AGENTEX_SDK_SYNC_PRIVATE_KEY and AGENTEX_SDK_SYNC_APP_ID there, since a workflow only reads secrets from the repo it runs in. --- .github/workflows/release-please.yml | 72 +++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index bd33ae2b5..e9ddc6392 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -1,20 +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: write - pull-requests: write + 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: - - uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1 - id: release + - name: Mint release token + id: release-token + uses: actions/create-github-app-token@v2 with: - token: ${{ secrets.RELEASE_PLEASE_TOKEN }} + 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 From 6da13c31fc3bfa97d164de6425b55de2bf0266f8 Mon Sep 17 00:00:00 2001 From: Ari Nguyen Date: Fri, 25 Sep 2026 15:56:03 -0700 Subject: [PATCH 12/15] ci(lint-pr): exempt the SDK automation App from the PR title and base checks release-please runs here as a CLI under the agentex-sdk-sync App rather than as the release-please[bot] GitHub App, so its release pull requests are authored by agentex-sdk-sync[bot] and matched neither exempt list. Both checks therefore failed on every release PR: the title comes from release-please's configured pull-request-title-pattern, which is not a Conventional Commits type, and the base is main with no target-main label. The base check even posted a comment telling reviewers to retarget to next, on a pull request its own text calls out as the automation that main is reserved for. The same App opens the promote pull requests, so this covers those too. Co-Authored-By: Claude Opus 5 --- .github/workflows/lint-pr.yaml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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 From 86d29c730094e52890c48782712fd8f7e7625a7a Mon Sep 17 00:00:00 2001 From: Ari Nguyen Date: Fri, 25 Sep 2026 16:35:27 -0700 Subject: [PATCH 13/15] ci(bandit): add the jq template the scan's logging step feeds bandit-ci.yml's "Generate logger template" step runs `jq -n ... -f .github/workflows/output-template.json`, but that file was never added here, so the step died with "Could not open" and took the whole Bandit job with it -- every run, on every pull request. The step carries `shell: bash {0}` specifically so logging failures stay non-fatal, but that only drops `-e`; the step still fails when its last command does, and the jq call is the last command. Despite the .json extension this is a jq PROGRAM, not data: it is passed with -f and interpolates the --arg values. Copied verbatim from the org-canonical copies, which are byte-identical to each other. Surfaced by the first stlc promote: Bandit is one of several workflows the staging trunk carries that production does not, so promoting would have introduced a permanently red check there. Co-Authored-By: Claude Opus 5 --- .github/workflows/output-template.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .github/workflows/output-template.json diff --git a/.github/workflows/output-template.json b/.github/workflows/output-template.json new file mode 100644 index 000000000..e6303bcf9 --- /dev/null +++ b/.github/workflows/output-template.json @@ -0,0 +1,13 @@ +{ + "source": "github", + "organization": "\($organization)", + "timestamp": "\($time)", + "action": "\($action)", + "meta": { + "repository": "\($repository)", + "commit": "\($sha)", + "branch": "\($branch)", + "link": "\($link)" + }, + "results": [] +} From 73ea73eab019f2121a15dc595fc5b0a155fbfafa Mon Sep 17 00:00:00 2001 From: Ari Nguyen Date: Fri, 25 Sep 2026 16:51:05 -0700 Subject: [PATCH 14/15] fix(stlc): restore the custom-code tree a stale seal anchor reverted A build on 2026-09-21 (567abff "Build SDK") removed 5,224 lines across 105 files from this trunk -- 14 source files and 10 test files deleted outright, including lib/utils/metadata_filters.py, lib/core/observability/sgp_obs_setup.py, lib/core/temporal/logging.py and lib/core/adapters/llm/_genai_metrics.py. 95 of the 96 changed paths were under the hand-written src/agentex/lib/ and tests/ trees. An stlc seal replays the content diff between its `base` and `integrated` anchors, so anything the trunk gained past `integrated` is overwritten rather than merely skipped. The tracking file in effect named an `integrated` commit dated six days earlier that lived on a side branch, not on this trunk -- and the back-sync that had just brought the custom code here was not an ancestor of it. The replay therefore restored a tree predating the custom code entirely, and every command reported success. This restores the affected paths from the production trunk, which kept all 24 files and is the authoritative copy. Verified: the diff against production for src/agentex/lib/, tests/, adk/README.md and adk/pyproject.toml is now empty. Deliberately NOT restored, because this trunk is correct and production is stale or the file does not apply: src/agentex/_client.py production still defaults to localhost; this trunk carries https://agentex.sgp.scale.com, which is what stainless.yml configures .stats.yml the SaaS-only spec/config hashes are gone by design under self-hosted codegen .github/** this trunk's own CI work release-please-config.json Nothing shipped from the damage: both agentex-sdk-v0.28.1 and agentex-client-v0.28.1 resolve to the production trunk with the custom tree intact. It surfaced only because the promote gate refused to carry the deletion into production. Co-Authored-By: Claude Opus 5 --- adk/README.md | 20 + adk/pyproject.toml | 18 + src/agentex/lib/adk/utils/_modules/client.py | 44 ++ src/agentex/lib/cli/debug/debug_handlers.py | 3 + .../lib/cli/handlers/deploy_handlers.py | 10 + src/agentex/lib/cli/handlers/run_handlers.py | 60 +- .../lib/cli/templates/PRIVATE_INDEX.md | 74 +++ .../default-claude-code/Dockerfile-uv.j2 | 20 + .../default-claude-code/Dockerfile.j2 | 13 +- .../templates/default-codex/Dockerfile-uv.j2 | 20 + .../cli/templates/default-codex/Dockerfile.j2 | 13 +- .../default-langgraph/Dockerfile-uv.j2 | 20 + .../templates/default-langgraph/Dockerfile.j2 | 13 +- .../default-openai-agents/Dockerfile-uv.j2 | 20 + .../default-openai-agents/Dockerfile.j2 | 13 +- .../default-openai-agents/project/acp.py.j2 | 17 +- .../default-pydantic-ai/Dockerfile-uv.j2 | 20 + .../default-pydantic-ai/Dockerfile.j2 | 13 +- .../cli/templates/default/Dockerfile-uv.j2 | 20 + .../lib/cli/templates/default/Dockerfile.j2 | 13 +- .../sync-claude-code/Dockerfile-uv.j2 | 20 + .../templates/sync-claude-code/Dockerfile.j2 | 13 +- .../cli/templates/sync-codex/Dockerfile-uv.j2 | 20 + .../cli/templates/sync-codex/Dockerfile.j2 | 13 +- .../templates/sync-langgraph/Dockerfile-uv.j2 | 20 + .../templates/sync-langgraph/Dockerfile.j2 | 13 +- .../Dockerfile-uv.j2 | 20 + .../Dockerfile.j2 | 13 +- .../project/agent.py.j2 | 17 +- .../sync-openai-agents/Dockerfile-uv.j2 | 20 + .../sync-openai-agents/Dockerfile.j2 | 13 +- .../sync-openai-agents/project/acp.py.j2 | 19 +- .../sync-pydantic-ai/Dockerfile-uv.j2 | 20 + .../templates/sync-pydantic-ai/Dockerfile.j2 | 13 +- .../lib/cli/templates/sync/Dockerfile-uv.j2 | 20 + .../lib/cli/templates/sync/Dockerfile.j2 | 13 +- .../temporal-claude-code/Dockerfile-uv.j2 | 20 + .../temporal-claude-code/Dockerfile.j2 | 13 +- .../project/workflow.py.j2 | 4 +- .../templates/temporal-codex/Dockerfile-uv.j2 | 20 + .../templates/temporal-codex/Dockerfile.j2 | 13 +- .../temporal-codex/project/workflow.py.j2 | 4 +- .../temporal-langgraph/Dockerfile-uv.j2 | 20 + .../temporal-langgraph/Dockerfile.j2 | 13 +- .../temporal-langgraph/project/workflow.py.j2 | 4 +- .../temporal-openai-agents/Dockerfile-uv.j2 | 20 + .../temporal-openai-agents/Dockerfile.j2 | 13 +- .../project/workflow.py.j2 | 23 +- .../temporal-pydantic-ai/Dockerfile-uv.j2 | 20 + .../temporal-pydantic-ai/Dockerfile.j2 | 13 +- .../project/workflow.py.j2 | 4 +- .../cli/templates/temporal/Dockerfile-uv.j2 | 20 + .../lib/cli/templates/temporal/Dockerfile.j2 | 13 +- .../templates/temporal/project/workflow.py.j2 | 4 +- src/agentex/lib/cli/tests/__init__.py | 0 .../lib/cli/tests/test_template_tracing.py | 57 ++ src/agentex/lib/cli/utils/cli_utils.py | 12 + .../lib/core/adapters/llm/_genai_metrics.py | 301 +++++++++ .../lib/core/adapters/llm/adapter_litellm.py | 21 +- .../lib/core/adapters/llm/tests/__init__.py | 0 .../adapters/llm/tests/test_genai_metrics.py | 413 ++++++++++++ .../lib/core/observability/sgp_obs_setup.py | 420 ++++++++++++ .../observability/tests/test_sgp_obs_setup.py | 613 ++++++++++++++++++ src/agentex/lib/core/temporal/logging.py | 34 + .../interceptors/context_interceptor.py | 4 +- .../lib/core/temporal/workers/worker.py | 40 +- .../lib/core/temporal/workflows/workflow.py | 4 +- src/agentex/lib/core/tracing/code_revision.py | 35 +- .../core/tracing/tracing_processor_manager.py | 104 +++ src/agentex/lib/environment_variables.py | 10 +- .../lib/sdk/fastacp/base/base_acp_server.py | 97 +++ .../lib/sdk/fastacp/base/tests/__init__.py | 0 .../fastacp/base/tests/test_shutdown_hooks.py | 344 ++++++++++ src/agentex/lib/types/agent_card.py | 11 +- src/agentex/lib/utils/build_provenance.py | 3 +- src/agentex/lib/utils/logging.py | 159 ++++- src/agentex/lib/utils/metadata_filters.py | 58 ++ src/agentex/lib/utils/registration.py | 33 +- src/agentex/lib/utils/tests/__init__.py | 0 .../lib/utils/tests/test_logging_handover.py | 236 +++++++ tests/lib/cli/test_deploy_handlers.py | 64 ++ tests/lib/cli/test_run_handlers_streaming.py | 180 +++++ .../core/temporal/test_workflow_logging.py | 102 +++ .../temporal/test_workflow_logging_replay.py | 82 +++ .../temporal/workers/test_worker_tracing.py | 103 +++ .../workers/test_worker_version_guard.py | 2 +- .../processors/test_sgp_tracing_processor.py | 12 +- tests/lib/core/tracing/test_code_revision.py | 34 +- tests/lib/test_agent_card.py | 60 ++ tests/lib/test_agentex_worker.py | 163 +++++ tests/lib/test_build_provenance.py | 3 +- tests/lib/test_client_timeout_env.py | 102 +++ tests/lib/test_metadata_filters.py | 112 ++++ tests/lib/utils/test_logging_level.py | 66 ++ tests/lib/utils/test_registration.py | 49 ++ tests/test_client.py | 4 +- tests/test_request_id_correlation.py | 150 +++++ 97 files changed, 5131 insertions(+), 118 deletions(-) create mode 100644 src/agentex/lib/cli/templates/PRIVATE_INDEX.md create mode 100644 src/agentex/lib/cli/tests/__init__.py create mode 100644 src/agentex/lib/cli/tests/test_template_tracing.py create mode 100644 src/agentex/lib/core/adapters/llm/_genai_metrics.py create mode 100644 src/agentex/lib/core/adapters/llm/tests/__init__.py create mode 100644 src/agentex/lib/core/adapters/llm/tests/test_genai_metrics.py create mode 100644 src/agentex/lib/core/observability/sgp_obs_setup.py create mode 100644 src/agentex/lib/core/observability/tests/test_sgp_obs_setup.py create mode 100644 src/agentex/lib/core/temporal/logging.py create mode 100644 src/agentex/lib/sdk/fastacp/base/tests/__init__.py create mode 100644 src/agentex/lib/sdk/fastacp/base/tests/test_shutdown_hooks.py create mode 100644 src/agentex/lib/utils/metadata_filters.py create mode 100644 src/agentex/lib/utils/tests/__init__.py create mode 100644 src/agentex/lib/utils/tests/test_logging_handover.py create mode 100644 tests/lib/cli/test_deploy_handlers.py create mode 100644 tests/lib/cli/test_run_handlers_streaming.py create mode 100644 tests/lib/core/temporal/test_workflow_logging.py create mode 100644 tests/lib/core/temporal/test_workflow_logging_replay.py create mode 100644 tests/lib/core/temporal/workers/test_worker_tracing.py create mode 100644 tests/lib/test_client_timeout_env.py create mode 100644 tests/lib/test_metadata_filters.py create mode 100644 tests/lib/utils/test_logging_level.py create mode 100644 tests/lib/utils/test_registration.py create mode 100644 tests/test_request_id_correlation.py diff --git a/adk/README.md b/adk/README.md index 206ba993b..ef7c553d9 100644 --- a/adk/README.md +++ b/adk/README.md @@ -27,6 +27,26 @@ This automatically pulls in [`agentex-client`](../) (the slim Stainless-generate The two packages contribute disjoint files to the `agentex.*` namespace — `agentex/lib/*` ships only from `agentex-sdk`. +## Workflow logging + +Use the workflow logger in Temporal workflow code: + +```python +from agentex.lib.core.temporal.logging import make_workflow_logger + +logger = make_workflow_logger(__name__) +``` + +It suppresses logs while Temporal replays recorded history and adds top-level +`workflow_id` and `run_id` fields during workflow execution. It preserves the +message, caller fields, and exception details. Outside workflows, including in +activities, it behaves like the ordinary SDK logger. + +New Temporal templates use this helper. Existing agents must replace their own +workflow loggers to get the same behavior. This does not create trace context or +add trace IDs to workflows that lack it. Temporal's worker diagnostics still report +replay failures. + ## Repo layout This package is hand-authored and lives at `adk/` inside [scaleapi/scale-agentex-python](https://github.com/scaleapi/scale-agentex-python). Stainless codegen never touches `adk/**` — it's outside the generated surface. The sibling `agentex-client` package lives at the repo root and IS Stainless-generated. diff --git a/adk/pyproject.toml b/adk/pyproject.toml index f251f3afc..2ca3dc407 100644 --- a/adk/pyproject.toml +++ b/adk/pyproject.toml @@ -65,6 +65,7 @@ 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", @@ -76,6 +77,23 @@ 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/src/agentex/lib/adk/utils/_modules/client.py b/src/agentex/lib/adk/utils/_modules/client.py index 725289631..5312b7b6a 100644 --- a/src/agentex/lib/adk/utils/_modules/client.py +++ b/src/agentex/lib/adk/utils/_modules/client.py @@ -1,3 +1,4 @@ +import os from typing import override import httpx @@ -26,7 +27,50 @@ def auth_flow(self, request): yield request +# HTTP timeouts for the AgentEx client, in seconds. Defaults match the SDK's +# DEFAULT_TIMEOUT, so leaving these unset changes nothing. +_TIMEOUT_ENV_DEFAULTS = { + "connect": ("AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS", 5.0), + "read": ("AGENTEX_CLIENT_READ_TIMEOUT_SECONDS", 300.0), + "write": ("AGENTEX_CLIENT_WRITE_TIMEOUT_SECONDS", 300.0), + "pool": ("AGENTEX_CLIENT_POOL_TIMEOUT_SECONDS", 300.0), +} + + +def _timeout_from_env() -> httpx.Timeout: + """Build the client timeout from environment variables. + + Read from ``os.environ`` rather than from ``EnvironmentVariables``. That model + is loaded by worker startup and by ``EnvAuth.auth_flow`` on every request, and + ``agentex.lib.adk.utils`` builds a client at import time, so a field added + there would make a malformed timeout break all three. Reading here keeps the + blast radius to the one value that is actually wrong. + + The connect timeout is the one worth raising: an AgentEx backend accepts + connections serially, so connect latency grows with the number of callers and + the 5s default is reached when a few hundred are in flight. + """ + values = {} + for field, (env_var, default) in _TIMEOUT_ENV_DEFAULTS.items(): + raw = os.environ.get(env_var) + if raw is None or raw.strip() == "": + values[field] = default + continue + try: + values[field] = float(raw) + except ValueError as exc: + raise ValueError(f"{env_var} must be a number in seconds, got {raw!r}") from exc + return httpx.Timeout(**values) + + def create_async_agentex_client(**kwargs) -> AsyncAgentex: + """Create an AsyncAgentex client. + + An explicit ``timeout=`` always wins; otherwise the timeout comes from the + AGENTEX_CLIENT_*_TIMEOUT_SECONDS environment variables. + """ + if "timeout" not in kwargs: + kwargs["timeout"] = _timeout_from_env() client = AsyncAgentex(**kwargs) client._client.auth = EnvAuth() return client diff --git a/src/agentex/lib/cli/debug/debug_handlers.py b/src/agentex/lib/cli/debug/debug_handlers.py index 98746387f..a27d682cd 100644 --- a/src/agentex/lib/cli/debug/debug_handlers.py +++ b/src/agentex/lib/cli/debug/debug_handlers.py @@ -16,6 +16,7 @@ pass from agentex.lib.utils.logging import make_logger +from agentex.lib.cli.utils.cli_utils import SUBPROCESS_STREAM_LIMIT from .debug_config import DebugConfig, resolve_debug_port @@ -66,6 +67,7 @@ async def start_temporal_worker_debug( env=debug_env, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, + limit=SUBPROCESS_STREAM_LIMIT, ) @@ -119,6 +121,7 @@ async def start_acp_server_debug( env=debug_env, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, + limit=SUBPROCESS_STREAM_LIMIT, ) diff --git a/src/agentex/lib/cli/handlers/deploy_handlers.py b/src/agentex/lib/cli/handlers/deploy_handlers.py index 605d91709..e1cd1965c 100644 --- a/src/agentex/lib/cli/handlers/deploy_handlers.py +++ b/src/agentex/lib/cli/handlers/deploy_handlers.py @@ -389,6 +389,8 @@ def merge_deployment_configs( _deep_merge(helm_values, agent_env_config.helm_overrides) logger.info(f"After-merge helm values: {helm_values}") + _stamp_agent_version(helm_values, set(all_env_vars) | {var["name"] for var in secret_env_vars}) + # Set final environment variables # Environment variable precedence: manifest -> environments.yaml -> secrets (highest) if all_env_vars: @@ -430,6 +432,14 @@ def _deep_merge(base_dict: dict[str, Any], override_dict: dict[str, Any]) -> Non base_dict[key] = value +def _stamp_agent_version(helm_values: dict[str, Any], declared_env_names: set[str]) -> None: + """Set global.agent.version from the merged image tag unless the deployment declares AGENT_VERSION itself.""" + if EnvVarKeys.AGENT_VERSION.value in declared_env_names: + # Chart >=0.6.0 renders global.agent.version as a second AGENT_VERSION env entry. + return + helm_values["global"]["agent"].setdefault("version", helm_values["global"]["image"]["tag"]) + + def create_helm_values_file(helm_values: dict[str, Any]) -> str: """Create a temporary helm values file""" with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: diff --git a/src/agentex/lib/cli/handlers/run_handlers.py b/src/agentex/lib/cli/handlers/run_handlers.py index 3a43e95dd..18ee84e93 100644 --- a/src/agentex/lib/cli/handlers/run_handlers.py +++ b/src/agentex/lib/cli/handlers/run_handlers.py @@ -12,6 +12,7 @@ from agentex.lib.cli.debug import DebugConfig, start_acp_server_debug, start_temporal_worker_debug from agentex.lib.utils.logging import make_logger from agentex.config.agent_manifest import AgentManifest +from agentex.lib.cli.utils.cli_utils import SUBPROCESS_STREAM_LIMIT from agentex.lib.cli.utils.path_utils import ( get_file_paths, calculate_uvicorn_target_for_local, @@ -23,6 +24,11 @@ logger = make_logger(__name__) console = Console() +# How many consecutive unreadable lines to skip before giving up on the stream. +# Skipping is only known-safe for the limit-overrun case; this bounds the damage +# if some other error repeats without consuming anything. +MAX_CONSECUTIVE_READ_ERRORS = 100 + class RunError(Exception): """An error occurred during agent run""" @@ -215,6 +221,7 @@ async def start_acp_server( env=env, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, + limit=SUBPROCESS_STREAM_LIMIT, ) @@ -234,23 +241,68 @@ async def start_temporal_worker( env=env, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, + limit=SUBPROCESS_STREAM_LIMIT, ) async def stream_process_output(process: asyncio.subprocess.Process, prefix: str): - """Stream process output with prefix""" + """Stream process output with prefix. + + This loop is the only reader of the child's stdout pipe. If it ever stops + reading, the pipe fills and the child blocks forever inside ``write()``, + which presents as a silent freeze: 0% CPU, no further logs, no traceback. + So a single unreadable line must never end the loop. + """ try: if process.stdout is None: return + consecutive_read_errors = 0 while True: - line = await process.stdout.readline() + try: + line = await process.stdout.readline() + except ValueError as e: + # readline() raises ValueError when a line exceeds the stream limit. + # In *that* case it has already discarded the line and resumed the + # transport, so skipping it makes guaranteed progress. Any other + # ValueError carries no such guarantee, and retrying it forever would + # spin without draining. We cannot tell the two apart (readline + # flattens LimitOverrunError into a bare ValueError), so bound the + # retries and let the outer handler report the hang risk. + consecutive_read_errors += 1 + if consecutive_read_errors > MAX_CONSECUTIVE_READ_ERRORS: + raise + logger.warning( + f"Skipping an unreadable line from {prefix}: {e!r} " + f"(consecutive failure {consecutive_read_errors}/{MAX_CONSECUTIVE_READ_ERRORS}). " + f"If this says the chunk exceeded the limit, raise limit= on this " + f"process's create_subprocess_exec." + ) + continue + + consecutive_read_errors = 0 + if not line: break - decoded_line = line.decode("utf-8").rstrip() + + try: + decoded_line = line.decode("utf-8").rstrip() + except UnicodeDecodeError as e: + logger.warning(f"Dropped an undecodable log line from {prefix} ({e}).") + continue + if decoded_line: # Only print non-empty lines console.print(f"[dim]{prefix}:[/dim] {decoded_line}") except Exception as e: - logger.debug(f"Output streaming ended for {prefix}: {e}") + # The escalation path, including for the re-raise above. Anything reaching + # here ends the loop, so the child is now at risk of blocking on a full pipe. + # Warning rather than debug: this used to be a debug() that make_logger could + # never emit, which is why three freezes produced no clue. + # CancelledError derives from BaseException, so the auto-reload path that + # cancels these tasks passes straight through and is unaffected. + logger.warning( + f"Output streaming for {prefix} stopped on {e!r}. " + f"Nothing is draining its stdout now, so {prefix} will hang once the pipe fills." + ) async def run_agent(manifest_path: str, debug_config: "DebugConfig | None" = None): diff --git a/src/agentex/lib/cli/templates/PRIVATE_INDEX.md b/src/agentex/lib/cli/templates/PRIVATE_INDEX.md new file mode 100644 index 000000000..932bd9819 --- /dev/null +++ b/src/agentex/lib/cli/templates/PRIVATE_INDEX.md @@ -0,0 +1,74 @@ +# 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 93d0f82d1..8a22d0f89 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,7 +34,20 @@ 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 @@ -42,6 +55,13 @@ 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 d714d96f9..3556f6dfd 100644 --- a/src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 @@ -33,8 +33,19 @@ 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 uv pip install --system -r requirements.txt +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 # 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 02860b9b9..b3c03c988 100644 --- a/src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 @@ -34,7 +34,20 @@ 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 @@ -42,6 +55,13 @@ 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 1a8eb1484..c0b3fc385 100644 --- a/src/agentex/lib/cli/templates/default-codex/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default-codex/Dockerfile.j2 @@ -33,8 +33,19 @@ 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 uv pip install --system -r requirements.txt +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 # 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 dd3035f7b..9b4f8d25b 100644 --- a/src/agentex/lib/cli/templates/default-langgraph/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default-langgraph/Dockerfile-uv.j2 @@ -30,7 +30,20 @@ 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 @@ -38,6 +51,13 @@ 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 0395caf74..7f148e274 100644 --- a/src/agentex/lib/cli/templates/default-langgraph/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default-langgraph/Dockerfile.j2 @@ -29,8 +29,19 @@ 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 uv pip install --system -r requirements.txt +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 # 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 dd3035f7b..9b4f8d25b 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,7 +30,20 @@ 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 @@ -38,6 +51,13 @@ 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 056d60b96..0a416aa38 100644 --- a/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile.j2 @@ -29,8 +29,19 @@ 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 uv pip install --system -r requirements.txt +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 # 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 66ee31243..ad8b6e41d 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_tracing_disabled +from agents import Agent, Runner, function_tool, set_trace_processors from agentex.lib import adk from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams @@ -34,10 +34,17 @@ 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 -# 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) +# 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([]) 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 dd3035f7b..9b4f8d25b 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,7 +30,20 @@ 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 @@ -38,6 +51,13 @@ 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 0395caf74..7f148e274 100644 --- a/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile.j2 @@ -29,8 +29,19 @@ 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 uv pip install --system -r requirements.txt +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 # 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 dd3035f7b..9b4f8d25b 100644 --- a/src/agentex/lib/cli/templates/default/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default/Dockerfile-uv.j2 @@ -30,7 +30,20 @@ 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 @@ -38,6 +51,13 @@ 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 0395caf74..7f148e274 100644 --- a/src/agentex/lib/cli/templates/default/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default/Dockerfile.j2 @@ -29,8 +29,19 @@ 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 uv pip install --system -r requirements.txt +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 # 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 93d0f82d1..8a22d0f89 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,7 +34,20 @@ 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 @@ -42,6 +55,13 @@ 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 6cdc70799..cd0338d18 100644 --- a/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.j2 @@ -33,8 +33,19 @@ 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 uv pip install --system -r requirements.txt +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 # 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 02860b9b9..b3c03c988 100644 --- a/src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 @@ -34,7 +34,20 @@ 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 @@ -42,6 +55,13 @@ 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 afa4470d9..79293756d 100644 --- a/src/agentex/lib/cli/templates/sync-codex/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync-codex/Dockerfile.j2 @@ -33,8 +33,19 @@ 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 uv pip install --system -r requirements.txt +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 # 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 dd3035f7b..9b4f8d25b 100644 --- a/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile-uv.j2 @@ -30,7 +30,20 @@ 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 @@ -38,6 +51,13 @@ 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 4d9f41d45..d0c204e47 100644 --- a/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile.j2 @@ -29,8 +29,19 @@ 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 uv pip install --system -r requirements.txt +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 # 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 dd3035f7b..9b4f8d25b 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,7 +30,20 @@ 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 @@ -38,6 +51,13 @@ 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 4d9f41d45..d0c204e47 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,8 +29,19 @@ 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 uv pip install --system -r requirements.txt +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 # 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 07546bffb..315c5a6ae 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_tracing_disabled +from agents import Runner, set_trace_processors from agents.sandbox import SandboxAgent, SandboxRunConfig from agents.run_config import RunConfig from agents.sandbox.sandboxes.unix_local import ( @@ -25,10 +25,17 @@ from agents.sandbox.sandboxes.unix_local import ( from project.tools import get_capabilities -# 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) +# 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([]) 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 dd3035f7b..9b4f8d25b 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,7 +30,20 @@ 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 @@ -38,6 +51,13 @@ 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 4d9f41d45..d0c204e47 100644 --- a/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile.j2 @@ -29,8 +29,19 @@ 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 uv pip install --system -r requirements.txt +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 # 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 41029f2ce..07849e81d 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,12 +13,19 @@ 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_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 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([]) 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 dd3035f7b..9b4f8d25b 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,7 +30,20 @@ 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 @@ -38,6 +51,13 @@ 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 4d9f41d45..d0c204e47 100644 --- a/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile.j2 @@ -29,8 +29,19 @@ 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 uv pip install --system -r requirements.txt +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 # 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 dd3035f7b..9b4f8d25b 100644 --- a/src/agentex/lib/cli/templates/sync/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync/Dockerfile-uv.j2 @@ -30,7 +30,20 @@ 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 @@ -38,6 +51,13 @@ 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 4d9f41d45..d0c204e47 100644 --- a/src/agentex/lib/cli/templates/sync/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync/Dockerfile.j2 @@ -29,8 +29,19 @@ 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 uv pip install --system -r requirements.txt +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 # 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 f8746c573..1665bceb1 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,7 +42,20 @@ 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 @@ -50,6 +63,13 @@ 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 225863607..1297b7bd7 100644 --- a/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 @@ -41,8 +41,19 @@ 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 uv pip install --system -r requirements.txt +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 # 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 8191ad80f..108316ab9 100644 --- a/src/agentex/lib/cli/templates/temporal-claude-code/project/workflow.py.j2 +++ b/src/agentex/lib/cli/templates/temporal-claude-code/project/workflow.py.j2 @@ -27,7 +27,7 @@ from temporalio import workflow from agentex.lib import adk from agentex.lib.types.acp import SendEventParams, CreateTaskParams from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger +from agentex.lib.core.temporal.logging import make_workflow_logger from agentex.types.text_content import TextContent from agentex.lib.environment_variables import EnvironmentVariables from agentex.lib.core.temporal.types.workflow import SignalName @@ -52,7 +52,7 @@ if environment_variables.WORKFLOW_NAME is None: if environment_variables.AGENT_NAME is None: raise ValueError("Environment variable AGENT_NAME is not set") -logger = make_logger(__name__) +logger = make_workflow_logger(__name__) @workflow.defn(name=environment_variables.WORKFLOW_NAME) diff --git a/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 index 7e31387fa..41d83e31c 100644 --- a/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 @@ -42,7 +42,20 @@ 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 @@ -50,6 +63,13 @@ 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 0ae4e2079..d77d8073f 100644 --- a/src/agentex/lib/cli/templates/temporal-codex/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal-codex/Dockerfile.j2 @@ -41,8 +41,19 @@ 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 uv pip install --system -r requirements.txt +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 # 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 1004ebfb8..9890efab8 100644 --- a/src/agentex/lib/cli/templates/temporal-codex/project/workflow.py.j2 +++ b/src/agentex/lib/cli/templates/temporal-codex/project/workflow.py.j2 @@ -29,7 +29,7 @@ from temporalio import workflow from agentex.lib import adk from agentex.lib.types.acp import SendEventParams, CreateTaskParams from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger +from agentex.lib.core.temporal.logging import make_workflow_logger from agentex.types.text_content import TextContent from agentex.lib.environment_variables import EnvironmentVariables from agentex.lib.core.temporal.types.workflow import SignalName @@ -54,7 +54,7 @@ if environment_variables.WORKFLOW_NAME is None: if environment_variables.AGENT_NAME is None: raise ValueError("Environment variable AGENT_NAME is not set") -logger = make_logger(__name__) +logger = make_workflow_logger(__name__) MODEL = os.environ.get("CODEX_MODEL", "o4-mini") diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 index 6746869df..56b4d949c 100644 --- a/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 @@ -36,7 +36,20 @@ 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 @@ -44,6 +57,13 @@ 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 ba47485a9..5bb133a22 100644 --- a/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile.j2 @@ -35,8 +35,19 @@ 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 uv pip install --system -r requirements.txt +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 # 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 14bafabc1..d0db42bc8 100644 --- a/src/agentex/lib/cli/templates/temporal-langgraph/project/workflow.py.j2 +++ b/src/agentex/lib/cli/templates/temporal-langgraph/project/workflow.py.j2 @@ -37,7 +37,7 @@ from project.graph import GRAPH_NAME, build_graph from agentex.lib.adk import emit_langgraph_messages from agentex.protocol.acp import SendEventParams, CreateTaskParams from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger +from agentex.lib.core.temporal.logging import make_workflow_logger from agentex.types.text_content import TextContent from agentex.lib.environment_variables import EnvironmentVariables from agentex.lib.core.temporal.types.workflow import SignalName @@ -64,7 +64,7 @@ if environment_variables.WORKFLOW_NAME is None: if environment_variables.AGENT_NAME is None: raise ValueError("Environment variable AGENT_NAME is not set") -logger = make_logger(__name__) +logger = make_workflow_logger(__name__) @workflow.defn(name=environment_variables.WORKFLOW_NAME) diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile-uv.j2 index 0d9801016..a674d7d35 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,7 +36,20 @@ 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 @@ -44,6 +57,13 @@ 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 4c1798c42..a9a63757d 100644 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile.j2 @@ -35,8 +35,19 @@ 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 uv pip install --system -r requirements.txt +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 # 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 af8b7a299..6897cd5a8 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,15 +7,22 @@ from agentex.lib import adk from agentex.protocol.acp import CreateTaskParams, SendEventParams from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow from agentex.lib.core.temporal.types.workflow import SignalName -from agentex.lib.utils.logging import make_logger +from agentex.lib.core.temporal.logging import make_workflow_logger from agentex.types.text_content import TextContent from agentex.lib.environment_variables import EnvironmentVariables -from agents import Agent, Runner, set_tracing_disabled - -# 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 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 agentex.lib.core.temporal.plugins.openai_agents.hooks.hooks import TemporalStreamingHooks from pydantic import BaseModel @@ -37,7 +44,7 @@ if environment_variables.WORKFLOW_NAME is None: if environment_variables.AGENT_NAME is None: raise ValueError("Environment variable AGENT_NAME is not set") -logger = make_logger(__name__) +logger = make_workflow_logger(__name__) # Setup tracing for SGP (Scale GenAI Platform) # This enables visibility into your agent's execution in the SGP dashboard diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile-uv.j2 index 0d9801016..a674d7d35 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,7 +36,20 @@ 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 @@ -44,6 +57,13 @@ 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 4c1798c42..a9a63757d 100644 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile.j2 @@ -35,8 +35,19 @@ 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 uv pip install --system -r requirements.txt +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 # 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 6dcca3002..0f25e961c 100644 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/workflow.py.j2 +++ b/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/workflow.py.j2 @@ -25,7 +25,7 @@ from project.agent import TaskDeps, temporal_agent from agentex.lib import adk from agentex.protocol.acp import SendEventParams, CreateTaskParams from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger +from agentex.lib.core.temporal.logging import make_workflow_logger from agentex.types.text_content import TextContent from agentex.lib.environment_variables import EnvironmentVariables from agentex.lib.core.temporal.types.workflow import SignalName @@ -55,7 +55,7 @@ if environment_variables.WORKFLOW_NAME is None: if environment_variables.AGENT_NAME is None: raise ValueError("Environment variable AGENT_NAME is not set") -logger = make_logger(__name__) +logger = make_workflow_logger(__name__) @workflow.defn(name=environment_variables.WORKFLOW_NAME) diff --git a/src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 index 0d9801016..a674d7d35 100644 --- a/src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 @@ -36,7 +36,20 @@ 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 @@ -44,6 +57,13 @@ 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 4c1798c42..a9a63757d 100644 --- a/src/agentex/lib/cli/templates/temporal/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal/Dockerfile.j2 @@ -35,8 +35,19 @@ 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 uv pip install --system -r requirements.txt +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 # 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 56db5abf3..8c23ecfc1 100644 --- a/src/agentex/lib/cli/templates/temporal/project/workflow.py.j2 +++ b/src/agentex/lib/cli/templates/temporal/project/workflow.py.j2 @@ -6,7 +6,7 @@ from agentex.lib import adk from agentex.protocol.acp import CreateTaskParams, SendEventParams from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow from agentex.lib.core.temporal.types.workflow import SignalName -from agentex.lib.utils.logging import make_logger +from agentex.lib.core.temporal.logging import make_workflow_logger from agentex.types.text_content import TextContent from agentex.lib.environment_variables import EnvironmentVariables @@ -18,7 +18,7 @@ if environment_variables.WORKFLOW_NAME is None: if environment_variables.AGENT_NAME is None: raise ValueError("Environment variable AGENT_NAME is not set") -logger = make_logger(__name__) +logger = make_workflow_logger(__name__) @workflow.defn(name=environment_variables.WORKFLOW_NAME) class {{ workflow_class }}(BaseWorkflow): diff --git a/src/agentex/lib/cli/tests/__init__.py b/src/agentex/lib/cli/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/cli/tests/test_template_tracing.py b/src/agentex/lib/cli/tests/test_template_tracing.py new file mode 100644 index 000000000..adb76fd08 --- /dev/null +++ b/src/agentex/lib/cli/tests/test_template_tracing.py @@ -0,0 +1,57 @@ +"""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 43b3fba62..4238e8fd9 100644 --- a/src/agentex/lib/cli/utils/cli_utils.py +++ b/src/agentex/lib/cli/utils/cli_utils.py @@ -5,6 +5,18 @@ console = Console() +# asyncio's StreamReader defaults to 64 KiB, and a single log line above that makes +# readline() raise. Agents legitimately emit large lines (serialized charts, payloads +# echoed back by validation errors), so give the reader room before it has to drop one. +# +# Lives here rather than beside its users so that both the normal spawns in +# cli/handlers/run_handlers.py and the debug spawns in cli/debug/debug_handlers.py can +# import it: run_handlers imports cli.debug, so the constant cannot live in either one. +# Keep the two in step. A subprocess left on the asyncio default overruns far more +# easily, and enough consecutive overruns exhaust the reader's retry bound and stop it +# draining, which is the deadlock the bound is there to avoid. +SUBPROCESS_STREAM_LIMIT = 8 * 1024 * 1024 + def handle_questionary_cancellation( result: str | None, operation: str = "operation" diff --git a/src/agentex/lib/core/adapters/llm/_genai_metrics.py b/src/agentex/lib/core/adapters/llm/_genai_metrics.py new file mode 100644 index 000000000..df16f5b94 --- /dev/null +++ b/src/agentex/lib/core/adapters/llm/_genai_metrics.py @@ -0,0 +1,301 @@ +"""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 7935f5f49..8fb1602aa 100644 --- a/src/agentex/lib/core/adapters/llm/adapter_litellm.py +++ b/src/agentex/lib/core/adapters/llm/adapter_litellm.py @@ -6,6 +6,7 @@ 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__) @@ -36,9 +37,13 @@ async def acompletion(self, *args, **kwargs) -> Completion: "Please use self.acompletion_stream instead of self.acompletion to stream responses" ) - # Return a single completion for non-streaming - response = await llm.acompletion(*args, **kwargs) - return Completion.model_validate(response) + # `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) @override async def acompletion_stream( @@ -47,5 +52,11 @@ async def acompletion_stream( if not kwargs.get("stream"): raise ValueError("To use streaming, please set stream=True in the kwargs") - async for chunk in await llm.acompletion(*args, **kwargs): # type: ignore[misc] - yield Completion.model_validate(chunk) + 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) diff --git a/src/agentex/lib/core/adapters/llm/tests/__init__.py b/src/agentex/lib/core/adapters/llm/tests/__init__.py new file mode 100644 index 000000000..e69de29bb 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 new file mode 100644 index 000000000..37f4992f5 --- /dev/null +++ b/src/agentex/lib/core/adapters/llm/tests/test_genai_metrics.py @@ -0,0 +1,413 @@ +"""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 new file mode 100644 index 000000000..3485e568e --- /dev/null +++ b/src/agentex/lib/core/observability/sgp_obs_setup.py @@ -0,0 +1,420 @@ +"""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 new file mode 100644 index 000000000..e39a6f4a9 --- /dev/null +++ b/src/agentex/lib/core/observability/tests/test_sgp_obs_setup.py @@ -0,0 +1,613 @@ +"""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 new file mode 100644 index 000000000..094388525 --- /dev/null +++ b/src/agentex/lib/core/temporal/logging.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import Any, override +from collections.abc import MutableMapping + +from temporalio import workflow + +from agentex.lib.utils.logging import make_logger + + +class WorkflowLoggerAdapter(workflow.LoggerAdapter): + """Skip workflow replay logs and add IDs without changing non-workflow logs.""" + + @override + def isEnabledFor(self, level: int) -> bool: + if not workflow.in_workflow(): + return self.logger.isEnabledFor(level) + return super().isEnabledFor(level) + + @override + def process(self, msg: Any, kwargs: MutableMapping[str, Any]) -> tuple[Any, MutableMapping[str, Any]]: + if workflow.in_workflow(): + info = workflow.info() + kwargs["extra"] = { + "workflow_id": info.workflow_id, + "run_id": info.run_id, + **(kwargs.get("extra") or {}), + } + return msg, kwargs + + +def make_workflow_logger(name: str) -> WorkflowLoggerAdapter: + """Create an SDK logger that suppresses replay and adds workflow/run IDs.""" + return WorkflowLoggerAdapter(make_logger(name), {}) diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/interceptors/context_interceptor.py b/src/agentex/lib/core/temporal/plugins/openai_agents/interceptors/context_interceptor.py index 893f75f28..26dce2994 100644 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/interceptors/context_interceptor.py +++ b/src/agentex/lib/core/temporal/plugins/openai_agents/interceptors/context_interceptor.py @@ -22,8 +22,10 @@ ) from temporalio.converter import default +from agentex.lib.core.temporal.logging import WorkflowLoggerAdapter + # Set up logging -logger = logging.getLogger("context.interceptor") +logger = WorkflowLoggerAdapter(logging.getLogger("context.interceptor"), {}) # Global context variables that models can read # These are thread-safe and work across async boundaries diff --git a/src/agentex/lib/core/temporal/workers/worker.py b/src/agentex/lib/core/temporal/workers/worker.py index 9f0aa2da3..ba8f87de5 100644 --- a/src/agentex/lib/core/temporal/workers/worker.py +++ b/src/agentex/lib/core/temporal/workers/worker.py @@ -31,7 +31,10 @@ 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__) @@ -178,6 +181,7 @@ def __init__( metrics_headers: dict[str, str] | None = None, metrics_use_http: bool = False, metrics_temporality_delta: bool = False, + agent_card: Any | None = None, ): self.task_queue = task_queue self.activity_handles = [] @@ -196,6 +200,7 @@ def __init__( self.metrics_temporality_delta = metrics_temporality_delta self.payload_codec = payload_codec self.data_converter = data_converter + self.agent_card = agent_card @overload async def run( @@ -220,6 +225,18 @@ 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() @@ -256,16 +273,29 @@ async def run( max_concurrent_activities=self.max_concurrent_activities, build_id=str(uuid.uuid4()), debug_mode=debug_enabled, # Disable deadlock detection in debug mode - # Tracing interceptor OUTERMOST so business interceptors (and the spans - # they create) nest under the propagated workflow/activity span. - interceptors=[*temporal_tracing_interceptors(), *self.interceptors], + # Temporal inherits client tracing before these business interceptors. + interceptors=self.interceptors, ) logger.info(f"Starting workers for task queue: {self.task_queue}") # Eagerly set the worker status to healthy self.healthy = True logger.info(f"Running workers for task queue: {self.task_queue}") - await worker.run() + 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() async def _health_check(self): return web.json_response(self.healthy) @@ -312,6 +342,6 @@ async def _register_agent(self): # the worker process never goes through the ACP server lifespan, so it needs its # own guard (mirrors base_acp_server.lifespan_context). await assert_backend_compatible(env_vars.AGENTEX_BASE_URL) - await register_agent(env_vars) + await register_agent(env_vars, agent_card=self.agent_card) else: logger.warning("AGENTEX_BASE_URL not set, skipping worker registration") diff --git a/src/agentex/lib/core/temporal/workflows/workflow.py b/src/agentex/lib/core/temporal/workflows/workflow.py index e47fd9a5c..8b638cf8a 100644 --- a/src/agentex/lib/core/temporal/workflows/workflow.py +++ b/src/agentex/lib/core/temporal/workflows/workflow.py @@ -7,10 +7,10 @@ from temporalio import workflow from agentex.protocol.acp import SendEventParams, CreateTaskParams, InterruptTaskParams -from agentex.lib.utils.logging import make_logger +from agentex.lib.core.temporal.logging import make_workflow_logger from agentex.lib.core.temporal.types.workflow import SignalName -logger = make_logger(__name__) +logger = make_workflow_logger(__name__) class BaseWorkflow(ABC): diff --git a/src/agentex/lib/core/tracing/code_revision.py b/src/agentex/lib/core/tracing/code_revision.py index 7b08dd45f..570d4f1cd 100644 --- a/src/agentex/lib/core/tracing/code_revision.py +++ b/src/agentex/lib/core/tracing/code_revision.py @@ -1,10 +1,11 @@ -"""Opt-in stamping of the agent's source commit onto its spans. +"""Stamping of the agent's source commit onto its spans. -Nothing is stamped until the agent calls :func:`enable`, mirroring the -``lineage`` registry next door: a process-wide switch the agent sets once at -import, rather than automatic behaviour every agent inherits. When enabled the -resolved commit lands in span data under ``__commit_sha__`` and is searchable in -the SGP Traces UI as ``__commit_sha__:``. +Stamping turns on when the process starts with ``AGENT_COMMIT_SHA`` set, which +the SGP cloud deploy does from the build record's attested commit, or when the +agent calls :func:`enable` itself. Nothing is stamped otherwise: upgrading the +SDK alone never starts emitting the field. When on, the resolved commit lands in +span data under ``__commit_sha__`` and is searchable in the SGP Traces UI as +``__commit_sha__:``. This is deliberately separate from ``__agent_version__``, which is automatic and carries the deployed image tag verbatim ("image tag or git sha"). That tag is a @@ -21,7 +22,7 @@ from agentex.lib.utils.logging import make_logger -__all__ = ("COMMIT_SHA_KEY", "enable", "disable", "is_enabled", "commit_sha") +__all__ = ("COMMIT_SHA_KEY", "enable", "disable", "is_enabled", "commit_sha", "is_git_object_name") logger = make_logger(__name__) @@ -31,6 +32,12 @@ # git's own 7-character minimum. _GIT_SHA_RE = re.compile(r"[0-9a-fA-F]{7,64}") + +def is_git_object_name(value: str) -> bool: + """Whether ``value`` is a full or abbreviated git SHA-1/SHA-256 object name.""" + return _GIT_SHA_RE.fullmatch(value.strip()) is not None + + _COMMIT_SHA_ENV = "AGENT_COMMIT_SHA" # Fallback only: automatic, and only usable when it happens to be SHA-shaped. _AGENT_VERSION_ENV = "AGENT_VERSION" @@ -42,13 +49,16 @@ def enable(commit_sha: str | None = None) -> None: - """Opt this process in to stamping ``__commit_sha__`` onto every span. + """Turn on stamping ``__commit_sha__`` onto every span from this process. Value precedence: the explicit ``commit_sha`` argument, else ``AGENT_COMMIT_SHA``, else ``AGENT_VERSION`` when the deployment happened to set it to a bare commit SHA. A value that is not a git object name is refused with a warning and leaves stamping off -- better an absent field than one named for a commit that holds an image tag. + + Called once at import when ``AGENT_COMMIT_SHA`` is set, so a deployment that + supplies the commit needs no code change in the agent. """ global _commit_sha @@ -103,3 +113,12 @@ def is_enabled() -> bool: def commit_sha() -> str | None: """The resolved commit SHA, or ``None`` when stamping is not enabled.""" return _commit_sha + + +def _enable_from_environment() -> None: + """Auto-enable on ``AGENT_COMMIT_SHA`` only; ``AGENT_VERSION`` stays an explicit fallback.""" + if os.environ.get(_COMMIT_SHA_ENV, "").strip(): + enable() + + +_enable_from_environment() diff --git a/src/agentex/lib/core/tracing/tracing_processor_manager.py b/src/agentex/lib/core/tracing/tracing_processor_manager.py index 07c440313..5227e891c 100644 --- a/src/agentex/lib/core/tracing/tracing_processor_manager.py +++ b/src/agentex/lib/core/tracing/tracing_processor_manager.py @@ -1,5 +1,8 @@ from __future__ import annotations +import asyncio +import logging +import threading from typing import TYPE_CHECKING from threading import Lock @@ -78,3 +81,104 @@ 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 00dbbaada..dae1e5db3 100644 --- a/src/agentex/lib/environment_variables.py +++ b/src/agentex/lib/environment_variables.py @@ -26,6 +26,7 @@ class EnvVarKeys(str, Enum): AGENT_ID = "AGENT_ID" AGENT_VERSION = "AGENT_VERSION" AGENT_COMMIT_SHA = "AGENT_COMMIT_SHA" + AGENT_SOURCE_REPO = "AGENT_SOURCE_REPO" AGENT_API_KEY = "AGENT_API_KEY" # ACP Configuration ACP_URL = "ACP_URL" @@ -68,12 +69,11 @@ class EnvironmentVariables(BaseModel): AGENT_ID: str | None = None # Build/version discriminator (image tag or git sha), set by the deployment AGENT_VERSION: str | None = None - # The agent's source commit, baked into the image or set by the deployment. - # Unlike AGENT_VERSION this is expected to be a git SHA and nothing else, and - # it is OPT-IN: nothing is stamped unless the agent calls - # `adk.code_revision.enable()`, which also refuses a value that is not a git - # object name. See agentex.lib.core.tracing.code_revision. + # The agent's source commit, set by the deployment or baked into the image; a git + # SHA and nothing else. Stamped as __commit_sha__ when set (see tracing.code_revision). AGENT_COMMIT_SHA: str | None = None + # Git remote the agent was built from (any URL form; normalized to host/path on use). + AGENT_SOURCE_REPO: str | None = None AGENT_API_KEY: str | None = None ACP_TYPE: str | None = "async" AGENT_INPUT_TYPE: str | None = None diff --git a/src/agentex/lib/sdk/fastacp/base/base_acp_server.py b/src/agentex/lib/sdk/fastacp/base/base_acp_server.py index 864b466d0..50c304c92 100644 --- a/src/agentex/lib/sdk/fastacp/base/base_acp_server.py +++ b/src/agentex/lib/sdk/fastacp/base/base_acp_server.py @@ -39,6 +39,8 @@ 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__) @@ -84,6 +86,71 @@ 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 @@ -103,12 +170,16 @@ 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) @@ -116,6 +187,7 @@ 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): @@ -139,6 +211,20 @@ 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 @@ -176,9 +262,20 @@ 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 new file mode 100644 index 000000000..e69de29bb 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 new file mode 100644 index 000000000..1fa359849 --- /dev/null +++ b/src/agentex/lib/sdk/fastacp/base/tests/test_shutdown_hooks.py @@ -0,0 +1,344 @@ +"""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 def4464c6..d0af817a5 100644 --- a/src/agentex/lib/types/agent_card.py +++ b/src/agentex/lib/types/agent_card.py @@ -5,7 +5,7 @@ from enum import Enum from typing import TYPE_CHECKING, Any, get_args, get_origin -from pydantic import BaseModel +from pydantic import Field, BaseModel if TYPE_CHECKING: from agentex.lib.sdk.state_machine.state import State @@ -31,6 +31,11 @@ class AgentCard(BaseModel): data_events: list[str] = [] input_types: list[str] = [] output_schema: dict | None = None + # Free-form JSON object for opt-in self-description (e.g. protocol-specific + # capability flags). Not interpreted by the platform, but callers can filter + # agents on it with ``agents.list(agent_card_metadata=...)`` -- see + # ``agentex.lib.utils.metadata_filters.encode_metadata_filter``. + metadata: dict[str, Any] = Field(default_factory=dict) @classmethod def from_states( @@ -40,6 +45,7 @@ def from_states( output_event_model: type[BaseModel] | None = None, extra_input_types: list[str] | None = None, queries: list[str] | None = None, + metadata: dict[str, Any] | None = None, ) -> AgentCard: """Build an AgentCard directly from a list[State] + initial_state. @@ -81,6 +87,7 @@ def from_states( data_events=data_events, input_types=sorted(derived_input_types | set(extra_input_types or [])), output_schema=output_schema, + metadata=metadata or {}, ) @classmethod @@ -90,6 +97,7 @@ def from_state_machine( output_event_model: type[BaseModel] | None = None, extra_input_types: list[str] | None = None, queries: list[str] | None = None, + metadata: dict[str, Any] | None = None, ) -> AgentCard: """Build an AgentCard from a StateMachine instance. Delegates to from_states().""" lifecycle = state_machine.get_lifecycle() @@ -125,6 +133,7 @@ def from_state_machine( data_events=data_events, input_types=sorted(derived_input_types | set(extra_input_types or [])), output_schema=output_schema, + metadata=metadata or {}, ) diff --git a/src/agentex/lib/utils/build_provenance.py b/src/agentex/lib/utils/build_provenance.py index 447980263..37b61a3f9 100644 --- a/src/agentex/lib/utils/build_provenance.py +++ b/src/agentex/lib/utils/build_provenance.py @@ -82,7 +82,8 @@ def normalize_remote(url: Optional[str]) -> Optional[str]: """Strip credentials and scheme from a remote, returning ``host/path``.""" if not url: return None - candidate = url.strip() + # Query strings and fragments never name a repo, but they do carry tokens. + candidate = url.strip().split("?", 1)[0].split("#", 1)[0] # scp-like syntax: git@host:org/repo(.git) — no scheme, host/path split on ':' if "://" not in candidate and ":" in candidate and "/" not in candidate.split(":", 1)[0]: candidate = candidate.split("@", 1)[-1].replace(":", "/", 1) diff --git a/src/agentex/lib/utils/logging.py b/src/agentex/lib/utils/logging.py index 5bbaf61ac..e2d5d5cb4 100644 --- a/src/agentex/lib/utils/logging.py +++ b/src/agentex/lib/utils/logging.py @@ -11,6 +11,59 @@ 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] @@ -43,6 +96,17 @@ 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. @@ -51,19 +115,28 @@ def make_logger(name: str) -> logging.Logger: """ # Create a console object to print colored text logger = logging.getLogger(name) - logger.setLevel(logging.INFO) + logger.setLevel(resolve_log_level()) + + 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 environment = os.getenv("ENVIRONMENT") if environment == "local": console = Console() # Add the RichHandler to the logger to print colored text - handler = RichHandler( - console=console, - show_level=False, - show_path=False, - show_time=False, + _attach( + logger, + RichHandler( + console=console, + show_level=False, + show_path=False, + show_time=False, + ), ) - logger.addHandler(handler) return logger stream_handler = logging.StreamHandler() @@ -74,6 +147,76 @@ def make_logger(name: str) -> logging.Logger: logging.Formatter("%(asctime)s %(levelname)s [%(name)s] [%(filename)s:%(lineno)d] - %(message)s") ) - logger.addHandler(stream_handler) + _attach(logger, 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 new file mode 100644 index 000000000..22d8aeb59 --- /dev/null +++ b/src/agentex/lib/utils/metadata_filters.py @@ -0,0 +1,58 @@ +"""Helpers for the platform's JSON-encoded metadata filter query parameters. + +The containment filters on ``agents.list(agent_card_metadata=...)`` and +``tasks.list(task_metadata=...)`` carry their filter as a JSON-encoded object +inside a single query string value, so the generated clients type them as +``str``. Encoding by hand is easy to get subtly wrong -- Python's ``json`` +happily emits ``NaN``/``Infinity``, which the server rejects with a 400 -- so +these helpers do it once, here, in the hand-written layer where they survive +SDK regeneration. + + from agentex.lib.utils.metadata_filters import encode_metadata_filter + + client.agents.list( + agent_card_metadata=encode_metadata_filter({"permits_capable": True}), + ) + +The ``agent_card_metadata`` filter requires an Agentex server that includes +scaleapi/scale-agentex#411. Older servers ignore the unknown query parameter +and return the full unfiltered agent list rather than erroring, and the SDK's +startup backend-contract check does not guard against this. +""" + +from __future__ import annotations + +import json +from typing import Any, Mapping + +__all__ = ["encode_metadata_filter"] + + +def encode_metadata_filter(metadata: Mapping[str, Any]) -> str: + """Encode a metadata filter mapping into the wire form the platform expects. + + Args: + metadata: The key/value pairs the target's metadata object must contain. + Values may be any JSON type; matching is exact containment, so + ``{"permits_capable": True}`` matches a stored JSON ``true`` but not + the string ``"true"``. An empty mapping matches any target that has + a metadata object at all. + + Returns: + A compact JSON object string, with keys sorted so the same filter always + produces the same query value. + + Raises: + TypeError: If ``metadata`` is not a mapping, or contains a value that + isn't JSON-serializable. + ValueError: If a value is a non-finite float. ``NaN`` and ``Infinity`` + aren't valid JSON and the server rejects them with a 400, so fail + here with a clearer message instead. + """ + if not isinstance(metadata, Mapping): + raise TypeError(f"metadata must be a mapping, got {type(metadata).__name__}") + + try: + return json.dumps(metadata, allow_nan=False, separators=(",", ":"), sort_keys=True) + except ValueError as exc: + raise ValueError(f"metadata filter is not encodable as JSON: {exc}") from exc diff --git a/src/agentex/lib/utils/registration.py b/src/agentex/lib/utils/registration.py index 5fc4d4be5..36b5f9a04 100644 --- a/src/agentex/lib/utils/registration.py +++ b/src/agentex/lib/utils/registration.py @@ -7,6 +7,8 @@ from agentex.lib.utils.logging import make_logger from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.utils.build_provenance import normalize_remote +from agentex.lib.core.tracing.code_revision import is_git_object_name logger = make_logger(__name__) @@ -20,6 +22,29 @@ def get_auth_principal(env_vars: EnvironmentVariables): except Exception: return None + +def build_registration_metadata(env_vars: EnvironmentVariables, agent_card=None) -> dict: + """Deployment id, source provenance, and agent card; keys appear only when known.""" + metadata: dict = {} + if env_vars.AGENTEX_DEPLOYMENT_ID: + metadata["deployment_id"] = env_vars.AGENTEX_DEPLOYMENT_ID + commit = (env_vars.AGENT_COMMIT_SHA or "").strip() + if commit: + if is_git_object_name(commit): + metadata["commit_sha"] = commit + else: + logger.warning( + "AGENT_COMMIT_SHA=%r is not a git commit SHA; commit_sha omitted from registration.", + commit, + ) + repo = normalize_remote(env_vars.AGENT_SOURCE_REPO) + if repo: + metadata["source_repo"] = repo + if agent_card is not None: + metadata["agent_card"] = agent_card.model_dump() if hasattr(agent_card, "model_dump") else agent_card + return metadata + + async def register_agent(env_vars: EnvironmentVariables, agent_card=None): """Register this agent with the Agentex server""" if not env_vars.AGENTEX_BASE_URL: @@ -33,13 +58,7 @@ async def register_agent(env_vars: EnvironmentVariables, agent_card=None): or f"Generic description for agent: {env_vars.AGENT_NAME}" ) - # Registration metadata carries the deployment id and agent card. - registration_metadata: dict = {} - if env_vars.AGENTEX_DEPLOYMENT_ID: - registration_metadata["deployment_id"] = env_vars.AGENTEX_DEPLOYMENT_ID - if agent_card is not None: - card_data = agent_card.model_dump() if hasattr(agent_card, "model_dump") else agent_card - registration_metadata["agent_card"] = card_data + registration_metadata = build_registration_metadata(env_vars, agent_card) # Prepare registration data registration_data = { diff --git a/src/agentex/lib/utils/tests/__init__.py b/src/agentex/lib/utils/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/agentex/lib/utils/tests/test_logging_handover.py b/src/agentex/lib/utils/tests/test_logging_handover.py new file mode 100644 index 000000000..265237322 --- /dev/null +++ b/src/agentex/lib/utils/tests/test_logging_handover.py @@ -0,0 +1,236 @@ +"""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 new file mode 100644 index 000000000..835b56ae8 --- /dev/null +++ b/tests/lib/cli/test_deploy_handlers.py @@ -0,0 +1,64 @@ +"""Tests for the helm values merge_deployment_configs assembles for `agentex agents deploy`.""" + +from __future__ import annotations + +from typing import Any + +from agentex.config.agent_config import AgentConfig +from agentex.config.build_config import BuildConfig, BuildContext +from agentex.config.agent_manifest import AgentManifest +from agentex.config.deployment_config import ImageConfig, DeploymentConfig +from agentex.config.environment_config import AgentAuthConfig, AgentEnvironmentConfig +from agentex.lib.cli.handlers.deploy_handlers import InputDeployOverrides, merge_deployment_configs + +MANIFEST_TAG = "sha-manifest" + + +def _manifest(env: dict[str, str] | None = None) -> AgentManifest: + return AgentManifest( + build=BuildConfig(context=BuildContext(root=".", dockerfile="Dockerfile", dockerignore=None)), + agent=AgentConfig(name="emu-tax", description="Files emu taxes", acp_type="async", env=env), + deployment=DeploymentConfig(image=ImageConfig(repository="registry.example.com/emu-tax", tag=MANIFEST_TAG)), + ) + + +def _env_config(helm_overrides: dict[str, Any]) -> AgentEnvironmentConfig: + return AgentEnvironmentConfig(auth=AgentAuthConfig(principal={"user_id": "u-1"}), helm_overrides=helm_overrides) + + +def _merge( + manifest: AgentManifest, + env_config: AgentEnvironmentConfig | None = None, + image_tag: str | None = None, +) -> dict[str, Any]: + overrides = InputDeployOverrides(image_tag=image_tag) + return merge_deployment_configs(manifest, env_config, overrides, "/nonexistent/manifest.yaml") + + +class TestAgentVersion: + def test_stamped_from_the_deploy_image_tag(self): + values = _merge(_manifest(), image_tag="sha-cli") + + assert values["global"]["agent"]["version"] == "sha-cli" + + def test_follows_an_image_tag_overridden_in_helm_overrides(self): + values = _merge(_manifest(), _env_config({"global": {"image": {"tag": "sha-env"}}})) + + assert values["global"]["image"]["tag"] == "sha-env" + assert values["global"]["agent"]["version"] == "sha-env" + + def test_explicit_helm_override_of_the_version_wins(self): + values = _merge(_manifest(), _env_config({"global": {"agent": {"version": "pinned"}}})) + + assert values["global"]["agent"]["version"] == "pinned" + + def test_skipped_when_the_manifest_env_declares_agent_version(self): + values = _merge(_manifest(env={"AGENT_VERSION": "v1.2.3"})) + + assert "version" not in values["global"]["agent"] + assert {"name": "AGENT_VERSION", "value": "v1.2.3"} in values["env"] + + def test_skipped_when_the_environment_env_declares_agent_version(self): + values = _merge(_manifest(), _env_config({"env": [{"name": "AGENT_VERSION", "value": "v9"}]})) + + assert "version" not in values["global"]["agent"] diff --git a/tests/lib/cli/test_run_handlers_streaming.py b/tests/lib/cli/test_run_handlers_streaming.py new file mode 100644 index 000000000..8f0ab13b5 --- /dev/null +++ b/tests/lib/cli/test_run_handlers_streaming.py @@ -0,0 +1,180 @@ +"""Tests for run_handlers output streaming. + +stream_process_output is the only reader of a child's stdout pipe. If it stops +reading, the pipe fills and the child blocks forever inside write(), which +presents as a silent freeze with no traceback. These tests pin the behaviour +that prevents that: a line the reader cannot handle is skipped, not fatal. +""" + +from __future__ import annotations + +import sys +import asyncio +from typing import Any + +import pytest + +from agentex.lib.cli.debug import DebugMode, DebugConfig +from agentex.lib.cli.handlers import run_handlers +from agentex.lib.cli.debug.debug_handlers import ( + start_acp_server_debug, + start_temporal_worker_debug, +) +from agentex.lib.cli.handlers.run_handlers import ( + SUBPROCESS_STREAM_LIMIT, + start_acp_server, + start_temporal_worker, + stream_process_output, +) + +# Emits a line of MARKER over the reader's limit, then enough further output to +# more than fill a 64 KiB pipe. If the reader stops draining, the child cannot +# finish its writes and never exits. +MARKER = "X" + +CHILD_SCRIPT = """ +print("before") +print("{marker}" * {oversized}) +for i in range(2000): + print("after", i, "y" * 60) +print("done") +""" + + +async def _drain(limit: int, oversized: int) -> int | None: + """Run the child under stream_process_output. None means it never exited.""" + process = await asyncio.create_subprocess_exec( + sys.executable, + "-c", + CHILD_SCRIPT.format(marker=MARKER, oversized=oversized), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + limit=limit, + ) + streamer = asyncio.create_task(stream_process_output(process, "TEST")) + try: + await asyncio.wait_for(asyncio.gather(streamer, process.wait()), timeout=60) + except TimeoutError: + process.kill() + await process.wait() + return None + return process.returncode + + +async def test_oversized_line_is_skipped_without_stalling_the_child( + capsys: pytest.CaptureFixture[str], +) -> None: + """A line past the reader's limit is dropped, and streaming continues. + + Before this was handled per line, readline() raised, the loop exited, and the + child deadlocked on a full pipe. The child reaching exit is the assertion. + """ + limit = 64 * 1024 + oversized = limit + 16_000 + + returncode = await _drain(limit=limit, oversized=oversized) + out = capsys.readouterr().out + + assert returncode == 0, "child did not exit: the reader stopped draining its pipe" + # The offending line is gone, but everything after it still streamed. + assert out.count(MARKER) == 0 + assert "done" in out + + +async def test_large_line_within_the_limit_is_streamed_in_full( + capsys: pytest.CaptureFixture[str], +) -> None: + """A line over asyncio's 64 KiB default still reaches the console under our limit. + + Counts marker characters rather than matching the line, because rich wraps + long output across terminal-width lines. + """ + oversized = 82_000 + + returncode = await _drain(limit=SUBPROCESS_STREAM_LIMIT, oversized=oversized) + out = capsys.readouterr().out + + assert returncode == 0 + assert out.count(MARKER) == oversized, "the large line was dropped rather than streamed" + + +class _AlwaysFailingReader: + """A reader whose readline() raises without consuming anything. + + The dangerous shape: skipping it makes no progress, so an unbounded retry + would spin at 100% CPU while still not draining the pipe. + """ + + def __init__(self) -> None: + self.attempts = 0 + + async def readline(self) -> bytes: + self.attempts += 1 + raise ValueError("unreadable, and nothing was consumed") + + +class _FakeProcess: + def __init__(self, stdout: Any) -> None: + self.stdout = stdout + + +async def test_repeated_unreadable_lines_give_up_instead_of_spinning() -> None: + """A ValueError that consumes nothing must not loop forever.""" + reader = _AlwaysFailingReader() + + await asyncio.wait_for( + stream_process_output(_FakeProcess(reader), "TEST"), timeout=30 + ) + + assert reader.attempts == run_handlers.MAX_CONSECUTIVE_READ_ERRORS + 1 + + +async def test_cancellation_is_not_swallowed() -> None: + """The auto-reload path cancels these tasks, so cancel must propagate. + + CancelledError derives from BaseException, so the outer `except Exception` + does not catch it. This pins that, since swallowing it would hang restarts. + """ + + class _NeverReturns: + async def readline(self) -> bytes: + await asyncio.sleep(3600) + return b"" + + task = asyncio.create_task(stream_process_output(_FakeProcess(_NeverReturns()), "TEST")) + await asyncio.sleep(0) + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + +async def test_every_spawn_uses_the_larger_limit( + monkeypatch: pytest.MonkeyPatch, tmp_path: Any +) -> None: + """Every spawn must pass limit=, including the debug ones. + + A subprocess left on asyncio's default overruns far more easily, and enough + consecutive overruns exhaust MAX_CONSECUTIVE_READ_ERRORS and stop the reader + draining, which is the deadlock the bound exists to avoid. + """ + seen: list[int | None] = [] + + async def fake_exec(*_args: Any, **kwargs: Any) -> None: + seen.append(kwargs.get("limit")) + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec) + monkeypatch.setattr(run_handlers, "calculate_uvicorn_target_for_local", lambda *_: "project.acp") + + await start_acp_server(tmp_path / "acp.py", 8000, {}, tmp_path) + await start_temporal_worker(tmp_path / "run_worker.py", {}, tmp_path) + + # BOTH, since each helper refuses unless its own mode is enabled. + debug_config = DebugConfig( + enabled=True, mode=DebugMode.BOTH, port=5678, wait_for_attach=False, auto_port=False + ) + await start_acp_server_debug(tmp_path / "acp.py", 8000, {}, debug_config) + await start_temporal_worker_debug(tmp_path / "run_worker.py", {}, debug_config) + + assert seen == [SUBPROCESS_STREAM_LIMIT] * 4, f"a spawn is missing limit=: {seen}" + assert SUBPROCESS_STREAM_LIMIT > 64 * 1024, "asyncio's default is what breaks readline()" diff --git a/tests/lib/core/temporal/test_workflow_logging.py b/tests/lib/core/temporal/test_workflow_logging.py new file mode 100644 index 000000000..6b193b35e --- /dev/null +++ b/tests/lib/core/temporal/test_workflow_logging.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import logging +from types import SimpleNamespace + +import pytest +from temporalio import workflow +from temporalio.testing import ActivityEnvironment + +from agentex.lib.core.temporal.workflows import workflow as base_workflow +from agentex.lib.core.temporal.plugins.openai_agents.interceptors import context_interceptor + + +@pytest.fixture(params=[base_workflow.logger, context_interceptor.logger], ids=["base-workflow", "context-interceptor"]) +def sdk_logger(request, caplog): + logger = request.param + caplog.set_level(logging.DEBUG, logger=logger.name) + return logger + + +@pytest.fixture +def workflow_context(monkeypatch): + def set_context(*, replaying: bool) -> None: + monkeypatch.setattr(workflow, "in_workflow", lambda: True) + monkeypatch.setattr(workflow, "info", lambda: SimpleNamespace(workflow_id="task-123", run_id="run-456")) + replay_check = ( + "is_replaying_history_events" if hasattr(workflow.unsafe, "is_replaying_history_events") else "is_replaying" + ) + monkeypatch.setattr(workflow.unsafe, replay_check, lambda: replaying) + + return set_context + + +@pytest.mark.parametrize("level", [logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR]) +def test_sdk_workflow_logs_are_suppressed_during_replay(sdk_logger, workflow_context, caplog, level): + workflow_context(replaying=True) + + sdk_logger.log(level, "Repeated workflow operation") + + assert not caplog.records + + +def test_sdk_workflow_logs_include_ids_and_preserve_caller_fields(sdk_logger, workflow_context, caplog): + workflow_context(replaying=False) + fields = {"operation": "interrupt", "trace_id": "existing-trace", "span_id": "existing-span"} + + sdk_logger.info("Handling %s", "interrupt", extra=fields) + + (record,) = caplog.records + assert record.workflow_id == "task-123" + assert record.run_id == "run-456" + assert record.operation == "interrupt" + assert record.trace_id == "existing-trace" + assert record.span_id == "existing-span" + assert record.getMessage() == "Handling interrupt" + assert record.pathname == __file__ + assert "temporal_workflow" not in record.__dict__ + assert fields == {"operation": "interrupt", "trace_id": "existing-trace", "span_id": "existing-span"} + + +def test_workflow_logs_without_trace_context_do_not_invent_ids(sdk_logger, workflow_context, caplog): + workflow_context(replaying=False) + + sdk_logger.info("Workflow without a trace") + + (record,) = caplog.records + assert record.workflow_id == "task-123" + assert record.run_id == "run-456" + assert "trace_id" not in record.__dict__ + assert "span_id" not in record.__dict__ + + +@pytest.mark.parametrize("in_activity", [False, True], ids=["startup", "activity"]) +def test_sdk_logger_works_outside_workflows(sdk_logger, caplog, in_activity): + def log_message(): + sdk_logger.info("Outside workflow", extra={"operation": "startup"}) + + if in_activity: + ActivityEnvironment().run(log_message) + else: + log_message() + + (record,) = caplog.records + assert record.getMessage() == "Outside workflow" + assert record.operation == "startup" + assert "workflow_id" not in record.__dict__ + assert "run_id" not in record.__dict__ + + +def test_sdk_workflow_logger_preserves_exception_details(sdk_logger, workflow_context, caplog): + workflow_context(replaying=False) + + try: + raise ValueError("operation failed") + except ValueError: + sdk_logger.exception("Workflow operation failed") + + (record,) = caplog.records + assert record.exc_info is not None + assert isinstance(record.exc_info[1], ValueError) + assert record.workflow_id == "task-123" + assert record.run_id == "run-456" diff --git a/tests/lib/core/temporal/test_workflow_logging_replay.py b/tests/lib/core/temporal/test_workflow_logging_replay.py new file mode 100644 index 000000000..3774d3689 --- /dev/null +++ b/tests/lib/core/temporal/test_workflow_logging_replay.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import logging +from concurrent.futures import ThreadPoolExecutor + +import pytest +from temporalio import workflow +from temporalio.client import WorkflowHistory +from temporalio.worker import Replayer + +with workflow.unsafe.imports_passed_through(): + from agentex.lib.core.temporal.workflows import workflow as base_workflow + + +@workflow.defn +class ReplayLoggingWorkflow: + @workflow.run + async def run(self) -> None: + base_workflow.logger.info("SDK workflow replay log") + + +def completed_history() -> WorkflowHistory: + return WorkflowHistory.from_json( + "replay-logging-workflow", + { + "events": [ + { + "eventId": "1", + "eventTime": "2026-09-18T00:00:00Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_STARTED", + "workflowExecutionStartedEventAttributes": { + "workflowType": {"name": "ReplayLoggingWorkflow"}, + "taskQueue": {"name": "replay-logging-queue"}, + "workflowTaskTimeout": "10s", + "originalExecutionRunId": "806b1959-3829-42a6-a32b-2623ea410033", + }, + }, + { + "eventId": "2", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "workflowTaskScheduledEventAttributes": { + "taskQueue": {"name": "replay-logging-queue"}, + "startToCloseTimeout": "10s", + "attempt": 1, + }, + }, + { + "eventId": "3", + "eventTime": "2026-09-18T00:00:00Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "workflowTaskStartedEventAttributes": {"scheduledEventId": "2"}, + }, + { + "eventId": "4", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "workflowTaskCompletedEventAttributes": {"scheduledEventId": "2", "startedEventId": "3"}, + }, + { + "eventId": "5", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED", + "workflowExecutionCompletedEventAttributes": {"workflowTaskCompletedEventId": "4"}, + }, + ], + }, + ) + + +async def test_sdk_logger_suppresses_real_workflow_replay(caplog, monkeypatch: pytest.MonkeyPatch) -> None: + caplog.set_level(logging.INFO, logger=base_workflow.logger.name) + with ThreadPoolExecutor(max_workers=1) as executor: + replayer = Replayer(workflows=[ReplayLoggingWorkflow], workflow_task_executor=executor) + + with monkeypatch.context() as patch: + patch.setattr(base_workflow, "logger", logging.getLogger(base_workflow.logger.name)) + await replayer.replay_workflow(completed_history()) + + assert [record.getMessage() for record in caplog.records] == ["SDK workflow replay log"] + caplog.clear() + + await replayer.replay_workflow(completed_history()) + + assert not caplog.records diff --git a/tests/lib/core/temporal/workers/test_worker_tracing.py b/tests/lib/core/temporal/workers/test_worker_tracing.py new file mode 100644 index 000000000..0242fd01b --- /dev/null +++ b/tests/lib/core/temporal/workers/test_worker_tracing.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import dataclasses +from typing import Any, override +from unittest.mock import Mock, AsyncMock + +import pytest +from temporalio import activity +from opentelemetry import trace +from temporalio.worker import Worker, Interceptor, ExecuteActivityInput, ActivityInboundInterceptor +from temporalio.testing import ActivityEnvironment +from opentelemetry.sdk.trace import TracerProvider +from temporalio.bridge.client import Client as BridgeClient +from temporalio.bridge.worker import Worker as BridgeWorker +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from temporalio.contrib.opentelemetry import TracingInterceptor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + +from agentex.lib.core.temporal.workers.worker import AgentexWorker + + +class _BusinessInterceptor(Interceptor): + def __init__(self, name: str, events: list[tuple[str, bool]]) -> None: + self.name = name + self.events = events + + @override + def intercept_activity(self, next: ActivityInboundInterceptor) -> ActivityInboundInterceptor: + owner = self + + class Inbound(ActivityInboundInterceptor): + @override + async def execute_activity(self, input: ExecuteActivityInput) -> Any: + owner.events.append((owner.name, trace.get_current_span().get_span_context().is_valid)) + return await self.next.execute_activity(input) + + return Inbound(next) + + +class _ActivityCall(ActivityInboundInterceptor): + def __init__(self) -> None: + pass + + @override + async def execute_activity(self, input: ExecuteActivityInput) -> Any: + return await input.fn(*input.args) + + +@pytest.mark.parametrize("tracing_enabled", [True, False]) +async def test_worker_inherits_one_tracing_interceptor_before_business_interceptors( + monkeypatch: pytest.MonkeyPatch, tracing_enabled: bool +) -> None: + monkeypatch.setenv("AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED", str(tracing_enabled).lower()) + monkeypatch.delenv("DD_AGENT_HOST", raising=False) + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + tracer = provider.get_tracer(__name__) + monkeypatch.setattr(trace, "get_tracer", lambda *args, **kwargs: tracer) + # Keep Client and Worker configuration real; replace their network boundary. + monkeypatch.setattr(BridgeClient, "connect", AsyncMock(return_value=Mock())) + monkeypatch.setattr(BridgeWorker, "create", Mock(return_value=Mock())) + + events: list[tuple[str, bool]] = [] + first = _BusinessInterceptor("first", events) + second = _BusinessInterceptor("second", events) + + @activity.defn + async def sample_activity() -> str: + events.append(("activity", trace.get_current_span().get_span_context().is_valid)) + return "completed" + + async def run_once(worker: Worker) -> None: + assert worker._activity_worker is not None + interceptors = worker._activity_worker._interceptors + assert sum(isinstance(item, TracingInterceptor) for item in interceptors) == int(tracing_enabled) + assert list(interceptors[-2:]) == [first, second] + + inbound: ActivityInboundInterceptor = _ActivityCall() + for interceptor in reversed(interceptors): + inbound = interceptor.intercept_activity(inbound) + environment = ActivityEnvironment() + environment.info = dataclasses.replace(environment.info, activity_type="sample_activity") + result = await environment.run( + inbound.execute_activity, + ExecuteActivityInput(fn=sample_activity, args=[], executor=None, headers={}), + ) + assert result == "completed" + + monkeypatch.setattr(Worker, "run", run_once) + worker = AgentexWorker(task_queue="test-tracing", health_check_port=8080, interceptors=[first, second]) + monkeypatch.setattr(worker, "start_health_check_server", AsyncMock()) + monkeypatch.setattr(worker, "_register_agent", AsyncMock()) + + try: + await worker.run(activities=[sample_activity], workflows=[]) + assert events == [("first", tracing_enabled), ("second", tracing_enabled), ("activity", tracing_enabled)] + spans = exporter.get_finished_spans() + assert len(spans) == int(tracing_enabled) + if tracing_enabled: + assert spans[0].name == "RunActivity:sample_activity" + finally: + provider.shutdown() diff --git a/tests/lib/core/temporal/workers/test_worker_version_guard.py b/tests/lib/core/temporal/workers/test_worker_version_guard.py index 4ab5fc435..5c2c9fb47 100644 --- a/tests/lib/core/temporal/workers/test_worker_version_guard.py +++ b/tests/lib/core/temporal/workers/test_worker_version_guard.py @@ -40,7 +40,7 @@ async def test_guard_runs_before_register_agent(monkeypatch): await _worker()._register_agent() guard.assert_awaited_once_with("http://backend") - register.assert_awaited_once_with(env) + register.assert_awaited_once_with(env, agent_card=None) assert order == ["guard", "register"] # guard must precede registration diff --git a/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py b/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py index e8a3fb08d..7b5c129d6 100644 --- a/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py +++ b/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py @@ -54,17 +54,15 @@ def test_agent_identity_and_version_stamped_into_span_data(self): "__agent_version__": "sha-abc123", } - # Abbreviated deliberately: a bare 40-char hex literal trips credential - # scanners, and code_revision accepts any git object name (7-64 hex). - SHA = "b362b171a9c4" + SHA = "b362b171a9c4e1f09d8e7a6b5c4d3e2f1a0b9c8d" - def test_commit_sha_is_not_stamped_without_opt_in(self, monkeypatch): - """Upgrading the SDK must not start emitting __commit_sha__ on its own, - even when the environment carries a perfectly good SHA.""" + def test_commit_sha_is_not_stamped_when_env_absent(self, monkeypatch): + """Upgrading the SDK must not start emitting __commit_sha__ on its own; + only AGENT_COMMIT_SHA or an enable() call turns it on.""" from agentex.lib.core.tracing import code_revision from agentex.lib.core.tracing.processors.sgp_tracing_processor import _sgp_metadata - monkeypatch.setenv("AGENT_COMMIT_SHA", self.SHA) + monkeypatch.delenv("AGENT_COMMIT_SHA", raising=False) code_revision.disable() span = _make_span(); span.data = {} diff --git a/tests/lib/core/tracing/test_code_revision.py b/tests/lib/core/tracing/test_code_revision.py index 0b89b88f2..a696129d7 100644 --- a/tests/lib/core/tracing/test_code_revision.py +++ b/tests/lib/core/tracing/test_code_revision.py @@ -1,7 +1,8 @@ -"""Opt-in commit-SHA stamping. +"""Commit-SHA stamping. -The contract that matters: an agent that does not call ``enable()`` gets nothing, -so upgrading the SDK never starts emitting this field on its own. +The contract that matters: with ``AGENT_COMMIT_SHA`` absent and no ``enable()`` +call, nothing is stamped, so upgrading the SDK never starts emitting this field +on its own. A deployment that sets the env var turns it on without agent code. """ from __future__ import annotations @@ -21,14 +22,33 @@ def _reset(): code_revision.disable() -class TestOptIn: - def test_disabled_by_default(self, monkeypatch): - """Even with the env fully populated, nothing resolves until enable().""" - monkeypatch.setenv("AGENT_COMMIT_SHA", SHA) +class TestEnablement: + def test_off_when_env_absent(self, monkeypatch): + """The import-time hook ignores AGENT_VERSION; that fallback needs enable().""" + monkeypatch.delenv("AGENT_COMMIT_SHA", raising=False) monkeypatch.setenv("AGENT_VERSION", SHA) + code_revision._enable_from_environment() assert code_revision.commit_sha() is None assert code_revision.is_enabled() is False + def test_env_set_at_startup_enables_without_a_call(self, monkeypatch): + """The cloud deploy sets AGENT_COMMIT_SHA from the build record; the agent + should not need to know.""" + monkeypatch.setenv("AGENT_COMMIT_SHA", SHA) + code_revision._enable_from_environment() + assert code_revision.commit_sha() == SHA + + def test_env_set_after_import_needs_enable(self, monkeypatch): + monkeypatch.setenv("AGENT_COMMIT_SHA", SHA) + assert code_revision.commit_sha() is None + code_revision.enable() + assert code_revision.commit_sha() == SHA + + def test_bad_env_at_startup_leaves_it_off(self, monkeypatch): + monkeypatch.setenv("AGENT_COMMIT_SHA", "latest") + code_revision._enable_from_environment() + assert code_revision.commit_sha() is None + def test_enable_reads_agent_commit_sha(self, monkeypatch): monkeypatch.setenv("AGENT_COMMIT_SHA", SHA) code_revision.enable() diff --git a/tests/lib/test_agent_card.py b/tests/lib/test_agent_card.py index 5d57f9e8e..7246d7c32 100644 --- a/tests/lib/test_agent_card.py +++ b/tests/lib/test_agent_card.py @@ -189,6 +189,7 @@ def test_defaults(self): assert card.data_events == [] assert card.input_types == [] assert card.output_schema is None + assert card.metadata == {} def test_serialization_roundtrip(self): card = AgentCard(input_types=["text"], data_events=["result"]) @@ -196,6 +197,34 @@ def test_serialization_roundtrip(self): restored = AgentCard.model_validate(dumped) assert restored == card + def test_metadata_accepts_arbitrary_json_object(self): + card = AgentCard( + metadata={ + "permits_capable": True, + "supported_workflows": ["submit", "review"], + "limits": {"max_batch": 5}, + } + ) + assert card.metadata == { + "permits_capable": True, + "supported_workflows": ["submit", "review"], + "limits": {"max_batch": 5}, + } + + def test_metadata_serialization_roundtrip(self): + card = AgentCard(metadata={"permits_capable": True}) + dumped = card.model_dump() + assert dumped["metadata"] == {"permits_capable": True} + restored = AgentCard.model_validate(dumped) + assert restored == card + + def test_metadata_default_instances_are_independent(self): + """Each default metadata is its own dict, not a shared class-level object.""" + card_a = AgentCard() + card_b = AgentCard() + card_a.metadata["mutated"] = True + assert card_b.metadata == {} + # --- AgentCard.from_states --- @@ -247,6 +276,14 @@ def test_state_fields(self, sample_states): assert waiting.accepts == ["text", "doc_upload"] assert waiting.transitions == ["processing"] + def test_metadata_forwarded(self, sample_states): + card = AgentCard.from_states( + initial_state=SampleState.WAITING, + states=sample_states, + metadata={"permits_capable": True}, + ) + assert card.metadata == {"permits_capable": True} + def test_matches_from_state_machine(self, sample_states, sample_sm): """from_states and from_state_machine should produce identical cards.""" card_states = AgentCard.from_states( @@ -315,6 +352,13 @@ def test_no_output_model(self, sample_sm): assert card.data_events == [] assert card.output_schema is None + def test_metadata_forwarded(self, sample_sm): + card = AgentCard.from_state_machine( + state_machine=sample_sm, + metadata={"permits_capable": True}, + ) + assert card.metadata == {"permits_capable": True} + # --- register_agent agent_card merging --- @@ -333,6 +377,8 @@ def mock_env_vars(self): "AGENT_ID": None, "AGENT_INPUT_TYPE": None, "AGENT_API_KEY": None, + "AGENT_COMMIT_SHA": None, + "AGENT_SOURCE_REPO": None, "AGENTEX_DEPLOYMENT_ID": None, })() return mock @@ -370,6 +416,20 @@ async def test_agent_card_merged_into_metadata(self, mock_env_vars): assert metadata["agent_card"]["input_types"] == ["text"] assert metadata["agent_card"]["data_events"] == ["result"] + async def test_agent_card_metadata_propagates_through_registration(self, mock_env_vars): + card = AgentCard(metadata={"permits_capable": True}) + mock_client = self._make_mock_client() + + with patch("agentex.lib.utils.registration.httpx.AsyncClient", return_value=mock_client): + from agentex.lib.utils.registration import register_agent + + await register_agent(mock_env_vars, agent_card=card) + + sent_data = mock_client.post.call_args.kwargs["json"] + metadata = sent_data["registration_metadata"] + + assert metadata["agent_card"]["metadata"] == {"permits_capable": True} + async def test_none_preserved_when_no_card(self, mock_env_vars): mock_client = self._make_mock_client() diff --git a/tests/lib/test_agentex_worker.py b/tests/lib/test_agentex_worker.py index 370bd5e60..b0bf47a63 100644 --- a/tests/lib/test_agentex_worker.py +++ b/tests/lib/test_agentex_worker.py @@ -117,6 +117,169 @@ 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 ae869320d..1bf3629d0 100644 --- a/tests/lib/test_build_provenance.py +++ b/tests/lib/test_build_provenance.py @@ -48,8 +48,9 @@ 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"), # trufflehog:ignore + ("https://x-token:secret@GitHub.com/scaleapi/Repo", "github.com/scaleapi/Repo"), ("ssh://git@gitlab.com/group/sub/proj.git", "gitlab.com/group/sub/proj"), + ("https://github.com/scaleapi/Repo.git?access_token=SECRET#frag", "github.com/scaleapi/Repo"), ("", None), (None, None), ], diff --git a/tests/lib/test_client_timeout_env.py b/tests/lib/test_client_timeout_env.py new file mode 100644 index 000000000..c0d2140a1 --- /dev/null +++ b/tests/lib/test_client_timeout_env.py @@ -0,0 +1,102 @@ +"""Timeouts for the AgentEx client are configurable by environment variable. + +The connect timeout is the one that matters in practice. An AgentEx backend +accepts connections serially, so connect latency grows with the number of +concurrent callers, and the 5s default is reached once a few hundred are in +flight. Before this was configurable, the only way to change it was to pass +``timeout=`` at every construction site, which application code cannot do for +the client the ADK builds internally. +""" + +from __future__ import annotations + +import httpx +import pytest + +from agentex.lib.adk.utils._modules.client import ( + _timeout_from_env, + create_async_agentex_client, +) + + +def test_defaults_match_the_sdk_default_timeout(): + """An unconfigured process must behave exactly as it did before.""" + timeout = _timeout_from_env() + assert timeout.connect == 5.0 + assert timeout.read == 300.0 + assert timeout.write == 300.0 + assert timeout.pool == 300.0 + + +def test_connect_timeout_is_configurable(monkeypatch): + monkeypatch.setenv("AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS", "30") + timeout = _timeout_from_env() + assert timeout.connect == 30.0 + # the others are untouched + assert timeout.read == 300.0 + + +def test_all_four_are_configurable(monkeypatch): + monkeypatch.setenv("AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS", "30") + monkeypatch.setenv("AGENTEX_CLIENT_READ_TIMEOUT_SECONDS", "120") + monkeypatch.setenv("AGENTEX_CLIENT_WRITE_TIMEOUT_SECONDS", "90") + monkeypatch.setenv("AGENTEX_CLIENT_POOL_TIMEOUT_SECONDS", "60") + timeout = _timeout_from_env() + assert (timeout.connect, timeout.read, timeout.write, timeout.pool) == ( + 30.0, + 120.0, + 90.0, + 60.0, + ) + + +def test_an_empty_value_falls_back_to_the_default(): + """An unset variable and one set to the empty string mean the same thing.""" + with pytest.MonkeyPatch.context() as mp: + mp.setenv("AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS", "") + assert _timeout_from_env().connect == 5.0 + + +def test_client_picks_up_the_env_timeout(monkeypatch): + monkeypatch.setenv("AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS", "30") + client = create_async_agentex_client(api_key="test", base_url="http://localhost:5003") + # client.timeout is float | Timeout | None; narrow before reading a component. + assert isinstance(client.timeout, httpx.Timeout) + assert client.timeout.connect == 30.0 + + +def test_explicit_timeout_wins_over_the_environment(monkeypatch): + monkeypatch.setenv("AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS", "30") + client = create_async_agentex_client( + api_key="test", + base_url="http://localhost:5003", + timeout=httpx.Timeout(connect=7.0, read=8.0, write=9.0, pool=10.0), + ) + assert isinstance(client.timeout, httpx.Timeout) + assert client.timeout.connect == 7.0 + + +def test_env_auth_is_still_attached(): + """The factory's original job must survive the change.""" + client = create_async_agentex_client(api_key="test", base_url="http://localhost:5003") + assert client._client.auth is not None + + +def test_a_bad_value_names_the_variable(monkeypatch): + """A malformed value is a configuration error, so it must not be swallowed.""" + monkeypatch.setenv("AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS", "not-a-number") + with pytest.raises(ValueError, match="AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS"): + _timeout_from_env() + + +def test_the_timeout_does_not_depend_on_the_shared_environment_model(monkeypatch): + """Regression: these must not become EnvironmentVariables fields. + + That model has required fields, is loaded by worker startup and by + EnvAuth.auth_flow on every request, and agentex.lib.adk.utils builds a + client at import time. Routing timeouts through it makes all three depend + on a fully configured environment. + """ + monkeypatch.delenv("AGENT_NAME", raising=False) + monkeypatch.delenv("ACP_URL", raising=False) + assert _timeout_from_env().connect == 5.0 diff --git a/tests/lib/test_metadata_filters.py b/tests/lib/test_metadata_filters.py new file mode 100644 index 000000000..34398187f --- /dev/null +++ b/tests/lib/test_metadata_filters.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import json + +import httpx +import respx +import pytest + +from agentex import Agentex, AsyncAgentex +from agentex.lib.utils.metadata_filters import encode_metadata_filter + +BASE_URL = "http://127.0.0.1:4010" +API_KEY = "My API Key" + + +class TestEncodeMetadataFilter: + def test_encodes_a_json_object(self) -> None: + assert encode_metadata_filter({"permits_capable": True}) == '{"permits_capable":true}' + + def test_empty_mapping_encodes_to_an_empty_object(self) -> None: + assert encode_metadata_filter({}) == "{}" + + def test_key_order_is_stable(self) -> None: + assert ( + encode_metadata_filter({"region": "us", "permits_capable": True}) + == encode_metadata_filter({"permits_capable": True, "region": "us"}) + == '{"permits_capable":true,"region":"us"}' + ) + + def test_preserves_json_types_and_nesting(self) -> None: + encoded = encode_metadata_filter({"flag": True, "count": 3, "ratio": 1.5, "nested": {"a": [1, "two", None]}}) + assert json.loads(encoded) == { + "flag": True, + "count": 3, + "ratio": 1.5, + "nested": {"a": [1, "two", None]}, + } + + @pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")]) + def test_rejects_non_finite_floats(self, value: float) -> None: + # The server rejects these with a 400; fail locally with a clearer message. + with pytest.raises(ValueError, match="not encodable as JSON"): + encode_metadata_filter({"x": value}) + + def test_rejects_a_non_mapping(self) -> None: + with pytest.raises(TypeError, match="must be a mapping"): + encode_metadata_filter([("permits_capable", True)]) # type: ignore[arg-type] + + def test_rejects_a_non_serializable_value(self) -> None: + with pytest.raises(TypeError): + encode_metadata_filter({"x": object()}) + + +class TestAgentCardMetadataOnTheWire: + """The encoded filter has to survive the client's query-string serialization. + + The generated `agents.list` parameter is a plain `str` (the platform spec + declares a JSON-encoded string, matching the shipped `task_metadata` + filter), so these assert the exact query value the server will parse. + """ + + @respx.mock(base_url=BASE_URL) + def test_sync_client_sends_the_encoded_object(self, respx_mock: respx.MockRouter) -> None: + route = respx_mock.get("/agents").mock(return_value=httpx.Response(200, json=[])) + + with Agentex(base_url=BASE_URL, api_key=API_KEY, _strict_response_validation=True) as client: + client.agents.list( + agent_card_metadata=encode_metadata_filter({"permits_capable": True, "region": "us"}), + limit=5, + ) + + params = route.calls.last.request.url.params + raw = params["agent_card_metadata"] + assert raw == '{"permits_capable":true,"region":"us"}' + assert json.loads(raw) == {"permits_capable": True, "region": "us"} + assert params["limit"] == "5" + + @respx.mock(base_url=BASE_URL) + async def test_async_client_sends_the_encoded_object(self, respx_mock: respx.MockRouter) -> None: + route = respx_mock.get("/agents").mock(return_value=httpx.Response(200, json=[])) + + async with AsyncAgentex(base_url=BASE_URL, api_key=API_KEY, _strict_response_validation=True) as client: + await client.agents.list( + agent_card_metadata=encode_metadata_filter({"permits_capable": True, "region": "us"}), + limit=5, + ) + + params = route.calls.last.request.url.params + raw = params["agent_card_metadata"] + assert raw == '{"permits_capable":true,"region":"us"}' + assert json.loads(raw) == {"permits_capable": True, "region": "us"} + assert params["limit"] == "5" + + @respx.mock(base_url=BASE_URL) + def test_omitted_filter_is_absent_from_the_query(self, respx_mock: respx.MockRouter) -> None: + route = respx_mock.get("/agents").mock(return_value=httpx.Response(200, json=[])) + + with Agentex(base_url=BASE_URL, api_key=API_KEY, _strict_response_validation=True) as client: + client.agents.list() + + assert "agent_card_metadata" not in route.calls.last.request.url.params + + @respx.mock(base_url=BASE_URL) + def test_empty_object_filter_is_sent_verbatim(self, respx_mock: respx.MockRouter) -> None: + """`{}` is a meaningful filter server-side (agent must have card metadata), + so it must reach the wire rather than being dropped as falsy.""" + route = respx_mock.get("/agents").mock(return_value=httpx.Response(200, json=[])) + + with Agentex(base_url=BASE_URL, api_key=API_KEY, _strict_response_validation=True) as client: + client.agents.list(agent_card_metadata=encode_metadata_filter({})) + + assert route.calls.last.request.url.params["agent_card_metadata"] == "{}" diff --git a/tests/lib/utils/test_logging_level.py b/tests/lib/utils/test_logging_level.py new file mode 100644 index 000000000..16b171e33 --- /dev/null +++ b/tests/lib/utils/test_logging_level.py @@ -0,0 +1,66 @@ +"""Tests for log level resolution in agentex.lib.utils.logging. + +The level used to be pinned to INFO with no override, so a debug() call could +never be emitted on any configuration. That is not just a missing feature: it +made diagnostics that were already written into the SDK unreachable. +""" + +from __future__ import annotations + +import logging + +import pytest + +from agentex.lib.utils.logging import ( + DEFAULT_LOG_LEVEL, + make_logger, + resolve_log_level, +) + + +def test_defaults_to_info_when_unset(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("LOG_LEVEL", raising=False) + + assert resolve_log_level() == DEFAULT_LOG_LEVEL == logging.INFO + + +@pytest.mark.parametrize( + ("configured", "expected"), + [ + ("DEBUG", logging.DEBUG), + ("debug", logging.DEBUG), + (" WaRnInG ", logging.WARNING), + ("ERROR", logging.ERROR), + ("CRITICAL", logging.CRITICAL), + ], +) +def test_reads_level_from_env( + monkeypatch: pytest.MonkeyPatch, configured: str, expected: int +) -> None: + monkeypatch.setenv("LOG_LEVEL", configured) + + assert resolve_log_level() == expected + + +@pytest.mark.parametrize("configured", ["", " ", "VERBOSE", "10x", "TRUE"]) +def test_falls_back_to_info_on_an_unusable_value( + monkeypatch: pytest.MonkeyPatch, configured: str +) -> None: + """A typo must not silently disable logging. + + logging.getLevelName returns the string "Level FOO" for anything it does not + recognise, which would otherwise be handed straight to setLevel. + """ + monkeypatch.setenv("LOG_LEVEL", configured) + + assert resolve_log_level() == logging.INFO + + +def test_make_logger_applies_the_configured_level(monkeypatch: pytest.MonkeyPatch) -> None: + """The regression that mattered: a debug() call must be able to emit.""" + monkeypatch.setenv("LOG_LEVEL", "DEBUG") + + logger = make_logger("agentex.tests.level_from_env") + + assert logger.level == logging.DEBUG + assert logger.isEnabledFor(logging.DEBUG) diff --git a/tests/lib/utils/test_registration.py b/tests/lib/utils/test_registration.py new file mode 100644 index 000000000..65960d757 --- /dev/null +++ b/tests/lib/utils/test_registration.py @@ -0,0 +1,49 @@ +"""Registration metadata: what an agent reports about itself at startup.""" + +from __future__ import annotations + +import pytest + +from agentex.lib.utils.registration import build_registration_metadata +from agentex.lib.environment_variables import EnvironmentVariables + +SHA = "b362b171a9c4e1f09d8e7a6b5c4d3e2f1a0b9c8d" + + +def _env(**overrides) -> EnvironmentVariables: + return EnvironmentVariables(AGENT_NAME="sample-agent", ACP_URL="http://agent", **overrides) + + +def test_nothing_known_yields_empty_metadata(): + assert build_registration_metadata(_env()) == {} + + +def test_commit_and_repo_reported_when_set(): + env = _env(AGENT_COMMIT_SHA=SHA, AGENT_SOURCE_REPO="git@github.com:scaleapi/Demo.git") + assert build_registration_metadata(env) == { + "commit_sha": SHA, + "source_repo": "github.com/scaleapi/Demo", + } + + +@pytest.mark.parametrize("value", ["latest", "v1.2.3", "rocket_mock_agent-" + SHA, "abc", " "]) +def test_non_commit_values_are_omitted_not_forwarded(value): + """A field named for a commit never holds an image tag, same rule as __commit_sha__.""" + assert "commit_sha" not in build_registration_metadata(_env(AGENT_COMMIT_SHA=value)) + + +def test_repo_normalization_strips_scheme_and_credentials(): + env = _env(AGENT_SOURCE_REPO="https://x-token:secret@GitHub.com/scaleapi/Demo.git") + assert build_registration_metadata(env)["source_repo"] == "github.com/scaleapi/Demo" + + +def test_deployment_id_and_agent_card_still_reported(): + class Card: + def model_dump(self): + return {"name": "sample"} + + env = _env(AGENTEX_DEPLOYMENT_ID="dep-1") + assert build_registration_metadata(env, Card()) == { + "deployment_id": "dep-1", + "agent_card": {"name": "sample"}, + } diff --git a/tests/test_client.py b/tests/test_client.py index 7c0177453..131d32fee 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("https://agentex.sgp.scale.com") + assert str(client.base_url).startswith("http://localhost:5003") 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("https://agentex.sgp.scale.com") + assert str(client.base_url).startswith("http://localhost:5003") await client.close() diff --git a/tests/test_request_id_correlation.py b/tests/test_request_id_correlation.py new file mode 100644 index 000000000..b281cf94e --- /dev/null +++ b/tests/test_request_id_correlation.py @@ -0,0 +1,150 @@ +"""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 From bc1a4636a572699f6ee582c7f5ec4a9678f71b5c Mon Sep 17 00:00:00 2001 From: Ari Nguyen Date: Fri, 25 Sep 2026 17:14:55 -0700 Subject: [PATCH 15/15] fix: align the base-URL test with this trunk, and stop Bandit failing on reporting Two failures on the promote pull request, both caught by CI on the staged commits rather than in production. tests/test_client.py asserted the default base URL is http://localhost:5003. This trunk's src/agentex/_client.py resolves https://agentex.sgp.scale.com, which is what stainless.yml configures; production is the stale side of that pair. The restore in 73ea73ea took the whole tests/ tree from production and so pulled the old assertion back with it -- the one file there where production was behind rather than ahead. 1739 tests passed and only these two failed, which is what confined the mistake to this assertion. Bandit's "Send unified results to logging cluster" step is annotated `shell: bash {0} # don't fail the job if the logging fails`, but that only drops `-e`: a step still fails when its LAST command fails, and curl was the last command. Neither this repo nor the production repo defines N8N_PRODSEC_ACTIONS_ENDPOINT or _TOKEN, so curl exited 2 ("no URL specified") and failed the Bandit job on every run. Now it skips with a visible warning when the endpoint is unset and tolerates a failed POST, matching the intent already stated in the step. The scan itself is unchanged and still runs. Co-Authored-By: Claude Opus 5 --- .github/workflows/bandit-ci.yml | 23 ++++++++++++++++++----- tests/test_client.py | 4 ++-- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/.github/workflows/bandit-ci.yml b/.github/workflows/bandit-ci.yml index d4690a71e..bd1e1e023 100644 --- a/.github/workflows/bandit-ci.yml +++ b/.github/workflows/bandit-ci.yml @@ -60,10 +60,23 @@ jobs: # directly, so size is irrelevant; it wraps the file's values in an array, hence [0]. jq --slurpfile scanResults tmp.json '.results += $scanResults[0]' tmp-output.json > output.json - name: Send unified results to logging cluster + # `shell: bash {0}` drops `-e`, but a step still fails when its LAST command does, + # and curl was the last command -- so a repo without these secrets failed the whole + # Bandit job on a reporting problem, contradicting the intent stated right here. + # Neither this repo nor the production repo defines them, so this failed every run. + # Stay non-fatal, but say so rather than reporting nothing silently. shell: bash {0} # don't fail the job if the logging fails + env: + ENDPOINT: ${{ secrets.N8N_PRODSEC_ACTIONS_ENDPOINT }} + TOKEN: ${{ secrets.N8N_PRODSEC_ACTIONS_TOKEN }} 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 }} + if [ -z "${ENDPOINT:-}" ]; then + echo "::warning title=Bandit results not reported::N8N_PRODSEC_ACTIONS_ENDPOINT is not set on this repository, so the scan results were not sent to the logging cluster. The scan itself ran and its findings are in the job log." + exit 0 + fi + curl -sS -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${TOKEN}" \ + -d @./output.json \ + "$ENDPOINT" \ + || echo "::warning title=Bandit results not reported::the POST to the logging cluster failed; the scan itself still ran." 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()